8000 feat(minimal2): auto-spawn minvmd on macOS by gominimal-aw-bot[bot] · Pull Request #354 · 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
8000 Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .github/workflows/ci-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
branches: ["main"]
paths:
- "crates/minvmd/**"
- "crates/minimal2/**"
- "Cargo.toml"
- "Cargo.lock"
- ".github/workflows/ci-macos.yml"
Expand All @@ -17,6 +18,7 @@ on:
branches: ["main"]
paths:
- "crates/minvmd/**"
- "crates/minimal2/**"
- "Cargo.toml"
- "Cargo.lock"
- ".github/workflows/ci-macos.yml"
Expand Down Expand Up @@ -137,6 +139,115 @@ jobs:
path: ${{ runner.temp }}/boot.log
if-no-files-found: ignore

autospawn-e2e:
# Auto-spawn E2E — the R4.5 CLI proof artifact, on real hardware. From a
# clean state `minimal ls` must auto-spawn minvmd and return within 8 s;
# `minvmd status` must then report running; a second `minimal ls` must
# complete in < 500 ms (warm reuse). This exercises the real
# minimal2 -> `minvmd run --detach` -> VM boot path end to end, the macOS
# integration gate that no unit test or Linux CI can cover.
if: ${{ vars.RUN_MACOS_CI != 'false' }}
needs: virtio-kernel
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Toolchain
uses: dtolnay/rust-toolchain@stable
- name: Verify libkrun is available
run: |
if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then
echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2
exit 1
fi
- name: Download virtio-linux kernel
uses: actions/download-artifact@v4
with:
name: virtio-kernel-aarch64
path: ${{ runner.temp }}/kernel
- name: Stage minvmd guest rootfs
run: scripts/build-rootfs.sh
- name: Build minvmd + minimal2 (no run)
# Build both bins before the single codesign. minimal2 spawns `minvmd`
# by name from PATH; only minvmd needs the hypervisor entitlement, so
# codesigning it must be the last thing to touch the binary.
run: cargo build -p minvmd --bin minvmd -p minimal2 --bin minimal2
- name: Codesign minvmd (hypervisor entitlement, R1.4)
run: codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - target/debug/minvmd
- name: Auto-spawn E2E (minimal ls cold/warm, R4.5)
# Run the prebuilt binaries directly (never `cargo run`, which would
# relink and unsign minvmd). minimal2 finds `minvmd` via PATH; both
# inherit MINVMD_KERNEL_PATH / MINVMD_ROOTFS_PATH through the
# `minvmd run --detach` re-exec. A fresh XDG_RUNTIME_DIR / XDG_STATE_HOME
# guarantees the clean (no-daemon) starting state the proof requires.
run: |
set -uo pipefail # not -e: capture failures so we can dump diagnostics
export PATH="$PWD/target/debug:$PATH"
export MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz"
export MINVMD_ROOTFS_PATH="$(scripts/build-rootfs.sh --print-path)"
# Capture the guest console; this env var propagates through the
# minimal2 -> `minvmd run --detach` -> `minvmd run` -> __krun-vmm chain,
# so the silent detached supervisor's boot is still observable here.
export MINVMD_BOOT_LOG="$RUNNER_TEMP/autospawn-boot.log"
export XDG_RUNTIME_DIR="$(mktemp -d)"
# minvmd resolves its state dir via dirs::state_dir(), which ignores
# XDG_STATE_HOME on macOS and uses ~/.local/state. Clear it so the
# proof starts from a genuinely clean (NotProvisioned) state on the
# persistent runner.
rm -rf "$HOME/.local/state/minimal/minvmd"

# macOS `date` has no %N; use perl's high-resolution clock for ms.
now_ms() { perl -MTime::HiRes=time -e 'printf "%d", time()*1000'; }

# On any failure, dump what the silent detach hides — minimal2's own
# error, the persisted state, and the guest boot console — then stop
# the supervisor and fail. An empty boot log means the VM never
# started (supervisor died before krun_start_enter); kernel output
# without a full boot means a stuck boot; a full boot points at a
# UDS/timing issue.
fail() {
echo "::group::autospawn-e2e diagnostics"
echo "--- minimal2 ls stderr ---"; cat "$RUNNER_TEMP/ls.err" 2>/dev/null || true
echo "--- state.toml ---"; cat "$HOME/.local/state/minimal/minvmd/state.toml" 2>/dev/null || echo "(none)"
echo "--- guest boot console (tail) ---"; tail -80 "$MINVMD_BOOT_LOG" 2>/dev/null || echo "(no boot log — VM never started)"
echo "::endgroup::"
minvmd stop >/dev/null 2>&1 || true
exit 1
}
trap 'minvmd stop >/dev/null 2>&1 || true' EXIT

# Cold: must auto-spawn minvmd and return within 8 s.
t0=$(now_ms)
minimal2 ls 2>"$RUNNER_TEMP/ls.err" || { echo "::error::cold 'minimal ls' failed to auto-spawn minvmd"; fail; }
t1=$(now_ms); cold=$(( t1 - t0 ))
echo "cold 'minimal ls': ${cold}ms"
[ "$cold" -lt 8000 ] || { echo "::error::cold 'minimal ls' took ${cold}ms (>= 8000ms)"; fail; }

# minvmd must now report running (exit 0). Poll briefly to absorb the
# gap between the host UDS accepting and the Running state persisting.
running=0
for _ in $(seq 1 50); do
if minvmd status >/dev/null 2>&1; then running=1; break; fi
sleep 0.1
done
[ "$running" = 1 ] || { echo "::error::minvmd did not report running after auto-spawn"; fail; }

# Warm: minvmd already running, must return in < 500 ms.
t0=$(now_ms)
minimal2 ls >/dev/null 2>"$RUNNER_TEMP/ls.err" || { echo "::error::warm 'minimal ls' failed"; fail; }
t1=$(now_ms); warm=$(( t1 - t0 ))
echo "warm 'minimal ls': ${warm}ms"
[ "$warm" -lt 500 ] || { echo "::error::warm 'minimal ls' took ${warm}ms (>= 500ms)"; fail; }
- name: Upload autospawn guest boot console log
if: always()
uses: actions/upload-artifact@v4
with:
name: autospawn-boot-log
path: ${{ runner.temp }}/autospawn-boot.log
if-no-files-found: ignore

build-macos:
# Self-hosted Apple Silicon runner. minvmd links libkrun
# (`#[link(name = "krun")]`) and only compiles on macOS — the Linux CI
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/minimal2/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ publish.workspace = true
clap.workspace = true
clap_complete.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

ot.workspace = true
minvmd = { path = "../minvmd" }
131 changes: 131 additions & 0 deletions crates/minimal2/src/autospawn.rs
8 < 10BC8 tr data-hunk="39287cfc8f3bfa05301bc07bb7937d46a8bb3e0cd847c37b5f15ee6348f6b327" class="show-top-border">
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Auto-spawn logic for minvmd on macOS (R4.5).
//!
//! On macOS, checks the minvmd state before connecting to the UDS. If minvmd
//! is not running, spawns `minvmd run --detach` and waits for the UDS to become
//! available.
//!
//! On Linux, this module is a no-op.

use std::io;
#[cfg(target_os = "macos")]
use std::process::Command;
#[cfg(target_os = "macos")]
use std::thread;
#[cfg(target_os = "macos")]
use std::time::{Duration, Instant};

/// Default timeout in seconds to wait for the UDS when spawning minvmd (R4.5).
#[cfg(target_os = "macos")]
const DEFAULT_SPAWN_TIMEOUT_SECS: u64 = 8;
Comment thread
norrietaylor marked this conversation as resolved.
Resolved

/// Poll interval while waiting for a shutting-down minvmd to reach a terminal
/// state before deciding whether to spawn.
#[cfg(target_os = "macos")]
const STOPPING_POLL_MS: u64 = 100;

/// Max time to wait for an in-progress `minvmd stop` (SIGTERM → SIGKILL, ~5 s)
/// to finish before giving up with a clear error.
#[cfg(target_os = "macos")]
const STOPPING_WAIT_SECS: u64 = 6;

/// Check if minvmd needs to be spawned, and spawn it if necessary.
///
/// On macOS (R4.5):
/// - Reads the minvmd state from `state.toml`
/// - If not running, spawns `minvmd run --detach` with a timeout
/// - Returns an error if spawn fails
///
/// On Linux, this is a no-op since minvmd does not exist on Linux.
#[cfg(target_os = "macos")]
pub fn ensure_minvmd_running() -> io::Result<()> {
let state_dir = minvmd::state::StateDir::new(minvmd::state::StateDir::default_path())?;

// Read current state (R4.5: check state.toml before connecting)
let state = state_dir.read_state()?;

// If already running or starting, no need to spawn
match state.lifecycle {
minvmd::lifecycle::Lifecycle::Running | minvmd::lifecycle::Lifecycle::Starting => {
tracing::debug!("minvmd already running or starting");
return Ok(());
}
minvmd::lifecycle::Lifecycle::Stopping => {
// `minvmd stop` can take up to ~5 s (SIGTERM → SIGKILL escalation),
// so a fixed short sleep usually leaves the daemon still Stopping.
// Spawning then would make the new `minvmd run` bail on its own
// Stopping guard, surfacing an opaque 8 s UDS timeout. Instead poll
// until it reaches a terminal state, re-reading on each tick.
tracing::info!("minvmd is stopping; waiting for it to finish");
let deadline = Instant::now() + Duration::from_secs(STOPPING_WAIT_SECS);
loop {
thread::sleep(Duration::from_millis(STOPPING_POLL_MS));
match state_dir.read_state()?.lifecycle {
// Something else restarted it in the meantime.
minvmd::lifecycle::Lifecycle::Running
| minvmd::lifecycle::Lifecycle::Starting => return Ok(()),
// Shutdown completed; fall through to spawn.
minvmd::lifecycle::Lifecycle::Stopped
| minvmd::lifecycle::Lifecycle::NotProvisioned => break,
_ if Instant::now() >= deadline => {
return Err(io::Error::other(
"minvmd is still stopping after waiting; try again shortly",
));
}
// Still stopping (or a future state); keep polling.
_ => continue,
}
}
}
minvmd::lifecycle::Lifecycle::Stopped | minvmd::lifecycle::Lifecycle::NotProvisioned => {
// Not running; will spawn below
}
// Lifecycle is #[non_exhaustive]; treat any future state conservatively
// as "not running" and fall through to spawn.
_ => {}
}

// Not running; spawn minvmd run --detach with timeout (R4.5)
tracing::info!(
"spawning minvmd run --detach with timeout {}",
DEFAULT_SPAWN_TIMEOUT_SECS
);
let output = Command::new("minvmd")
.arg("run")
.arg("--detach")
.arg("--timeout")
.arg(DEFAULT_SPAWN_TIMEOUT_SECS.to_string())
.output()
.map_err(|e| io::Error::new(e.kind(), format!("failed to spawn minvmd: {}", e)))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"minvmd run --detach failed: {}",
stderr
)));
}

tracing::info!("minvmd spawned successfully");
Ok(())
}

/// On Linux, auto-spawn is a no-op since minvmd is not available.
#[cfg(target_os = "linux")]
pub fn ensure_minvmd_running() -> io::Result<()> {
tracing::debug!("ensure_minvmd_running is a no-op on Linux");
Ok(())
}

#[cfg(test)]
mod tests {
#[test]
#[cfg(target_os = "linux")]
fn test_autospawn_noop_on_linux() {
// On Linux, ensure_minvmd_running should be a no-op and always succeed
let result = super::ensure_minvmd_running();
assert!(
result.is_ok(),
"ensure_minvmd_running should always succeed on Linux"
);
}
}
19 changes: 19 additions & 0 deletions crates/minimal2/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use clap_complete::Shell;
use std::path::PathBuf;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};

mod autospawn;

#[derive(Parser)]
#[command(name = "minimal", version = env!("CARGO_PKG_VERSION"), long_version = env!("LONG_VERSION"))]
#[command(about = "The Minimal CLI")]
Expand All @@ -18,6 +20,8 @@ struct Cli {

#[derive(Subcommand)]
enum Command {
/// List sessions
Ls,
/// Generate shell completion script
#[command(
long_about = "Generate a shell tab-completion script for the minimal CLI.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(minimal completions bash)"
Expand Down Expand Up @@ -56,6 +60,21 @@ async fn main() -> Result<(), ()> {
let cli = Cli::parse();

match cli.command {
Command::Ls => {
Comment thread
norrietaylor marked this conversation as resolved.
Resolved
// Ensure minvmd is running before listing (R4.5). On macOS this
// auto-spawns minvmd; on Linux `ensure_minvmd_running` is a no-op,
// so `ls` falls through to the placeholder `[]` below.
//
// TODO(#311): minimal targets Linux too — add the Linux backend and
// real session listing over the minimald UDS; `[]` is a placeholder
// on both platforms until then.
if let Err(e) = autospawn::ensure_minvmd_running() {
eprintln!("Failed to ensure minvmd is running: {}", e);
return Err(());
}
println!("[]");
Ok(())
}
Command::Completions(CompletionsArgs { shell }) => {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
Expand Down
12 changes: 11 additions & 1 deletion crates/minvmd/src/cmd/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,17 @@ fn run_foreground() -> Result<()> {
Lifecycle::NotProvisioned | Lifecycle::Stopped => {}
}

let starting = next_state(state.lifecycle, Action::Start)
// A clean install starts NotProvisioned; provision it
// (NotProvisioned → Stopped) before starting, since the state machine
// only permits Start from Stopped.
let base = match state.lifecycle {
Lifecycle::NotProvisioned => {
next_state(Lifecycle::NotProvisioned, Action::Provision)
.map_err(|e| anyhow::anyhow!("lifecycle transition error: {e}"))?
}
other => other,
};
let starting = next_state(base, Action::Start)
.map_err(|e| anyhow::anyhow!("lifecycle transition error: {e}"))?;
state_dir
.write_state(&State {
Expand Down
Loading
0