Files
netris-nestri/crates/nesprotocol/src/lifecycle.rs
Kristian Ollikainen 8246aa5538 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>
2026-09-14 14:45:13 +03:00

621 lines
23 KiB
Rust

// The lifecycle layer of the control channel between a box and whatever runs
// it: the document describing the box, the commands that run things inside it,
// and what the guest says back about carrying either out.
//
// It lives beside the media types for the same reason they live here — one
// definition, so the two ends cannot drift from each other silently.
//
// Nothing in this module describes *what* the guest runs. A set of share tags,
// a command line, and what an exit means: that is the whole vocabulary, and a
// field that only makes sense for one kind of workload does not belong in it.
// ref(d-0033)
//
// # The box outlives what runs in it
//
// The guest init is resident: it mounts what the descriptor names, brings up
// the box's service stack, says so, and then takes commands for as long as the
// box lives. So the descriptor describes the *box* — which shares are mounted
// where — and a command describes an occupant. A box may be launched into many
// times. ref(d-0064)
//
// That is why every launch carries an id and every event about a launch carries
// it back. Without one, a second launch's exit is indistinguishable from the
// first's, which reads as an ended session that keeps billing or a running one
// reported as stopped.
//
// # What is deliberately absent
//
// **Output geometry.** The compositor wraps the workload rather than running as
// a service, so it is started by a launch with that launch's geometry in its own
// argv, and the numbers appear nowhere else. Two sources of truth for one number
// is a worse failure than either choice, because the wrong one is used silently.
//
// The channel also carries a second layer, which the guest relays as opaque
// bytes and never parses. Those types land with the relay that needs them.
use std::collections::BTreeMap;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
/// The vsock port the guest dials.
///
/// The guest dials out rather than being connected to, which 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 establishing is
/// itself the liveness signal, without which a caller needs a timeout to tell a
/// slow boot from a dead one.
pub const CONTROL_PORT: u32 = 7000;
/// Version of this layer. Both ends compare it during the handshake and refuse
/// on mismatch, so a guest built against one version meeting a caller built
/// against another fails immediately and legibly, rather than later on a field
/// that turned out to be missing.
///
/// Adding a variant or a field does not need a bump; removing or renaming one
/// does.
///
/// Version 3 took three fields off the descriptor and put an id on three
/// messages, so a version-2 peer and a version-3 peer do not talk at all.
/// There is deliberately no shim: nothing is deployed, and a shim would be the
/// second definition of this wire that one shared crate exists to prevent.
pub const CONTROL_VERSION: u32 = 3;
/// The command to run, and who runs it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Exec {
/// The program and its arguments. Never a shell string: a guest that splits
/// words is a guest that can split them differently than the caller meant.
pub argv: Vec<String>,
/// Environment for the process. Sorted, so two descriptors that say the
/// same thing serialize identically.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Working directory. `None` means the root of the guest filesystem.
#[serde(default)]
pub cwd: Option<String>,
/// The uid and gid to drop to before exec.
///
/// These are load-bearing rather than hygiene. Whoever writes this
/// descriptor is also whoever exported the writable share, so the ids have
/// to agree; when they do not, the share refuses the first write and the
/// failure surfaces here as `EACCES` with a path, instead of as a workload
/// that misbehaves much later for no visible reason.
pub uid: u32,
pub gid: u32,
}
/// One share to mount, named by tag.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mount {
/// The share's tag. Never a path on the other side of the channel — the
/// guest learns nothing about the filesystem it is being handed a piece of.
pub tag: String,
/// Where it lands inside the guest.
///
/// The caller names this, not the guest: choosing a mount point means
/// knowing what the workload expects to find there, which is exactly the
/// knowledge a workload-independent init does not have. ref(d-0033)
pub at: String,
#[serde(default)]
pub ro: bool,
}
/// Names one launch, for as long as anything has something to say about it.
///
/// Minted by the caller and only ever echoed by the guest. A guest that
/// generated these would be naming things the caller then has to correlate
/// against something else.
///
/// Opaque on purpose: nothing in this layer parses it, and a caller that wants
/// meaning in it can put meaning in it without this crate having an opinion.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LaunchId(pub String);
impl LaunchId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for LaunchId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// What the workload exiting means for the session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnExit {
/// Whether the exit ends the session.
///
/// This says what an exit *means*; it is not a restart policy. The guest
/// reports the exit and stops, and starting something again is a new
/// command from the caller — the only end that can see whether restarting
/// is repair or a loop. ref(d-0033)
///
/// It rides on the launch rather than on the descriptor, because a box that
/// can be launched into repeatedly cannot have one answer to this fixed at
/// boot. ref(d-0064)
pub terminal: bool,
}
/// What the box *is*, in one document: the shares it has and where they land.
///
/// Sent once, immediately after the handshake, and read once. Deliberately not
/// a conversation: this much is a document, and a document cannot half-arrive.
/// What runs *in* the box is a conversation, and a separate one — see
/// [`HostToGuest::Launch`]. ref(d-0064)
///
/// An empty `mounts` is legitimate. A box with nothing mounted still boots and
/// still brings up its services.
///
/// `deny_unknown_fields` is load-bearing rather than strictness for its own
/// sake. A descriptor still carrying a command line is a caller that has not
/// been updated, and the default behaviour — ignore what it does not recognise —
/// would mount the shares, silently drop the command, and leave a box that came
/// up correctly and runs nothing. Refusing it says so instead.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BootDescriptor {
#[serde(default)]
pub mounts: Vec<Mount>,
}
/// How a workload ended.
///
/// Exactly one of these is set: a process that was signalled has no exit code,
/// and reporting `0` for one would make a kill look like a clean run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Exit {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub signal: Option<i32>,
}
impl Exit {
pub fn code(code: i32) -> Self {
Self {
exit_code: Some(code),
signal: None,
}
}
pub fn signal(signal: i32) -> Self {
Self {
exit_code: None,
signal: Some(signal),
}
}
}
/// What the guest says.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GuestToHost {
/// First line on the connection, before anything else is read or written.
///
/// **This is the handshake, not readiness.** It says a connection exists
/// and both ends speak the same version. Whether anything in the box works
/// is [`GuestToHost::Initialized`], which is a different fact and must not
/// be merged with this one — a caller that treats this as readiness has a
/// wait stage that succeeds before the guest has started anything.
Ready { protocol_version: u32 },
/// Every share the descriptor named is where it said to put it.
Mounted,
/// A share could not be mounted, in the words the operating system used.
///
/// Kept separate from `StartFailed` because the two want different things
/// looked at: a share that did not appear and a command that did not run
/// are not the same incident.
MountFailed { reason: String },
/// The box's own services are up and it will accept launches.
///
/// The one fact a caller waits on before it may launch anything. It names
/// what came up, so a log says which — an empty list is a box with no
/// service stack, which is legitimate and worth being able to see.
Initialized {
#[serde(default)]
services: Vec<String>,
},
/// The box's services could not be brought up, in the words of whatever
/// refused.
///
/// Kept separate from [`GuestToHost::MountFailed`] and
/// [`GuestToHost::StartFailed`] for the reason those two are separate from
/// each other: a share that did not appear, a box that could not be made,
/// and a command that did not run are three incidents that want three
/// different things looked at. A box in this state cannot be launched into
/// at all, which is what distinguishes it from a refused launch.
InitFailed { reason: String },
/// A service in the box's own stack exited.
///
/// **Reported, never repaired.** Nothing else in the guest is watching
/// these, so a death that is not said here is a box that looks healthy and
/// cannot work. Restarting one is a decision for whoever can see whether
/// restarting is repair or a loop, and that is not this end. ref(d-0064)
ServiceDied {
name: String,
#[serde(flatten)]
exit: Exit,
},
/// The launch with this id is running.
Started { id: LaunchId },
/// The launch with this id could not be run, in the words the operating
/// system used.
StartFailed { id: LaunchId, reason: String },
/// The launch with this id has ended. Terminal or not is the launch's own
/// answer, not this message's.
WorkloadExited {
id: LaunchId,
#[serde(flatten)]
exit: Exit,
},
/// How a client reaches this box's media, once it is known.
Ticket { ticket: String },
/// Bytes from the workload, relayed. See [`Payload`].
Payload {
#[serde(flatten)]
payload: Payload,
},
}
/// What the guest is told.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HostToGuest {
/// The boot descriptor. One per connection.
Boot {
#[serde(flatten)]
descriptor: Box<BootDescriptor>,
},
/// Run something in the box. Any number of times, after `initialized`.
///
/// The caller mints `id` and every message about this launch carries it
/// back. `on_exit` belongs here rather than on the descriptor because a box
/// that can be launched into repeatedly has one answer per launch, not one
/// per boot. ref(d-0064)
Launch {
id: LaunchId,
exec: Exec,
on_exit: OnExit,
},
/// Stop one launch. Idempotent, and does not end the session.
Stop { id: LaunchId },
/// Stop one launch and start it again with the same command.
///
/// **Defined as a kill followed by a launch of the same `Exec`, and nothing
/// more.** No retry, no backoff, no policy of any kind in the guest — it
/// exists as one message only because a caller sending two has the same
/// effect with a worse race in it. The relaunch keeps the id. ref(d-0064)
Restart { id: LaunchId },
/// Shut the guest down.
Shutdown,
/// Bytes for the workload, relayed. See [`Payload`].
Payload {
#[serde(flatten)]
payload: Payload,
},
}
/// The second layer of the channel: bytes the guest carries and never reads.
///
/// `body` is a string rather than nested JSON, and that is the structural part
/// of it. A document the guest can index into is a document the guest can grow
/// to depend on, and then this layer is no longer opaque and the boundary it
/// exists to draw is gone.
///
/// **An envelope is never logged.** Not the body, not truncated, not at debug
/// level. The channel name and the byte count are the whole of what may be
/// said about one, because what crosses here includes credentials meant for
/// the workload and nothing else. ref(d-0033)
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Payload {
/// Which conversation this belongs to. Loggable.
pub channel: String,
/// Opaque bytes. Never logged, never parsed, never inspected.
pub body: String,
}
impl Payload {
pub fn new(channel: impl Into<String>, body: impl Into<String>) -> Self {
Self {
channel: channel.into(),
body: body.into(),
}
}
/// What may be said about an envelope, and all of it.
pub fn summary(&self) -> String {
format!("{} ({} bytes)", self.channel, self.body.len())
}
}
/// Written by hand, and it is load-bearing: a derived `Debug` puts the body
/// one careless `{:?}` away from a log line.
impl std::fmt::Debug for Payload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Payload")
.field("channel", &self.channel)
.field("body", &format_args!("<{} bytes>", self.body.len()))
.finish()
}
}
/// Encode one message as a line, framing included.
///
/// Newline-delimited JSON: the channel is a byte stream, so it needs a frame,
/// and a frame a person can read in a log of the channel itself is worth more
/// here than a compact one.
pub fn to_line<T: Serialize>(message: &T) -> Result<String, serde_json::Error> {
let mut line = serde_json::to_string(message)?;
line.push('\n');
Ok(line)
}
/// Decode one line. The trailing newline is optional, so a caller may pass what
/// a line-oriented reader handed it either way.
pub fn from_line<T: DeserializeOwned>(line: &str) -> Result<T, serde_json::Error> {
serde_json::from_str(line.trim_end_matches(['\n', '\r']))
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor() -> BootDescriptor {
BootDescriptor {
mounts: vec![Mount {
tag: "install".into(),
at: "/mnt/install".into(),
ro: true,
}],
}
}
fn exec() -> Exec {
Exec {
argv: vec!["/usr/bin/true".into()],
env: BTreeMap::from([("HOME".to_string(), "/mnt/user".to_string())]),
cwd: Some("/mnt/user".into()),
uid: 1000,
gid: 1000,
}
}
#[test]
fn a_line_round_trips() {
let line = to_line(&HostToGuest::Boot {
descriptor: Box::new(descriptor()),
})
.unwrap();
assert!(line.ends_with('\n'), "a line has to carry its own frame");
assert!(!line.trim_end().contains('\n'), "one message is one line");
let back: HostToGuest = from_line(&line).unwrap();
assert_eq!(
back,
HostToGuest::Boot {
descriptor: Box::new(descriptor())
}
);
}
#[test]
fn a_launch_round_trips_with_its_id() {
let launch = HostToGuest::Launch {
id: LaunchId::new("l-1"),
exec: exec(),
on_exit: OnExit { terminal: true },
};
let back: HostToGuest = from_line(&to_line(&launch).unwrap()).unwrap();
assert_eq!(back, launch);
}
/// The reason ids exist: a caller has to be able to tell which launch it is
/// being told about, or the second one's exit overwrites the first's record.
#[test]
fn every_event_about_a_launch_carries_the_launch_it_is_about() {
let first = LaunchId::new("l-1");
let second = LaunchId::new("l-2");
let events = [
GuestToHost::Started { id: first.clone() },
GuestToHost::StartFailed {
id: first.clone(),
reason: "ENOENT".into(),
},
GuestToHost::WorkloadExited {
id: first.clone(),
exit: Exit::code(0),
},
];
for event in events {
let line = to_line(&event).unwrap();
assert!(
line.contains(first.as_str()) && !line.contains(second.as_str()),
"an event does not say which launch it is about: {line}"
);
let back: GuestToHost = from_line(&line).unwrap();
assert_eq!(back, event);
}
}
/// The descriptor describes the box. A caller still sending a command in it
/// has not been updated, and the cost of accepting one quietly is a box that
/// mounts, comes up, and runs nothing.
#[test]
fn a_descriptor_carrying_a_command_is_refused_rather_than_ignored() {
let stale = r#"{"exec":{"argv":["/bin/sh"],"uid":1000,"gid":1000},
"mounts":[],
"geometry":{"width":1280,"height":720,"fps":60},
"on_exit":{"terminal":true}}"#;
let parsed: Result<BootDescriptor, _> = from_line(stale);
assert!(
parsed.is_err(),
"a descriptor with a command in it parsed: {parsed:?}"
);
}
/// Geometry is in the launched argv and nowhere else, so there is no field
/// here for it to disagree with.
#[test]
fn geometry_is_not_on_this_layer() {
let line = to_line(&HostToGuest::Boot {
descriptor: Box::new(descriptor()),
})
.unwrap();
for named in ["width", "height", "fps", "hdr", "geometry"] {
assert!(
!line.contains(named),
"the descriptor names {named}, which belongs in the launch: {line}"
);
}
}
/// A dead service is not a dead workload: they are different incidents and
/// want different things looked at.
#[test]
fn a_dead_service_says_which_one_and_how() {
let died = GuestToHost::ServiceDied {
name: "pipewire".into(),
exit: Exit::signal(9),
};
let line = to_line(&died).unwrap();
assert!(line.contains("pipewire"), "no service name: {line}");
assert!(
!line.contains("exit_code"),
"a signalled service has no exit code: {line}"
);
let back: GuestToHost = from_line(&line).unwrap();
assert_eq!(back, died);
}
/// `ready` is the handshake and `initialized` is the box working. A caller
/// waiting on the wrong one succeeds before anything has started.
#[test]
fn readiness_and_initialisation_are_two_messages() {
let ready = to_line(&GuestToHost::Ready {
protocol_version: CONTROL_VERSION,
})
.unwrap();
let initialized = to_line(&GuestToHost::Initialized {
services: vec!["dbus".into(), "pipewire".into()],
})
.unwrap();
assert_ne!(ready, initialized);
// An empty stack is legitimate and has to survive the round trip, or a
// box with no services looks like a box that never came up.
let empty = GuestToHost::Initialized { services: vec![] };
let back: GuestToHost = from_line(&to_line(&empty).unwrap()).unwrap();
assert_eq!(back, empty);
}
#[test]
fn a_signalled_exit_is_not_a_zero_exit() {
let signalled = to_line(&GuestToHost::WorkloadExited {
id: LaunchId::new("l-1"),
exit: Exit::signal(9),
})
.unwrap();
assert!(
!signalled.contains("exit_code"),
"a signalled workload has no exit code: {signalled}"
);
let clean = to_line(&GuestToHost::WorkloadExited {
id: LaunchId::new("l-1"),
exit: Exit::code(0),
})
.unwrap();
assert!(
!clean.contains("signal"),
"a clean exit was not signalled: {clean}"
);
}
#[test]
fn an_envelope_does_not_print_its_body() {
let payload = Payload::new("identity", "a-credential-nobody-should-read");
let printed = format!("{payload:?}");
assert!(
!printed.contains("a-credential"),
"the body reached a log line: {printed}"
);
assert!(
printed.contains("identity"),
"the channel name is loggable: {printed}"
);
assert_eq!(payload.summary(), "identity (31 bytes)");
}
#[test]
fn an_envelope_body_stays_a_string_in_both_directions() {
// Nested JSON in the body has to survive as text: the moment it
// arrives as structure, this layer is one field access from being
// parsed.
let body = r#"{"looks":"structured"}"#;
let line = to_line(&GuestToHost::Payload {
payload: Payload::new("identity", body),
})
.unwrap();
let back: GuestToHost = from_line(&line).unwrap();
let GuestToHost::Payload { payload } = back else {
panic!("not an envelope: {line}")
};
assert_eq!(payload.body, body);
let line = to_line(&HostToGuest::Payload {
payload: Payload::new("identity", body),
})
.unwrap();
let back: HostToGuest = from_line(&line).unwrap();
let HostToGuest::Payload { payload } = back else {
panic!("not an envelope: {line}")
};
assert_eq!(payload.body, body);
}
#[test]
fn a_mount_failure_keeps_its_reason_verbatim() {
let reason = "EACCES: /mnt/user";
let line = to_line(&GuestToHost::MountFailed {
reason: reason.into(),
})
.unwrap();
let back: GuestToHost = from_line(&line).unwrap();
assert_eq!(
back,
GuestToHost::MountFailed {
reason: reason.into()
}
);
}
#[test]
fn defaults_cover_what_a_caller_may_leave_out() {
// A box with nothing mounted is a legitimate box.
let parsed: BootDescriptor = from_line("{}").unwrap();
assert!(parsed.mounts.is_empty());
let launch = r#"{"type":"launch","id":"l-1",
"exec":{"argv":["/bin/sh"],"uid":1000,"gid":1000},
"on_exit":{"terminal":false}}"#;
let HostToGuest::Launch { id, exec, on_exit } = from_line(launch).unwrap() else {
panic!("not a launch: {launch}")
};
assert_eq!(id, LaunchId::new("l-1"));
assert!(exec.env.is_empty());
assert_eq!(exec.cwd, None);
assert!(!on_exit.terminal);
}
}