mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
A microVM has no init unless something is it, and three of the jobs belong to nothing else in the guest: reaping whatever the workload orphans, turning a signal into an ordered shutdown, and being the guest end of the one channel out. None of it knows what it is running. The guest dials out on a fixed vsock port, says its protocol version first, is handed one boot descriptor — a command line, shares, output geometry, and what an exit means — and carries that out. There is no code path that branches on which workload started, which is the property the component exists to keep. It reports and does not supervise. When the workload ends, the exit goes up the channel and the session is over; `on_exit` says what the exit means, and starting something again is a decision for the end that can see whether restarting is repair or a loop. A signalled workload is reported as signalled with no exit code, because reporting 0 for a killed process makes a kill look like a clean run. Two seams keep this testable without a VM, which is the reason for both of them. Reaping runs against real forked children, with the subreaper bit making a test process inherit orphans the way PID 1 does. The channel is generic over the byte stream, so the exchange is driven over an in-memory pipe — the transport contributes nothing to the protocol beyond ordering and framing. The lifecycle types live in nesprotocol behind a feature, off by default: both ends of the channel read one definition and cannot drift from it silently, while the media components keep building without serde. Mounting shares is not implemented in this build. The descriptor's mounts are refused rather than ignored — a workload started without the shares it was promised fails later, somewhere else, for a reason nobody can see from here.
136 lines
4.1 KiB
Rust
136 lines
4.1 KiB
Rust
// Ordered shutdown: the second job that is nobody else's.
|
|
//
|
|
// The order is the whole content of this module. The workload goes first and
|
|
// alone, because it is the only process whose exit anyone is waiting to hear
|
|
// about; everything else goes after, so a service is never killed while the
|
|
// workload still needs it. Then the disks are flushed and the machine is
|
|
// powered off, because a guest whose init returns is a guest that hangs.
|
|
|
|
use std::time::Duration;
|
|
|
|
/// What an ordered shutdown does to the machine, behind a trait so the order
|
|
/// can be asserted without a VM and without root.
|
|
pub trait Machine {
|
|
/// Ask the workload to stop.
|
|
fn signal_workload(&mut self);
|
|
/// Wait up to `grace` for the workload to leave. `true` if it did.
|
|
fn await_workload(&mut self, grace: Duration) -> bool;
|
|
/// Stop waiting.
|
|
fn kill_workload(&mut self);
|
|
/// Ask every remaining process to stop, then wait up to `grace`.
|
|
fn signal_rest(&mut self, grace: Duration);
|
|
/// Stop waiting for the rest.
|
|
fn kill_rest(&mut self);
|
|
fn flush_disks(&mut self);
|
|
fn power_off(&mut self);
|
|
}
|
|
|
|
/// Run the shutdown, in order, and do not return.
|
|
///
|
|
/// A workload that leaves inside its grace period is never killed: an exit
|
|
/// code that says "asked to stop" is worth more to whoever reads the report
|
|
/// than one that says "killed", and some workloads only save on the way out.
|
|
pub fn ordered<M: Machine>(machine: &mut M, grace: Duration) {
|
|
machine.signal_workload();
|
|
if !machine.await_workload(grace) {
|
|
machine.kill_workload();
|
|
}
|
|
machine.signal_rest(grace);
|
|
machine.kill_rest();
|
|
machine.flush_disks();
|
|
machine.power_off();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[derive(Default)]
|
|
struct Recorder {
|
|
steps: Vec<&'static str>,
|
|
workload_leaves: bool,
|
|
}
|
|
|
|
impl Machine for Recorder {
|
|
fn signal_workload(&mut self) {
|
|
self.steps.push("signal_workload");
|
|
}
|
|
fn await_workload(&mut self, _grace: Duration) -> bool {
|
|
self.steps.push("await_workload");
|
|
self.workload_leaves
|
|
}
|
|
fn kill_workload(&mut self) {
|
|
self.steps.push("kill_workload");
|
|
}
|
|
fn signal_rest(&mut self, _grace: Duration) {
|
|
self.steps.push("signal_rest");
|
|
}
|
|
fn kill_rest(&mut self) {
|
|
self.steps.push("kill_rest");
|
|
}
|
|
fn flush_disks(&mut self) {
|
|
self.steps.push("flush_disks");
|
|
}
|
|
fn power_off(&mut self) {
|
|
self.steps.push("power_off");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_workload_stops_before_anything_else_and_the_disks_flush_before_power() {
|
|
let mut machine = Recorder {
|
|
workload_leaves: true,
|
|
..Default::default()
|
|
};
|
|
ordered(&mut machine, Duration::from_secs(5));
|
|
|
|
assert_eq!(
|
|
machine.steps,
|
|
vec![
|
|
"signal_workload",
|
|
"await_workload",
|
|
"signal_rest",
|
|
"kill_rest",
|
|
"flush_disks",
|
|
"power_off",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_workload_that_leaves_in_time_is_not_killed() {
|
|
let mut machine = Recorder {
|
|
workload_leaves: true,
|
|
..Default::default()
|
|
};
|
|
ordered(&mut machine, Duration::from_secs(5));
|
|
assert!(!machine.steps.contains(&"kill_workload"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_workload_that_overstays_its_grace_is_killed_and_shutdown_still_finishes() {
|
|
let mut machine = Recorder {
|
|
workload_leaves: false,
|
|
..Default::default()
|
|
};
|
|
ordered(&mut machine, Duration::from_secs(5));
|
|
|
|
let killed = machine
|
|
.steps
|
|
.iter()
|
|
.position(|s| *s == "kill_workload")
|
|
.unwrap();
|
|
let rest = machine
|
|
.steps
|
|
.iter()
|
|
.position(|s| *s == "signal_rest")
|
|
.unwrap();
|
|
assert!(
|
|
killed < rest,
|
|
"the rest of the guest outlives the workload: {:?}",
|
|
machine.steps
|
|
);
|
|
assert_eq!(machine.steps.last(), Some(&"power_off"));
|
|
}
|
|
}
|