8000 Make installer gracefully stop a running instance during install by twitchyliquid64 · Pull Request #768 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 201 additions & 4 deletions crates/minimal/src/autospawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,89 @@ pub fn ensure_daemon_running(use_minvmd: bool, minimal_dir: Option<&Path>) -> io
Ok(())
}

/// Whether the daemon the CLI talks to is currently up — the question
/// [`ensure_daemon_running`] answers before deciding to spawn, exposed for
/// callers that must *not* spawn. `min stop` is the case: without this it can
/// only discover an already-down daemon by failing to connect to it, which is a
/// connect error (or, against a stale socket, a timeout) for what is really the
/// goal state.
///
/// Backend selection mirrors [`ensure_daemon_running`] exactly, and each backend
/// uses the same probe its `ensure_*` counterpart gates its spawn on, so the two
/// cannot disagree about what "running" means.
///
/// This is a point-in-time answer, not a lease: the daemon can go down between
/// the probe and the caller's connect. Callers must still handle a failed
/// connect — this only keeps the *expected* case off that path.
#[cfg(target_os = "macos")]
pub fn is_daemon_running(use_minvmd: bool, minimal_dir: Option<&Path>) -> io::Result<bool> {
let _ = use_minvmd;
is_minvmd_running(minimal_dir)
}

#[cfg(target_os = "linux")]
pub fn is_daemon_running(use_minvmd: bool, minimal_dir: Option<&Path>) -> io::Result<bool> {
if use_minvmd {
return is_minvmd_running(minimal_dir);
}
let sock = crate::client::resolve_socket_path(minimal_dir)
.map_err(|e| io::Error::other(format!("resolving native minimald socket path: {e}")))?;
Ok(minimald_socket_live(&sock))
}

/// With no backend to probe, report "running" so the caller falls through to its
/// connect and surfaces whatever that says — the behaviour before this probe
/// existed. Claiming "not running" here would silently turn every such call into
/// a no-op on a platform we cannot actually observe.
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn is_daemon_running(use_minvmd: bool, minimal_dir: Option<&Path>) -> io::Result<bool> {
let _ = (use_minvmd, minimal_dir);
Ok(true)
}

/// Whether a minvmd-backed VM is up. Read through `effective_state`, like the
/// rest of this module: a supervisor that died without writing `Stopped` leaves
/// `Running` behind, and that is a stopped VM, not a reachable one.
///
/// `is_active` counts `Stopping` as running: a VM mid-shutdown still holds the
/// socket, and a `stop` against it should ride out that shutdown rather than
/// return as though the VM were already down.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn is_minvmd_running(minimal_dir: Option<&Path>) -> io::Result<bool> {
let provider_dir = crate::client::resolve_provider_dir(minimal_dir)?;
let state_dir = minvmd::state::StateDir::new(provider_dir)?;
Ok(state_dir.effective_state()?.lifecycle.is_active())
}

/// Linux native (DM2): the daemon is up exactly when its socket accepts a
/// connection. There is no lifecycle file for this backend — minimald owns the
/// socket for its whole lifetime, so reachability *is* the liveness signal.
///
/// Only two failures *prove* nothing is listening: a missing socket file, and a
/// refused connection (the stale file a killed daemon leaves behind — nothing
/// unlinks it). Every other connect error — a path over `SUN_LEN`, a directory
/// we cannot traverse — means we could not tell, and is deliberately reported as
/// live: the caller then attempts its own connect and surfaces the real error,
/// instead of this probe converting a broken socket path into a confident (and
/// wrong) "the daemon is not running".
#[cfg(target_os = "linux")]
fn minimald_socket_live(socket_path: &Path) -> bool {
match std::os::unix::net::UnixStream::connect(socket_path) {
Ok(_) => true,
Err(e) => !matches!(
e.kind(),
io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound
),
}
}

/// Linux native (DM2): if the SSH socket is not already accepting connections,
/// spawn `minimald run --detach --instance-num 0`. minimald daemonizes itself
/// (setsid) and only returns once it is listening, so this fail-fasts on a
/// non-zero exit.
#[cfg(target_os = "linux")]
fn ensure_minimald_running(socket_path: &Path, minimal_dir: Option<&Path>) -> io::Result<()> {
use std::os::unix::net::UnixStream;

if UnixStream::connect(socket_path).is_ok() {
if minimald_socket_live(socket_path) {
tracing::debug!("native minimald already running (socket connectable)");
return Ok(());
}
Expand Down Expand Up @@ -295,10 +369,19 @@ fn ensure_minimald_running(socket_path: &Path, minimal_dir: Option<&Path>) -> io
#[cfg(test)]
#[cfg(any(target_os = "macos", target_os = "linux"))]
mod tests {
use super::{Decision, classify, wait_for_minvmd_stopped};
use super::{Decision, classify, is_daemon_running, wait_for_minvmd_stopped};
use minvmd::lifecycle::Lifecycle;
use minvmd::state::{State, StateDir};

/// A state whose lifecycle is `l`, as a live supervisor would have left it.
fn state_at(l: Lifecycle) -> State {
State {
lifecycle: l,
vmm_pid: Some(999_999_999),
started_at: Some(0),
}
}

#[test]
fn running_or_starting_needs_no_spawn() {
assert_eq!(classify(Lifecycle::Running), Decision::AlreadyRunning);
Expand Down Expand Up @@ -350,4 +433,118 @@ mod tests {
Lifecycle::Stopped
);
}

#[test]
fn minvmd_is_not_running_when_never_provisioned() {
let tmp = tempfile::tempdir().unwrap();
assert!(!is_daemon_running(true, Some(tmp.path())).unwrap());
}

#[test]
fn minvmd_is_not_running_when_stopped() {
let tmp = tempfile::tempdir().unwrap();
state_dir_for(tmp.path())
.write_state(&State::stopped())
.unwrap();
assert!(!is_daemon_running(true, Some(tmp.path())).unwrap());
}

/// The case the probe exists for: a supervisor that died without writing
/// `Stopped` leaves `Running` behind. Trusting that would send `stop` at a
/// socket nobody serves — the connect error the probe is meant to avoid.
#[test]
fn minvmd_is_not_running_on_stale_running_state_from_a_dead_supervisor() {
let tmp = tempfile::tempdir().unwrap();
state_dir_for(tmp.path())
.write_state(&state_at(Lifecycle::Running))
.unwrap();
assert!(!is_daemon_running(true, Some(tmp.path())).unwrap());
}

#[test]
fn minvmd_is_running_while_a_live_daemon_holds_the_alive_lock() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = state_dir_for(tmp.path());
state_dir
.write_state(&state_at(Lifecycle::Running))
.unwrap();
let _alive = state_dir
.try_acquire_alive_lock()
.unwrap()
.expect("the alive lock is free in a fresh state dir");
assert!(is_daemon_running(true, Some(tmp.path())).unwrap());
}

/// A VM mid-shutdown still holds the socket, so `stop` should ride out its
/// shutdown rather than report it already down.
#[test]
fn minvmd_stopping_still_counts_as_running() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = state_dir_for(tmp.path());
state_dir
.write_state(&state_at(Lifecycle::Stopping))
.unwrap();
let _alive = state_dir
.try_acquire_alive_lock()
.unwrap()
.expect("the alive lock is free in a fresh state dir");
assert!(is_daemon_running(true, Some(tmp.path())).unwrap());
}

#[cfg(target_os = "linux")]
#[test]
fn native_minimald_is_not_running_when_no_socket_exists() {
let tmp = tempfile::tempdir().unwrap();
assert!(!is_daemon_running(false, Some(tmp.path())).unwrap());
}

#[cfg(target_os = "linux")]
#[test]
fn native_minimald_is_running_when_the_socket_accepts() {
let tmp = tempfile::tempdir().unwrap();
let sock = crate::client::resolve_socket_path(Some(tmp.path())).unwrap();
std::fs::create_dir_all(sock.parent().unwrap()).unwrap();
let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
assert!(is_daemon_running(false, Some(tmp.path())).unwrap());
}

/// A crashed minimald leaves its socket file behind — std's `UnixListener`
/// does not unlink on drop, and neither does a SIGKILL. The file existing is
/// therefore not liveness; only a connect that is answered is.
#[cfg(target_os = "linux")]
#[test]
fn native_minimald_is_not_running_when_the_socket_file_is_stale() {
let tmp = tempfile::tempdir().unwrap();
let sock = crate::client::resolve_socket_path(Some(tmp.path())).unwrap();
std::fs::create_dir_all(sock.parent().unwrap()).unwrap();
drop(std::os::unix::net::UnixListener::bind(&sock).unwrap());
assert!(
sock.exists(),
"the stale socket file must outlive its daemon"
);
assert!(!is_daemon_running(false, Some(tmp.path())).unwrap());
}

/// A connect error that is not "refused" or "missing" leaves liveness
/// unknown, and unknown must not read as "not running": that would report a
/// clean stop for a socket path no daemon could ever bind, hiding the real
/// error the caller's own connect is about to give. A path past `SUN_LEN` is
/// the reachable case (a long `--minimal-dir`).
#[cfg(target_os = "linux")]
#[test]
fn native_minimald_unknown_liveness_defers_to_the_callers_connect() {
let tmp = tempfile::tempdir().unwrap();
let long = tmp.path().join("d".repeat(120));
std::fs::create_dir_all(&long).unwrap();
let sock = crate::client::resolve_socket_path(Some(&long)).unwrap();
std::fs::create_dir_all(sock.parent().unwrap()).unwrap();
assert!(
std::os::unix::net::UnixStream::connect(&sock)
.unwrap_err()
.kind()
!= std::io::ErrorKind::NotFound,
"this test is only meaningful while the path is rejected for its length"
);
assert!(is_daemon_running(false, Some(&long)).unwrap());
}
}
20 changes: 20 additions & 0 deletions crates/minimal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,7 +1216,27 @@ pub async fn cmd_destroy(global: &GlobalArgs, args: DestroyArgs) -> Result<(), a
}

/// Shut down the minimald daemon via the `Shutdown` RPC.
///
/// A daemon that is already down is the goal state, not a failure: `stop` says
/// so and exits 0. Without the probe the only way to find that out is to fail
/// connecting to it, which reports a connect error (or a timeout, against a
/// stale socket) for a machine that is in exactly the state asked for. Note the
/// deliberate asymmetry with every other command: they call `ensure_daemon` and
/// autospawn, which for `stop` would mean booting a VM in order to shut it down.
pub async fn cmd_stop(global: &GlobalArgs, args: StopArgs) -> Result<(), anyhow::Error> {
// Cheap and bounded (a state-file read, or a connect to a local socket that
// refuses at once when nothing listens), so it runs inline rather than on
// the blocking pool — unlike the shutdown wait below, which sleep-polls.
if !autospawn::is_daemon_running(global.minvmd, global.minimal_dir.as_deref())
.context("Failed to determine whether the daemon is running")?
{
println!("Daemon is not running.");
return Ok(());
}

// Racy by nature: the daemon may go down between the probe and this connect
// (or `--minvmd` may point the probe and the client at different backends),
// so a connect failure is still a real error, not something to swallow.
let mut client = connect_daemon(global).await?;

use minimald_rpc::{Shutdown, ShutdownRequest, ShutdownResponse};
Expand Down
27 changes: 27 additions & 0 deletions docs/specs/07-spec-installer/07-spec-installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,26 @@ got=$(sha256 "$target.tmp.$$")
mv -f "$target.tmp.$$" "$target"
```

**R5.5** — Installing a new version over a **running daemon** wedges it: the
daemon goes on serving from the old image while the newly-installed `min` speaks
to it. Before the first component file is replaced, the installer therefore runs
`<bin>/min stop --force` — the `min` **already on disk**, which is the build that
matches the daemon it started and is the one about to be overwritten. `--force`
because an upgrade must not be blocked by live sessions, and because a prompt is
not available in a `curl … | sh` pipeline.

The step is **best-effort and silent**, output discarded and exit status ignored:
"no `min` installed yet" (a fresh install), "no daemon running" (`min stop`
merely connects — it never autospawns — so this is a failed connect and nothing
more), and "the installed `min` is too old to know `stop --force`" are all just
*nothing to stop*, and none may fail an install whose binaries are otherwise
fine. Only `<bin>/min` is ever run — never a `min` found on `$PATH`, which is not
this installer's footprint.

It is attempted **at most once per run**, and only from the path that actually
replaces a file: a rerun where every component is already up to date, or a run
that dies fetching or checksum-verifying, must leave a healthy daemon running.

**Proof artifacts**:

- **Test**: Running twice against the same fixture manifest downloads on the
Expand All @@ -265,6 +285,13 @@ mv -f "$target.tmp.$$" "$target"
*same* manifest performs zero downloads and no re-sign, but a rerun against a
manifest whose `sha256` for that component *changed* re-downloads and re-signs
it — the recorded installed hash does not mask a new release.
- **Test** (R5.5): a fresh install stops nothing (no `min` on disk yet) and an
up-to-date rerun stops nothing (nothing replaced); an upgrade whose components
are stale runs the on-disk `min` with exactly `stop --force`, once, however
many components it replaces.
- **Test** (R5.5): an installed `min` whose `stop` exits non-zero and writes to
both stdout and stderr still yields exit 0, leaks neither stream into the
installer's output, and completes the upgrade.

### Unit 6 — Install record and PATH advisory

Expand Down
29 changes: 27 additions & 2 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ resolve_prefix() {
esac
}

# The bin prefix, resolved once. Needed early: the pre-upgrade daemon stop
# (R5.5) runs the `min` already installed there, before the component loop
# replaces it.
bindir="$(resolve_prefix bin)"

# --- Unit 9: shared shell-integration paths and markers ---------------------

# Where the generated (not downloaded) shell-integration files live. Init
Expand Down Expand Up @@ -433,6 +438,22 @@ records="$tmpdir/installed"
: >"$records"
installed=0 skipped=0

# R5.5 — swapping binaries under a running daemon wedges it: the daemon keeps
# serving from the old image while the new `min` talks to it. Stop it first,
# using the `min` ALREADY on disk — that one matches the daemon it started, and
# it is about to be overwritten. Deliberately best-effort and silent: nothing
# installed yet, no daemon running, or a `min` too old to have `stop --force`
# all mean "nothing to stop", and none of them should fail an install whose
# binaries are otherwise fine. `min stop` only connects (it never autospawns),
# so with no daemon up this is a failed connect and nothing more.
daemon_stop_tried=0
stop_running_daemon() {
[ "$daemon_stop_tried" -eq 0 ] || return 0
daemon_stop_tried=1
[ -x "$bindir/min" ] || return 0
"$bindir/min" stop --force >/dev/null 2>&1 || true
}

# The prior run's install record (R6.1) maps each component to the hash of the
# file it placed on disk. Written into place at the end of this run; here we only
# need its path so a completed run can replace it. The on-disk file — not this
Expand Down Expand Up @@ -503,6 +524,12 @@ while read -r comp _ _ _ want kind dest src; do
xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true
fi

# R5.5 — last moment before the first live file is swapped, so a run where
# every component is up to date (or one that dies fetching/verifying) never
# touches a healthy daemon. The guard inside makes this a no-op after the
# first replaced component.
stop_running_daemon

mv -f "$tmp" "$target_file"
installed=$((installed + 1))
# Record the manifest hash paired with the on-disk hash, so a later
Expand All @@ -515,8 +542,6 @@ done <"$applicable"

# --- Unit 9a: generated shell-init files and completions -------------------

bindir="$(resolve_prefix bin)"

# Append a generated file to this run's install record so a later run and
# `--uninstall` treat it exactly like a downloaded component (R9.1/R9.3). No
# manifest hash exists for generated content, so both hash columns carry the
Expand Down
Loading
Loading
0