From 8246aa5538ae5d5b8df154a01180df234b47609e Mon Sep 17 00:00:00 2001
From: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:45:13 +0300
Subject: [PATCH] feat: resident guest init (#333)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Get this thing going..
Confidence Score: 5/5
The PR appears safe to merge; all previous findings are resolved and the
latest readiness change introduces no established actionable regression.
Summary
- 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.
Diagram
```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
```
Reviews (4) · Last reviewed commit: ["fix(nesinit): readiness is a
socket
that..."](https://github.com/nestrilabs/nestri/commit/731d34df9df30463f67963363424cb9487e89196)
---------
Co-authored-by: DatCaptainHorse
Co-authored-by: Claude Opus 5
---
Cargo.lock | 113 +-
apps/nescapture/scripts/verify-chain.sh | 15 +
apps/nescope/Cargo.toml | 6 +-
apps/nescope/src/main.rs | 25 +-
apps/nesinit/README.md | 75 +-
apps/nesinit/src/filesystems.rs | 158 ++-
apps/nesinit/src/lib.rs | 12 +-
apps/nesinit/src/main.rs | 54 +-
apps/nesinit/src/services.rs | 959 ++++++++++++++
apps/nesinit/src/session.rs | 1319 ++++++++++++++------
apps/nesinit/src/system.rs | 539 ++++++++
apps/nesinit/src/workload.rs | 209 +++-
apps/nesinit/tests/services_stop.rs | 82 ++
build/Containerfile | 573 +++++++++
build/Containerfile.containerignore | 40 +
build/Containerfile.proton | 102 ++
build/Containerfile.proton.containerignore | 15 +
build/Dockerfile | 309 -----
build/Dockerfile.dockerignore | 29 -
build/Makefile | 45 +-
build/README.md | 230 +++-
build/etc/conf.d/agetty.hvc0.debug | 3 -
build/etc/conf.d/agetty.hvc0.prod | 3 -
build/etc/conf.d/nescope | 16 -
build/etc/conf.d/neshub | 18 -
build/etc/conf.d/nestri-user-env | 34 -
build/etc/conf.d/neswire | 32 -
build/etc/fstab | 35 -
build/etc/init.d/dbus-session | 33 -
build/etc/init.d/dbus-system | 19 -
build/etc/init.d/guest-net | 92 --
build/etc/init.d/nescope | 33 -
build/etc/init.d/neshub | 39 -
build/etc/init.d/neswire | 93 --
build/etc/init.d/pipewire | 45 -
build/etc/init.d/wireplumber | 20 -
build/etc/init.d/xdg-runtime | 44 -
build/etc/profile.d/nestri-env.sh | 22 +-
build/etc/rc.conf | 27 -
build/scripts/mkimage.sh | 4 +-
build/scripts/proton-build.sh | 96 ++
build/scripts/proton-fetch.sh | 57 +
crates/nesprotocol/src/lifecycle.rs | 327 ++++-
43 files changed, 4448 insertions(+), 1553 deletions(-)
create mode 100644 apps/nesinit/src/services.rs
create mode 100644 apps/nesinit/src/system.rs
create mode 100644 apps/nesinit/tests/services_stop.rs
create mode 100644 build/Containerfile
create mode 100644 build/Containerfile.containerignore
create mode 100644 build/Containerfile.proton
create mode 100644 build/Containerfile.proton.containerignore
delete mode 100644 build/Dockerfile
delete mode 100644 build/Dockerfile.dockerignore
delete mode 100644 build/etc/conf.d/agetty.hvc0.debug
delete mode 100644 build/etc/conf.d/agetty.hvc0.prod
delete mode 100644 build/etc/conf.d/nescope
delete mode 100644 build/etc/conf.d/neshub
delete mode 100644 build/etc/conf.d/nestri-user-env
delete mode 100644 build/etc/conf.d/neswire
delete mode 100644 build/etc/fstab
delete mode 100644 build/etc/init.d/dbus-session
delete mode 100644 build/etc/init.d/dbus-system
delete mode 100644 build/etc/init.d/guest-net
delete mode 100644 build/etc/init.d/nescope
delete mode 100644 build/etc/init.d/neshub
delete mode 100644 build/etc/init.d/neswire
delete mode 100644 build/etc/init.d/pipewire
delete mode 100644 build/etc/init.d/wireplumber
delete mode 100644 build/etc/init.d/xdg-runtime
delete mode 100644 build/etc/rc.conf
create mode 100755 build/scripts/proton-build.sh
create mode 100755 build/scripts/proton-fetch.sh
diff --git a/Cargo.lock b/Cargo.lock
index 67f8d545..e9207348 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -628,6 +628,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1022,11 +1028,17 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
-version = "0.8.35"
+version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e"
dependencies = [
"cfg-if",
+ "core_detect",
+ "multiversion",
+ "multiversion_no_op",
+ "rustversion",
+ "scopeguard",
+ "simdutf8",
]
[[package]]
@@ -1243,28 +1255,6 @@ dependencies = [
"slab",
]
-[[package]]
-name = "gbm"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a"
-dependencies = [
- "bitflags 2.13.1",
- "drm",
- "drm-fourcc",
- "gbm-sys",
- "libc",
-]
-
-[[package]]
-name = "gbm-sys"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "generator"
version = "0.8.9"
@@ -1349,17 +1339,6 @@ dependencies = [
"polyval",
]
-[[package]]
-name = "gl_generator"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
-dependencies = [
- "khronos_api",
- "log",
- "xml-rs",
-]
-
[[package]]
name = "glob"
version = "0.3.4"
@@ -2156,12 +2135,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "khronos_api"
-version = "3.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
-
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2371,6 +2344,34 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "multiversion"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
+dependencies = [
+ "multiversion-macros",
+ "target-features",
+]
+
+[[package]]
+name = "multiversion-macros"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "target-features",
+]
+
+[[package]]
+name = "multiversion_no_op"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+
[[package]]
name = "n0-error"
version = "1.0.1"
@@ -3102,24 +3103,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "pixman"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cea217d496c19ac0a8e502b37078e1f683d16344adee9eb247a5d57c165e1edf"
-dependencies = [
- "drm-fourcc",
- "paste",
- "pixman-sys",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "pixman-sys"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1a0483e89e81d7915defe83c51f23f6800594d64f6f4a21253ce87fd8444ada"
-
[[package]]
name = "pkcs8"
version = "0.11.0"
@@ -3905,7 +3888,6 @@ dependencies = [
"atomic_float",
"bitflags 2.13.1",
"calloop",
- "cc",
"cgmath",
"cursor-icon",
"downcast-rs",
@@ -3914,14 +3896,9 @@ dependencies = [
"drm-fourcc",
"encoding_rs",
"errno",
- "gbm",
- "gl_generator",
"indexmap",
"input",
"libc",
- "libloading",
- "pixman",
- "pkg-config",
"profiling",
"rand 0.9.5",
"rustix 1.1.4",
@@ -4103,6 +4080,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+[[package]]
+name = "target-features"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
+
[[package]]
name = "target-lexicon"
version = "0.13.5"
diff --git a/apps/nescapture/scripts/verify-chain.sh b/apps/nescapture/scripts/verify-chain.sh
index 9b184484..b68245fb 100755
--- a/apps/nescapture/scripts/verify-chain.sh
+++ b/apps/nescapture/scripts/verify-chain.sh
@@ -36,6 +36,21 @@ done
echo "building…"
cargo build --release -p nescope -p nescapture --manifest-path "$ROOT/Cargo.toml" >/dev/null
+# The whole method here is two independent instruments on the same frames, and
+# the second one is the compositor's own readback. While nescope's screenshot
+# path is commented out there is no second instrument, so this script cannot
+# make the comparison it exists for. Said here rather than fifty lines later as
+# "compositor readback produced no frames", which reads like a capture bug.
+#
+# Asked of the binary rather than hard-coded, so this comes back by itself on
+# the commit that brings the path back.
+if ! "$ROOT/target/release/nescope" --help 2>&1 | grep -q -- --screenshot-ipc; then
+ echo "this nescope has no --screenshot-ipc, so there is no readback to compare" >&2
+ echo "the encoded frames against; the GPU readback path in nescope is" >&2
+ echo "commented out. See apps/nescope/src/main.rs." >&2
+ exit 1
+fi
+
LAYER="$ROOT/target/release/libnescapture_layer.so"
MANIFEST_DIR="$WORK/implicit_layer.d"
mkdir -p "$MANIFEST_DIR"
diff --git a/apps/nescope/Cargo.toml b/apps/nescope/Cargo.toml
index dfa79a7b..9afca051 100644
--- a/apps/nescope/Cargo.toml
+++ b/apps/nescope/Cargo.toml
@@ -18,13 +18,11 @@ smithay = { version = "0.7", default-features = false, features = [
"backend_drm",
"desktop",
"backend_libinput",
- "renderer_pixman", # needed for on_commit_buffer_handler
+ #"renderer_pixman", # needed for on_commit_buffer_handler
# Reading a dmabuf back to the CPU. nescope still does not composite or
# present anything -- this is import-and-copy only, so a client that
# renders on the GPU can be screenshotted like any other.
- "renderer_gl",
- "backend_egl",
- "backend_gbm",
+ #"backend_gbm",
] }
# Wayland client – connects to the host compositor to forward buffers.
diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs
index 7bd8316f..444136a3 100644
--- a/apps/nescope/src/main.rs
+++ b/apps/nescope/src/main.rs
@@ -60,15 +60,15 @@ use smithay::reexports::wayland_server::Display;
use smithay::wayland::socket::ListeningSocketSource;
mod focus;
-mod gpu_readback;
+//mod gpu_readback;
mod handlers;
mod hdr;
mod input;
mod input_ipc;
mod libinput_backend;
mod protocols;
-mod screenshot_ipc;
-mod screenshot_wire;
+//mod screenshot_ipc;
+//mod screenshot_wire;
mod state;
mod xwm;
@@ -125,14 +125,11 @@ struct Args {
)]
input_ipc: String,
- /// Path to the hub's screenshot IPC socket (nescope connects as client).
- ///
- /// Optional, and absent means the feature is simply off: it exists for
- /// clients that are not games — a Steam login screen has no Vulkan frames
- /// for `nescapture` to take, so its pixels can only come from here.
- #[arg(long, env = "NESCOPE_SCREENSHOT_IPC")]
- screenshot_ipc: Option,
-
+ // There is no `--screenshot-ipc`. The path it named is commented out below,
+ // and an option that is accepted and does nothing is worse than one that is
+ // refused: a caller passing it gets no error, no capture, and nothing to
+ // read that says which. It comes back with the code, or not at all.
+ //
/// GPU render device (e.g. /dev/dri/renderD128). Sets VK_DRIVER_FILES
/// for the game so it uses the same GPU.
#[arg(long, env = "NESCOPE_RENDER_DEVICE")]
@@ -317,13 +314,13 @@ fn main() {
// The GPU to import dmabufs on for screenshots. Same device the game is
// pointed at, because a buffer the game produced can only be imported on
// the device that made it.
- gpu_readback::set_render_device(args.render_device.clone());
+ //gpu_readback::set_render_device(args.render_device.clone());
// ── Screenshot IPC source ────────────────────────────────────────────
// Same dial-out shape as the input socket below, so the hub is the
// listener and there is no race against a socket that does not exist yet.
// Absent means the feature is off, which is the normal case for a game.
- if let Some(path) = args.screenshot_ipc.clone() {
+ /*if let Some(path) = args.screenshot_ipc.clone() {
match screenshot_ipc::ScreenshotIpcSource::connect(&path) {
Ok(source) => match source.try_clone_writer() {
Ok(mut writer) => {
@@ -357,7 +354,7 @@ fn main() {
},
Err(e) => tracing::warn!("Failed to connect to screenshot IPC socket {path}: {e}"),
}
- }
+ }*/
// ── Input IPC source ─────────────────────────────────────────────────
// Connect to the neshub input socket and feed events into the
diff --git a/apps/nesinit/README.md b/apps/nesinit/README.md
index 0aec304e..ecae58fe 100644
--- a/apps/nesinit/README.md
+++ b/apps/nesinit/README.md
@@ -2,8 +2,9 @@
PID 1 inside a box.
-A microVM has no init unless something is it. Three of the jobs are nobody
-else's, and this is all of them:
+A microVM has no init unless something is it, and in a box nothing else is:
+there is no service manager in the image and no init scripts. Four jobs, and
+this is all of them:
- **Reaping.** A process whose parent dies is reparented to PID 1. Without a
reaper, every orphan the workload leaves behind holds a pid and a slot in the
@@ -11,25 +12,46 @@ else's, and this is all of them:
- **Ordered shutdown.** The workload stops first and alone, then everything
else, then the disks are flushed and the machine is powered off. An init that
returns leaves a guest running with nothing in it.
+- **The box's own services.** The bus, audio, and the transport that carries a
+ session out, started in order from a table compiled into this binary. There
+ is no unit format and no directory of files to read: the services in a box
+ are fixed, and running on any distribution comes from depending on none of
+ their init scripts rather than from being configurable.
- **The guest end of the control channel.** One vsock connection out, carrying
what to run in and what happened back.
-It does not know what it is running. It is handed a command line, a set of
-shares and what an exit means; there is no code path here that branches on
-which workload it started, and there is not meant to be.
+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; there is no code path here
+that branches on which workload it started, and there is not meant to be.
+
+**A box outlives what runs in it.** Init mounts, brings the services up, says
+it is ready, and then takes commands for as long as the box lives — so this
+image on its own runs nothing at all, and a box may be launched into more than
+once.
### The channel
The guest dials out on a fixed vsock port and speaks first:
```
-guest → { "type": "ready", "protocol_version": 2 }
-guest ← { "type": "boot", "exec": {...}, "mounts": [...], "geometry": {...}, "on_exit": {...} }
+guest → { "type": "ready", "protocol_version": 3 }
+guest ← { "type": "boot", "mounts": [...] }
guest → { "type": "mounted" }
-guest → { "type": "started" }
-guest → { "type": "workload_exited", "exit_code": 0 }
+guest → { "type": "initialized", "services": ["dbus-system", ...] }
+guest ← { "type": "launch", "id": "…", "exec": {...}, "on_exit": {...} }
+guest → { "type": "started", "id": "…" }
+guest → { "type": "workload_exited", "id": "…", "exit_code": 0 }
```
+`ready` is the handshake and `initialized` is the box working. They are two
+facts and must not be treated as one: a caller that waits on the first has a
+wait that succeeds before anything in the guest has started.
+
+Every launch carries an id and every message about a launch carries it back.
+Without one, a second launch's exit is indistinguishable from the first's —
+which reads at the far end as a finished session still running, or a running
+one reported as stopped.
+
Newline-delimited JSON. Dialling out rather than being connected to is worth
keeping for two reasons: the listener is up before the VM starts, so nothing
races a booting kernel and nothing has to retry, and the connection
@@ -109,19 +131,40 @@ later for no visible reason.
### It reports; it does not supervise
-When the workload ends, the exit goes up the channel and the session is over.
-`on_exit` says what that exit *means* — whether it ends the session — and
-nothing here restarts anything. Starting something again is a decision for the
-end that can see whether restarting is repair or a loop.
+When a launch ends, the exit goes up the channel. `on_exit` says what that exit
+*means* — whether it ends the session or leaves the box up to be launched into
+again — and nothing here restarts anything of its own accord. Starting
+something again is a decision for the end that can see whether restarting is
+repair or a loop.
+
+`restart` exists as one message and is defined as exactly that: a kill followed
+by a launch of the same command, keeping the id, with no retry and no backoff.
+It is one message rather than two only because a caller sending two has the
+same effect with a worse race in it.
+
+The same rule covers the box's own services. One that dies is **reported and
+left dead** — nothing else in the guest is watching them, so a death that is
+not said here is a box that looks healthy and cannot work.
+
+**One launch at a time.** A launch arriving while one is running is refused,
+carrying the id it was asked for, rather than queued or silently replacing it:
+a box has one screen, so a second concurrent launch has nowhere to draw.
A signalled workload is reported as signalled, with no exit code. Reporting
`0` for a killed process would make a kill look like a clean run.
### What is not here yet
-`geometry` is carried and parsed but nothing consumes it: nesinit does not
-start the guest's own services yet. `ticket` exists as a message with no
-producer wired to it.
+**It has never been PID 1 of anything.** Every line of this is written for a
+box and all of it has been tested outside one. It runs perfectly well as an
+ordinary process — it warns rather than fails when it is not PID 1 — which is
+how most of it is exercised, and is also how a guest that will not boot gets
+debugged: `init=/bin/bash` on the kernel command line, then run this by hand
+and watch it fail.
+
+Output geometry is deliberately absent from this layer. The compositor is
+started by a launch, with that launch's geometry in its own arguments, so the
+numbers appear in one place rather than two that can disagree.
### Testing
diff --git a/apps/nesinit/src/filesystems.rs b/apps/nesinit/src/filesystems.rs
index 04d03d5e..05cb5fd5 100644
--- a/apps/nesinit/src/filesystems.rs
+++ b/apps/nesinit/src/filesystems.rs
@@ -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
diff --git a/apps/nesinit/src/lib.rs b/apps/nesinit/src/lib.rs
index 32df7edd..85730fe0 100644
--- a/apps/nesinit/src/lib.rs
+++ b/apps/nesinit/src/lib.rs
@@ -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;
diff --git a/apps/nesinit/src/main.rs b/apps/nesinit/src/main.rs
index cb44cbed..78c2728c 100644
--- a/apps/nesinit/src/main.rs
+++ b/apps/nesinit/src/main.rs
@@ -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 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
diff --git a/apps/nesinit/src/services.rs b/apps/nesinit/src/services.rs
new file mode 100644
index 00000000..856679a6
--- /dev/null
+++ b/apps/nesinit/src/services.rs
@@ -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, 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;
+}
+
+// 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,
+ /// 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,
+ reported: Sender,
+}
+
+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 {
+ 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) -> 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, 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 {
+ &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 {
+ 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,
+ 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,
+ pub names: Vec,
+ deaths: Receiver,
+ pub report: Sender,
+ }
+
+ 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, Failure> {
+ self.brought_up += 1;
+ match &self.failure {
+ Some(failure) => Err(failure.clone()),
+ None => Ok(self.names.clone()),
+ }
+ }
+
+ fn deaths(&mut self) -> &mut Receiver {
+ &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"
+ );
+ }
+}
diff --git a/apps/nesinit/src/session.rs b/apps/nesinit/src/session.rs
index a94e0b33..c40d8bc8 100644
--- a/apps/nesinit/src/session.rs
+++ b/apps/nesinit/src/session.rs
@@ -1,44 +1,76 @@
// The guest end of the control channel.
//
// The shape of the exchange, and none of it is negotiable from this side: the
-// guest speaks first with its version, is handed one boot descriptor, and from
-// then on reports. It is not a supervisor: when the workload ends, the exit
-// goes up the channel and this returns. Starting something again is the
-// caller's decision, because the caller is the only end that can see whether
-// restarting is repair or a loop. ref(d-0033)
+// guest speaks first with its version, is handed one document describing the
+// box, brings the box's own services up, says so — and then takes commands for
+// as long as the box lives. ref(d-0064)
+//
+// It is not a supervisor. Nothing here restarts anything of its own accord: a
+// workload's exit is reported, a service's death is reported, and starting
+// something again is the caller's decision, because the caller is the only end
+// that can see whether restarting is repair or a loop. ref(d-0033)
+//
+// # One launch at a time
+//
+// A box may be launched into many times over its life, and not twice at once.
+// A launch arriving while one is running is refused with the id it was asked
+// for, rather than queued or silently replacing it — a box has one compositor
+// and one screen, so a second concurrent launch has nowhere to draw. Nothing
+// in the wire format forbids it if that ever changes; this is a property of
+// this implementation and it says so where it refuses.
use nesprotocol::lifecycle::{
- CONTROL_VERSION, Exit, GuestToHost, HostToGuest, Payload, from_line, to_line,
+ CONTROL_VERSION, Exec, Exit, GuestToHost, HostToGuest, LaunchId, OnExit, Payload, from_line,
+ to_line,
};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use crate::payload::Ports;
+use crate::services::{Died, Services};
use crate::workload::{Exited, Failure, Workload};
use tokio::sync::mpsc::Receiver;
/// How a session ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
- /// The workload ended and the exit was reported.
+ /// A launch whose exit was terminal ended, and the exit was reported.
WorkloadExited(Exit),
/// The caller asked for a shutdown.
Shutdown,
/// The channel closed under us. Not an error by itself — a caller that has
/// stopped listening has also stopped being able to tell us to stop.
ChannelClosed,
- /// The descriptor could not be carried out. The reason is the operating
- /// system's, verbatim.
+ /// The box could not be made. The reason is the operating system's,
+ /// verbatim.
+ ///
+ /// This is the descriptor or the service stack, never a launch: a command
+ /// that will not run is reported and the box stays up, because a box that
+ /// dies of a bad `argv` cannot be told a better one.
Refused(Failure),
}
+/// The launch that is running, and what it would take to start it again.
+struct Current {
+ id: LaunchId,
+ /// Kept so a restart is the same command rather than a new one the caller
+ /// has to re-send.
+ exec: Exec,
+ on_exit: OnExit,
+ exited: Exited,
+ /// Set by `restart`, read when the exit arrives. A restart is a kill
+ /// followed by a launch and this is the "followed by".
+ relaunch: bool,
+}
+
/// Run one session over an already-connected channel.
///
/// Generic over the channel so the exchange can be driven from a test without
/// a VM: the transport contributes nothing to the protocol beyond ordering and
/// framing, which any byte stream has.
-pub async fn run(
+pub async fn run(
channel: C,
workload: &mut W,
+ services: &mut S,
payload: &mut Ports,
addresses: &mut Receiver,
untrusted: &crate::ticket::Untrusted,
@@ -46,8 +78,9 @@ pub async fn run(
where
C: AsyncRead + AsyncWrite,
W: Workload,
+ S: Services,
{
- match converse(channel, workload, payload, addresses, untrusted).await {
+ match converse(channel, workload, services, payload, addresses, untrusted).await {
Err(error) if channel_gone(&error) => {
// A caller that has stopped reading has also stopped being able to
// tell us to stop, which is the same situation as the channel
@@ -69,9 +102,10 @@ fn channel_gone(error: &std::io::Error) -> bool {
)
}
-async fn converse(
+async fn converse(
channel: C,
workload: &mut W,
+ services: &mut S,
payload: &mut Ports,
addresses: &mut Receiver,
untrusted: &crate::ticket::Untrusted,
@@ -79,6 +113,7 @@ async fn converse(
where
C: AsyncRead + AsyncWrite,
W: Workload,
+ S: Services,
{
let (reader, mut writer) = tokio::io::split(channel);
let mut lines = BufReader::new(reader).lines();
@@ -94,29 +129,88 @@ where
)
.await?;
- let mut running: Option = None;
+ let mut running: Option = None;
+ let mut booted = false;
let mut relay_open = true;
let mut carrier_open = true;
+ // Guarded like the other two, and for the same reason: a closed channel
+ // resolves immediately and forever, so an unguarded branch on one turns
+ // this loop into a spin that still looks like it is waiting.
+ let mut services_open = true;
loop {
let event = match running.as_mut() {
- Some(exited) => tokio::select! {
- ended = exited => Event::Ended(ended?),
+ Some(current) => tokio::select! {
+ ended = &mut current.exited => Event::Ended(ended?),
line = lines.next_line() => Event::Line(line?),
up = payload.from_workload.recv(), if relay_open => Event::FromWorkload(up),
found = addresses.recv(), if carrier_open => Event::Address(found),
+ gone = services.deaths().recv(), if services_open => Event::ServiceDied(gone),
},
None => tokio::select! {
line = lines.next_line() => Event::Line(line?),
up = payload.from_workload.recv(), if relay_open => Event::FromWorkload(up),
found = addresses.recv(), if carrier_open => Event::Address(found),
+ gone = services.deaths().recv(), if services_open => Event::ServiceDied(gone),
},
};
let line = match event {
Event::Ended(exit) => {
- send(&mut writer, &GuestToHost::WorkloadExited { exit }).await?;
- return Ok(Outcome::WorkloadExited(exit));
+ // Taken before anything is sent: whatever happens next, this
+ // launch is no longer the running one, and a failure to
+ // relaunch must not leave a dead launch looking live.
+ let ended = running.take().expect("an exit came from a launch");
+ send(
+ &mut writer,
+ &GuestToHost::WorkloadExited {
+ id: ended.id.clone(),
+ exit,
+ },
+ )
+ .await?;
+
+ if ended.relaunch {
+ // A restart is a kill followed by a launch of the same
+ // command, and this is the second half. The id is kept, so
+ // a caller sees the exit and then the same launch running
+ // again rather than having to correlate a new name.
+ running = start(
+ &mut writer,
+ workload,
+ untrusted,
+ ended.id,
+ ended.exec,
+ ended.on_exit,
+ )
+ .await?;
+ continue;
+ }
+
+ // Terminal is the launch's own answer, not this end's. A
+ // non-terminal exit leaves the box up and waiting to be
+ // launched into again, which is the whole point of the box
+ // outliving what runs in it. ref(d-0064)
+ if ended.on_exit.terminal {
+ return Ok(Outcome::WorkloadExited(exit));
+ }
+ tracing::info!(launch = %ended.id, "a launch ended and the box stays up");
+ continue;
+ }
+ Event::ServiceDied(Some(Died { name, exit })) => {
+ // Reported, never repaired. Nothing else in this guest is
+ // watching these, so a death that is not said here is a box
+ // that looks healthy and cannot work. ref(d-0064)
+ tracing::error!(service = %name, ?exit, "a service died");
+ send(&mut writer, &GuestToHost::ServiceDied { name, exit }).await?;
+ continue;
+ }
+ Event::ServiceDied(None) => {
+ // The supervisor is gone, which on the way down is ordinary.
+ // Nothing to report and nothing to end: a box with no service
+ // stack left can still be stopped and still has to report it.
+ services_open = false;
+ continue;
}
Event::FromWorkload(Some(payload)) => {
tracing::debug!(envelope = %payload.summary(), "sending an envelope on");
@@ -166,14 +260,15 @@ where
match message {
HostToGuest::Boot { descriptor } => {
- if running.is_some() {
+ if booted {
tracing::warn!("ignoring a second descriptor: one is read per connection");
continue;
}
+ booted = true;
- // The shares, then the command, and each reported separately.
- // Which of the two failed decides what is worth looking at,
- // so the two are never one message.
+ // The shares, then the services, and each reported separately.
+ // Which of the two failed decides what is worth looking at, so
+ // the two are never one message.
match workload.mount(&descriptor.mounts) {
Ok(()) => send(&mut writer, &GuestToHost::Mounted).await?,
Err(failure) => {
@@ -188,20 +283,18 @@ where
}
}
- // Before the workload exists, so there is no window in which
- // it is running and something else would still be trusted to
- // serve this session's address.
- untrusted.is(descriptor.exec.uid);
-
- match workload.start(&descriptor.exec) {
- Ok(exited) => {
- send(&mut writer, &GuestToHost::Started).await?;
- running = Some(exited);
+ // A box whose own services will not come up cannot be launched
+ // into, so this is refused rather than reported and carried on
+ // from — unlike a launch, which is the caller's to correct.
+ match services.bring_up() {
+ Ok(up) => {
+ tracing::info!(services = up.len(), "the box is ready to be launched into");
+ send(&mut writer, &GuestToHost::Initialized { services: up }).await?
}
Err(failure) => {
send(
&mut writer,
- &GuestToHost::StartFailed {
+ &GuestToHost::InitFailed {
reason: failure.reason.clone(),
},
)
@@ -210,19 +303,142 @@ where
}
}
}
+ HostToGuest::Launch { id, exec, on_exit } => {
+ if !booted {
+ // Before the descriptor there are no shares and no
+ // services, so whatever this launch expects to find is not
+ // there yet. Refused with its own id rather than run into
+ // a box that is not finished.
+ refuse(&mut writer, id, "the box has not been told what it is").await?;
+ continue;
+ }
+ if let Some(current) = &running {
+ let reason = format!("this box is already running a launch: {}", current.id);
+ refuse(&mut writer, id, &reason).await?;
+ continue;
+ }
+ running = start(&mut writer, workload, untrusted, id, exec, on_exit).await?;
+ }
+ HostToGuest::Stop { id } => match &running {
+ // Idempotent, and never ends the session by itself.
+ Some(current) if current.id == id => workload.signal_stop(),
+ // Not an error and not worth failing: a caller stopping
+ // something that has already stopped got what it asked for.
+ // Signalling anyway would aim a kill at whatever is running
+ // now, which is the one thing this must not do.
+ _ => tracing::debug!(launch = %id, "nothing with that id is running to stop"),
+ },
+ HostToGuest::Restart { id } => match running.as_mut() {
+ Some(current) if current.id == id => {
+ // Kill now, launch when the exit arrives. Doing both here
+ // would start the second before the first had gone.
+ current.relaunch = true;
+ workload.signal_stop();
+ }
+ // Nothing is remembered about a launch that has already ended,
+ // so this cannot be a launch in disguise: the caller has the
+ // command and can send it.
+ _ => {
+ refuse(
+ &mut writer,
+ id,
+ "nothing with that id is running to restart",
+ )
+ .await?;
+ }
+ },
HostToGuest::Payload { payload: envelope } => hand_over(payload, envelope),
- HostToGuest::Stop => workload.signal_stop(),
HostToGuest::Shutdown => return Ok(Outcome::Shutdown),
}
}
}
-/// What the session is waiting on, and there are only three things.
+/// Start one launch, reporting which of the two things happened.
+///
+/// Returns `None` when it could not be started, which is not the end of the
+/// session: a box that dies of a bad `argv` cannot be told a better one.
+async fn start(
+ writer: &mut Wr,
+ workload: &mut W,
+ untrusted: &crate::ticket::Untrusted,
+ id: LaunchId,
+ exec: Exec,
+ on_exit: OnExit,
+) -> std::io::Result