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
216 changes: 158 additions & 58 deletions crates/minimald/src/guest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,66 +672,63 @@ fn clock_step_target(host_ns: u64, guest_ns: u64) -> Option<nix::sys::time::Time
))
}

/// Listens forever for host time updates and steps the guest's `CLOCK_REALTIME`
/// onto the host's whenever the two have drifted apart.
/// Vsock port the guest listens on for host time updates (see
/// [`run_timekeep_listener`]).
///
/// This is the guest half of libkrun's timesync worker
/// ([`timesync.rs`](https://github.com/containers/libkrun/blob/main/src/devices/src/virtio/vsock/timesync.rs)),
/// which libkrun initializes on macOS hosts only. It sends an AF_VSOCK
/// **datagram** — eight bytes, the host's nanoseconds since the epoch, little
/// endian — to a fixed port (123) every 60 s, and immediately after it notices
/// the host slept. Without this the guest clock stops for the duration of a
/// host suspend and every later timestamp is wrong: TLS handshakes fail on
/// not-yet-valid certificates and build systems see sources "from the future".
/// Sits next to [`BOOT_MARKER_PORT`] (7350) in the same private range. The host
/// half in `minvmd` must register a bridge to this port, so the value is
/// mirrored there — keep the two in step.
pub const TIMEKEEP_PORT: u32 = 7351;

/// How long a host timekeeper connection may stay silent before the guest
/// treats it as dead. Several times the host's 60 s heartbeat, so an idle
/// window this long means the peer is gone, not slow.
///
/// Runs until the socket fails, hence the [`Infallible`] success type: every
/// `Ok` path loops. Callers spawn it and log the error. Malformed datagrams
/// (anything but exactly 8 bytes) are skipped, not fatal — one bad packet is
/// not a reason to stop tracking the host clock.
/// Connections are served one at a time, so this is what stops a peer that
/// vanished *without* closing — a wedged bridge, a host end that never sends a
/// FIN — from parking the listener in `read_exact` forever while the host's
/// reconnect waits unaccepted in the backlog, leaving the guest clock
/// uncorrected for the life of the VM.
const TIMEKEEP_IDLE_TIMEOUT: Duration = Duration::from_secs(300);

/// Serves one host timekeeper connection: a stream of 8-byte little-endian
/// nanosecond-since-epoch stamps, each applied to the guest clock as it
/// arrives. Returns `Ok(())` on a clean end-of-stream (including a half-frame
/// at EOF, which just means the host closed mid-write) and after `idle_timeout`
/// of silence — both mean "done with this peer, go accept the next".
///
/// [`AsyncFd`]: tokio::io::unix::AsyncFd
/// [`Infallible`]: std::convert::Infallible
pub async fn run_timekeep_listener(port: u32) -> std::io::Result<std::convert::Infallible> {
use nix::sys::socket::{AddressFamily, MsgFlags, SockFlag, SockType, bind, recv, socket};
/// `idle_timeout` is a parameter rather than a read of
/// [`TIMEKEEP_IDLE_TIMEOUT`] so a test can drive the timeout path in
/// milliseconds; the listener always passes the constant.
///
/// `warned_settime` is threaded from the accept loop so the `CAP_SYS_TIME`
/// warning stays one-shot across reconnects rather than per connection.
async fn serve_time_updates<R: tokio::io::AsyncRead + Unpin>(
reader: &mut R,
idle_timeout: Duration,
warned_settime: &mut bool,
) -> std::io::Result<()> {
use nix::time::{ClockId, clock_gettime, clock_settime};
use std::os::fd::AsRawFd as _;
use tokio::io::unix::AsyncFd;
use tokio_vsock::VMADDR_CID_ANY;

// Non-blocking from birth: `AsyncFd` only reports readiness, the recv below
// is ours to issue, and a blocking one would stall a runtime worker.
let sock = socket(
AddressFamily::Vsock,
SockType::Datagram,
SockFlag::SOCK_CLOEXEC | SockFlag::SOCK_NONBLOCK,
None,
)?;
// The host addresses the guest by its own CID, which the guest does not need
// to know: `VMADDR_CID_ANY` binds the port on whatever CID we were given.
bind(sock.as_raw_fd(), &VsockAddr::new(VMADDR_CID_ANY, port))?;
let sock = AsyncFd::new(sock)?;
tracing::info!(port, "listening for host time updates on vsock");

// `clock_settime` needs CAP_SYS_TIME. The microVM's pid-1 has it, but a
// native daemon handed --timekeep-listener-port may not, and updates arrive
// every 60s — warn on the first denial and stay quiet after that.
let mut warned_settime = false;
use tokio::io::AsyncReadExt as _;

let mut buf = [0u8; 8];
loop {
let mut ready = sock.readable().await?;
let mut buf = [0u8; 8];
let received = match ready.try_io(|sock| {
recv(sock.as_raw_fd(), &mut buf, MsgFlags::empty()).map_err(std::io::Error::from)
}) {
// Spurious readiness; `try_io` cleared it, so wait for the next.
Err(_would_block) => continue,
Ok(Ok(n)) => n,
match tokio::time::timeout(idle_timeout, reader.read_exact(&mut buf)).await {
Ok(Ok(_)) => {}
// Clean close, or a truncated final frame — either way the host is
// done talking; the accept loop waits for the next connection.
Ok(Err(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()),
Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Ok(Err(e)) => return Err(e),
};
if received != 8 {
tracing::warn!(received, "ignoring malformed time update");
continue;
// Silent for several heartbeats: drop this connection (any partial
// frame with it) so a reconnect can be accepted.
Err(_elapsed) => {
tracing::debug!(
timeout_s = idle_timeout.as_secs(),
"no host time update within the idle window; dropping the connection"
);
return Ok(());
}
}

let host_ns = u64::from_le_bytes(buf);
Expand All @@ -746,10 +743,10 @@ pub async fn run_timekeep_listener(port: u32) -> std::io::Result<std::convert::I
match clock_settime(ClockId::CLOCK_REALTIME, host_ts) {
Ok(()) => {
tracing::info!(drift_ns, "stepped the guest clock onto the host's");
warned_settime = false;
*warned_settime = false;
}
Err(e) if !warned_settime => {
warned_settime = true;
Err(e) if !*warned_settime => {
*warned_settime = true;
tracing::warn!(
error = %e,
drift_ns,
Expand All @@ -762,6 +759,65 @@ pub async fn run_timekeep_listener(port: u32) -> std::io::Result<std::convert::I
}
}

/// Listens forever for host time updates and steps the guest's `CLOCK_REALTIME`
/// onto the host's whenever the two have drifted apart.
///
/// The guest clock only advances while the VM is scheduled, so it stops for the
/// duration of a host suspend and every later timestamp is wrong: TLS
/// handshakes fail on not-yet-valid certificates and build systems see sources
/// "from the future". This listener is the repair.
///
/// **Wire protocol.** The guest *listens* on a vsock **stream** at `port`
/// (`VMADDR_CID_ANY`, so it needs no knowledge of its own CID) and the host
/// dials in — the same direction as the SSH bridge, and the reason this is a
/// stream socket: AF_VSOCK **datagrams** do not exist on a stock kernel's
/// virtio-vsock transport. They are a libkrun/TSI extension carried by the
/// patched kernel libkrun ships, which we do not boot — the original design
/// took libkrun's timesync worker
/// ([`timesync.rs`](https://github.com/containers/libkrun/blob/main/src/devices/src/virtio/vsock/timesync.rs))
/// at its word and bound `SOCK_DGRAM` on port 123, which can never receive
/// anything under our guest kernel. A stream listener works on any kernel with
/// vsock + virtio, at the cost of owning the host half ourselves (in `minvmd`).
///
/// Each update is 8 bytes: the host's nanoseconds since the epoch, little
/// endian (libkrun's payload, kept). A connection carries any number of them
/// back to back, so the host may hold one long-lived connection and write every
/// 60 s, or dial per update; both work. Connections are served one at a time —
/// there is a single host timekeeper, and serializing keeps the clock updates
/// ordered.
///
/// Runs until the listening socket fails, hence the [`Infallible`] success
/// type: every `Ok` path loops. Callers spawn it and log the error. A failure
/// on an accepted connection ends only that connection.
///
/// [`Infallible`]: std::convert::Infallible
pub async fn run_timekeep_listener(port: u32) -> std::io::Result<std::convert::Infallible> {
use tokio_vsock::{VMADDR_CID_ANY, VsockListener};

let listener = VsockListener::bind(VsockAddr::new(VMADDR_CID_ANY, port))?;
tracing::info!(port, "listening for host time updates on vsock");

// `clock_settime` needs CAP_SYS_TIME. The microVM's pid-1 has it, but a
// native daemon handed --timekeep-listener-port may not, and updates arrive
// every 60s — warn on the first denial and stay quiet after that.
let mut warned_settime = false;

loop {
let (mut stream, peer) = listener.accept().await?;
tracing::debug!(
cid = peer.cid(),
port = peer.port(),
"host timekeeper connected"
);
match serve_time_updates(&mut stream, TIMEKEEP_IDLE_TIMEOUT, &mut warned_settime).await {
Ok(()) => tracing::debug!("host timekeeper disconnected"),
// One bad connection is not a reason to stop tracking the host
// clock: drop it and wait for the host to dial again.
Err(e) => tracing::warn!(error = %e, "host time update stream failed"),
}
}
}

/// Brings up egress for the guest **root** netns (where `minimald` itself runs)
/// by attaching a primary `eth0` tap to the host gvproxy over the vsock shuttle.
///
Expand Down Expand Up @@ -1094,10 +1150,11 @@ mod tests {
assert_eq!(step.tv_nsec(), 999_999_999);
}

/// The wire format libkrun's timesync worker writes: 8 bytes, little
/// endian, nanoseconds since the epoch.
/// The wire format of one update frame: 8 bytes, little endian,
/// nanoseconds since the epoch (libkrun's payload, kept when the transport
/// moved from a TSI datagram to a plain vsock stream).
#[test]
fn a_time_update_datagram_decodes_little_endian() {
fn a_time_update_frame_decodes_little_endian() {
let host_ns = 1_700_000_000 * NANOS_IN_SECOND + 123_456_789;
assert_eq!(u64::from_le_bytes(host_ns.to_le_bytes()), host_ns);
assert_eq!(
Expand All @@ -1106,6 +1163,49 @@ mod tests {
);
}

/// A connection carries back-to-back frames and ends cleanly at EOF —
/// including on a truncated final frame, which only means the host closed
/// mid-write.
///
/// The stamps written are the guest's own current time, i.e. zero drift, so
/// the loop takes the no-step path: a test must never actually call
/// `clock_settime` (running as root, it would step the machine's clock).
#[tokio::test]
async fn a_stream_of_updates_is_read_frame_by_frame_to_eof() {
use nix::time::{ClockId, clock_gettime};

let now = clock_gettime(ClockId::CLOCK_REALTIME).unwrap();
let now_ns = now.tv_sec() as u64 * NANOS_IN_SECOND + now.tv_nsec() as u64;

let mut stream = Vec::new();
stream.extend_from_slice(&now_ns.to_le_bytes());
stream.extend_from_slice(&now_ns.to_le_bytes());
// A half-written final frame: EOF mid-frame is a clean end, not an error.
stream.extend_from_slice(&now_ns.to_le_bytes()[..3]);

let mut warned = false;
serve_time_updates(&mut stream.as_slice(), TIMEKEEP_IDLE_TIMEOUT, &mut warned)
.await
.expect("a truncated trailing frame ends the stream cleanly");
assert!(!warned, "an in-threshold update never touches the clock");
}

/// A peer that goes silent without closing must not hold the listener:
/// connections are served one at a time, so the idle timeout is what lets
/// the host's reconnect be accepted. Driven with a millisecond window —
/// the timeout is a parameter precisely so this costs no wall-clock time.
#[tokio::test]
async fn a_silent_connection_is_dropped_after_the_idle_window() {
// `_writer` stays alive for the whole test: the stream is open and
// idle, which is the case under test — not EOF.
let (_writer, mut reader) = tokio::io::duplex(64);

let mut warned = false;
serve_time_updates(&mut reader, Duration::from_millis(10), &mut warned)
.await
.expect("an idle connection ends cleanly, like EOF");
}

#[test]
fn ext4_superblock_probe_detects_magic() {
use std::io::{Seek, SeekFrom, Write};
Expand Down
18 changes: 8 additions & 10 deletions crates/minimald/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ pub struct ListenArgs {
#[clap(hide = true)]
mk_mount_state_volume: Option<String>,

/// Vsock port to listen on for time updates. This listens for updates emitted by
/// libkrun's timesync worker: https://github.com/libkrun/libkrun/blob/main/src/devices/src/virtio/vsock/timesync.rs#L16.
///
/// This worker is only initialized for a MacOS host.
/// Vsock port to listen on for host time updates: 8-byte little-endian
/// nanoseconds-since-epoch stamps, dialed in by the host half in `minvmd`
/// (see `guest::run_timekeep_listener`). Only useful as a VM init process;
/// `None` leaves the guest clock free-running.
#[arg(long)]
#[clap(hide = true)]
timekeep_listener_port: Option<u32>,
Expand Down Expand Up @@ -407,12 +407,10 @@ async fn async_main() -> Result<(), MainError> {
mount_rootfs: Some("/dev/vda".to_string()),
mk_mount_state_volume: Some("/dev/vdb".to_string()),
detach: false,
timekeep_listener_port: if cfg!(target_arch = "aarch64") {
// Libkrun shares the time as datagrams down vsock 123 on MacOS.
Some(123)
} else {
None
},
// Host time updates arrive on a vsock stream we listen on, from
// minvmd. Always listen: the guest cannot see what the
// host runs.
timekeep_listener_port: Some(guest::TIMEKEEP_PORT),
// In-VM (DM1/3/4) the PTask attaches to the host gvproxy over the
// vsock shuttle, so no in-guest gvproxy binary path is needed.
gvproxy_bin: None,
Expand Down
53 changes: 52 additions & 1 deletion crates/minvmd/src/cmd/vmm_child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
//! 5. Registers the marker socket for `VSOCK_MARKER_PORT` (guest→host): the
//! guest workload connects to that vsock port and writes `READY\n`, which
//! libkrun bridges to the host UNIX socket where the parent listens.
//! 6. Calls `krun_start_enter`, which boots the VM. On success libkrun
//! 6. On macOS, registers the timekeep socket for `VSOCK_TIMEKEEP_PORT`
//! (host→guest) and starts the thread that sends the host wall clock to the
//! guest, so a host suspend does not leave the guest clock frozen
//! ([`crate::timekeep`]).
//! 7. Calls `krun_start_enter`, which boots the VM. On success libkrun
//! `exit()`s with the guest workload's exit code and never returns here.
//!
//! Without libkrun this subcommand bails immediately with a "no libkrun" error.
Expand Down Expand Up @@ -108,6 +112,16 @@ fn run_vmm() -> Result<()> {
ctx.add_vsock_port(VSOCK_MARKER_PORT, &marker_sock)
.context("registering READY-marker vsock port")?;

// Host wall-clock updates (host→guest, `crate::timekeep`), macOS only.
//
// Best-effort: a VM that boots with a drifting clock beats one that does
// not boot, so every failure warns and carries on.
if cfg!(target_os = "macos")
&& let Err(e) = start_timekeep(&mut ctx)
{
tracing::warn!(error = %e, "host time updates unavailable; the guest clock will drift");
}

tracing::info!(
port = VSOCK_MARKER_PORT,
sock = %marker_sock,
Expand All @@ -121,3 +135,40 @@ fn run_vmm() -> Result<()> {
let err = ctx.start_enter();
bail!("krun_start_enter returned unexpectedly: {err}");
}

/// Register the host→guest timekeep bridge and start the sender thread.
///
/// libkrun's vsock API takes a filesystem path and nothing else, so this
/// goes via a temporary UDS path.
///
/// The sender starts here rather than after `start_enter`, which never
/// returns. libkrun binds the socket while starting the VM and the guest
/// listener appears later still, so the thread dials into a socket that does
/// not exist yet and retries — by design.
#[cfg(minvmd_libkrun)]
fn start_timekeep(ctx: &mut crate::krun::Context) -> Result<()> {
use anyhow::Context as _;

let sock =
crate::timekeep::resolve_timekeep_sock().context("resolving the timekeep socket path")?;
// libkrun aborts the process on an over-long socket path instead of
// returning an error, so check before handing it over.
crate::sock::check_uds_path_len(&sock)?;
crate::sock::prepare_socket_dir(&sock)?;
// Drop a stale socket from a prior run; libkrun's listen-bind fails
// EEXIST otherwise (e.g. on a persistent runner).
crate::sock::remove_stale_socket(&sock)?;

ctx.add_vsock_port2(crate::timekeep::VSOCK_TIMEKEEP_PORT, &sock, true)
.context("registering the timekeep vsock port")?;
// Detached on purpose: the thread lives as long as the process, which
// libkrun `exit()`s when the guest ends.
let _sender = crate::timekeep::spawn(sock.clone()).context("spawning the timekeep sender")?;

tracing::info!(
port = crate::timekeep::VSOCK_TIMEKEEP_PORT,
sock = %sock.display(),
"registered host→guest timekeep bridge",
);
Ok(())
}
1 change: 1 addition & 0 deletions crates/minvmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod net;
pub(crate) mod rpc_client;
pub mod sock;
pub mod state;
pub mod timekeep;
pub mod vm;
pub mod volume;

Expand Down
Loading