mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-23 11:08:18 +03:00
feat(nesinit): PID 1 for a box — reaping, ordered shutdown, and one channel out
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.
This commit is contained in:
72
apps/nesinit/src/reap.rs
Normal file
72
apps/nesinit/src/reap.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// Reaping. The part of being PID 1 that no other component can do.
|
||||
//
|
||||
// A process whose parent dies is reparented to PID 1, so every orphan in the
|
||||
// guest becomes this process's child and stays a zombie until it is waited
|
||||
// for. Zombies hold a pid and a slot in the process table; a workload that
|
||||
// leaks them in a long session eventually cannot fork.
|
||||
|
||||
use std::io;
|
||||
|
||||
use nesprotocol::lifecycle::Exit;
|
||||
|
||||
/// Reap every child that has already exited, without blocking on any that
|
||||
/// have not.
|
||||
///
|
||||
/// Returns what it collected, which is what makes this testable: the caller
|
||||
/// decides whether an exit is interesting, and the same call site both frees
|
||||
/// the process table and answers "did the process I care about end".
|
||||
pub fn reap_exited() -> Vec<(i32, Exit)> {
|
||||
let mut reaped = Vec::new();
|
||||
loop {
|
||||
let mut status: libc::c_int = 0;
|
||||
// -1 is "any child"; WNOHANG makes this a poll rather than a wait, so
|
||||
// one call drains the queue and never blocks the caller.
|
||||
let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
|
||||
match pid {
|
||||
0 => return reaped,
|
||||
-1 => return reaped, // no children left, or a signal interrupted the poll
|
||||
pid => reaped.push((pid, exit_of(status))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `wait` status as an exit.
|
||||
///
|
||||
/// A signalled process has no exit code. Reporting `0` for one would make a
|
||||
/// kill indistinguishable from a clean run, which is the difference a caller
|
||||
/// most needs from this.
|
||||
pub fn exit_of(status: libc::c_int) -> Exit {
|
||||
if libc::WIFSIGNALED(status) {
|
||||
Exit::signal(libc::WTERMSIG(status))
|
||||
} else {
|
||||
Exit::code(libc::WEXITSTATUS(status))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the kernel to reparent orphans to this process even when it is not
|
||||
/// PID 1.
|
||||
///
|
||||
/// In the guest this is redundant — PID 1 already collects them. It is called
|
||||
/// anyway because it is what makes the reaper testable off a VM, and because a
|
||||
/// nesinit that is accidentally not PID 1 should still reap rather than leak.
|
||||
pub fn become_subreaper() -> io::Result<()> {
|
||||
// SAFETY: prctl with this option takes one integer argument and returns
|
||||
// -1/errno on failure; nothing here is borrowed by the kernel.
|
||||
if unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take this process out of the reach of the OOM killer.
|
||||
///
|
||||
/// Under memory pressure the kernel picks a victim by score, and init being
|
||||
/// eligible is the one loss the guest cannot report: everything else exiting
|
||||
/// is a message up the channel, whereas init exiting takes the channel with
|
||||
/// it, and the caller sees a box that stopped answering for no stated reason.
|
||||
///
|
||||
/// This covers init only. Making the workload the preferred victim is the
|
||||
/// image's job — this process cannot score other processes it did not start.
|
||||
pub fn refuse_oom_kill() -> io::Result<()> {
|
||||
std::fs::write("/proc/self/oom_score_adj", "-1000\n")
|
||||
}
|
||||
Reference in New Issue
Block a user