Skip to content

Commit 1fa2172

Browse files
SashaMITcursoragent
andcommitted
feat(vz-backend): Phase 2 Day 3 — lifecycle + console forwarder
Replace the Phase 1 fail-closed stubs in VzProvider::load / start / stop / status / info / http_port with real VZVirtualMachine lifecycle wiring routed through VzMachineHandle. On Apple Silicon, `VzProvider::load` now validates the assembled VZVirtualMachineConfiguration, instantiates a VZVirtualMachine on the shared serial dispatch queue and spawns a kernel-console → tracing forwarder so guest printk output flows into the same `vm_console` target crosvm uses on Linux. The lifecycle methods drive Apple's completion- handler blocks (block2) onto Tokio oneshots via DispatchQueue::exec_sync; the queue is one-per-provider, per Phase 0 §D pitfall #10. Two new modules under ffi/: - ffi/lifecycle.rs (437 LOC): VmState ↔ VZVirtualMachineState mapping, SendableVm Send wrapper with documented invariants, VzMachineHandle (validate → init → start/stop/current_state), and a pure format_validate_error helper that decorates Apple's entitlement error with the Day-4 codesign-script hint before it ever reaches the operator. - ffi/console_forwarder.rs (223 LOC): spawn_console_forwarder emits one tracing event per guest line with vm_id attached, exits cleanly on EOF, has a test-only shutdown_and_join for deterministic test cleanup. Production never joins it — Vz retains the kernel-console NSFileHandle until the VM drops, so the forwarder exits naturally with the VzMachineHandle. Not in Day 3 (deliberate, see PLAN.md): - scripts/dev/sign-elastos-vz/ and the entitlements plist (Day 4). - elastos vm-debug boot CLI command (Day 4). - Actual end-to-end guest boot validation on this Mac — that needs codesign first. - VZVirtualMachineDelegate via objc2::define_class! (Day 5+, for crash details / network disconnect). `set_session_for_vm`, `set_network_for_vm` and `append_boot_args_for_vm` keep returning PHASE_1_STUB_MESSAGE — they require late-bound mutation of a now-frozen VZVirtualMachineConfiguration, which we'll handle by reshaping the API to "set before load" in a later day. Tests: 59 passing (was 51). Eight new / updated: - ffi::lifecycle: vm_state mapping (all 10 variants), unknown-variant raw-int round-trip, entitlement-hint constant, format_validate_error embeds hint when Apple flags entitlement, format_validate_error passes other errors through unmodified. - ffi::console_forwarder: EOF exit + clean join, timeout when writer stays open, blank-line skip. - provider: capsule-not-found for missing rootfs (replaces Phase 1 stub assertion), lifecycle methods fail-closed with CapsuleNotFound for an unloaded handle (replaces the blanket PHASE_1_STUB_MESSAGE check). Gates: cargo build --workspace, cargo test -p elastos-vz, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check, scripts/check-linux-untouched.sh bcf5a0a, mac-vz-feature-check exit 0 — all green. On unsigned dev binaries `VzMachineHandle::new` fails with the operator-friendly entitlement-hint message; Day 4 ships the codesign script that lets the same path proceed to first boot. Anchors: docs/vz-backend/PLAN.md (Phase 2), PHASE_0_SCOPE.md §B/§D, PRINCIPLES.md "fail closed", state.md "support boundary". Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bf3baef commit 1fa2172

5 files changed

Lines changed: 1029 additions & 176 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
//! Kernel-console → `tracing` forwarder.
2+
//!
3+
//! Phase 0 §B "kernel console" row + §D pitfall #7 (no
4+
//! file-backed console). Apple's `VZVirtioConsoleDeviceSerialPortConfiguration`
5+
//! emits the guest's printk stream to whichever NSFileHandle we
6+
//! attached. [`ffi::console::build_kernel_console`] hands us the
7+
//! read end of a `pipe(2)`; this module drains that pipe and
8+
//! emits one `tracing::info!(target = "vm_console", ...)` per
9+
//! line of guest output, matching the contract crosvm uses on
10+
//! Linux so the supervisor's existing log routing keeps working.
11+
//!
12+
//! Sizing rationale:
13+
//!
14+
//! - Day 2's probe verified the pipe is the right transport
15+
//! (Vz writes guest serial output to our pipe).
16+
//! - The forwarder is intentionally minimal: a single blocking
17+
//! reader spawned via `tokio::task::spawn_blocking`. Per-line
18+
//! parsing keeps the supervisor's existing `vm_console`
19+
//! filtering rules portable.
20+
//! - Shutdown happens **naturally**: when the VM stops, Apple
21+
//! releases the `NSFileHandle` holding the pipe's write end,
22+
//! our read end sees EOF, the loop exits. The
23+
//! [`ConsoleForwarder::shutdown_and_join`] timeout exists only
24+
//! to bound the wait if Vz never closes the pipe (stuck VM,
25+
//! etc.).
26+
27+
use std::io::{BufRead, BufReader};
28+
use std::time::Duration;
29+
30+
use tokio::task::JoinHandle;
31+
32+
/// Handle returned by [`spawn_console_forwarder`]. Hold it for
33+
/// the lifetime of the VM. Tokio detaches the `JoinHandle` on
34+
/// drop, so the forwarder keeps running until the kernel
35+
/// console reaches EOF (which happens when the
36+
/// `VZVirtualMachine` releases the `NSFileHandle` — i.e. when
37+
/// `VzMachineHandle` itself drops).
38+
///
39+
/// Tests can call [`Self::shutdown_and_join`] to bound the wait
40+
/// for the task to finish in a deterministic way; production
41+
/// code never needs to — see the long-form note in
42+
/// [`crate::ffi::lifecycle::VzMachineHandle::stop`].
43+
pub(crate) struct ConsoleForwarder {
44+
// `dead_code` because the *value* `handle` is never read
45+
// outside `shutdown_and_join` (which itself is test-only as
46+
// of Day 3). The field still has to live on the struct so
47+
// tests can move it out via `self.handle`. Phase 5+ may
48+
// resurrect it for runtime-side abort once we add a
49+
// `VZVirtualMachineDelegate`.
50+
#[allow(dead_code)]
51+
handle: JoinHandle<()>,
52+
}
53+
54+
impl ConsoleForwarder {
55+
/// Wait for the forwarder to finish naturally (EOF on the
56+
/// pipe), with a hard upper bound. If the timeout elapses
57+
/// the task is aborted; any unread bytes are lost.
58+
///
59+
/// Production callers do **not** use this method — Apple's
60+
/// VZVirtualMachine holds the write fd open across `stop`,
61+
/// so a forced join would always time out. The method
62+
/// exists for tests that explicitly close their own writer.
63+
#[allow(dead_code)]
64+
pub(crate) async fn shutdown_and_join(self, timeout: Duration) -> Result<(), String> {
65+
match tokio::time::timeout(timeout, self.handle).await {
66+
Ok(Ok(())) => Ok(()),
67+
Ok(Err(join_err)) => {
68+
// JoinError covers panics + cancellation. Either
69+
// is unusual enough to surface.
70+
Err(format!(
71+
"console forwarder task did not finish cleanly: {join_err}"
72+
))
73+
}
74+
Err(_) => Err(format!(
75+
"console forwarder did not finish within {} ms (guest may be hung)",
76+
timeout.as_millis()
77+
)),
78+
}
79+
}
80+
}
81+
82+
/// Spawn a forwarder that drains `host_read` to `tracing`.
83+
///
84+
/// `vm_id` is attached to every emitted event under the
85+
/// `vm_console.vm_id` field so multiple VMs can share a single
86+
/// tracing subscriber without log-line confusion.
87+
pub(crate) fn spawn_console_forwarder(host_read: std::fs::File, vm_id: String) -> ConsoleForwarder {
88+
let handle = tokio::task::spawn_blocking(move || {
89+
let mut reader = BufReader::new(host_read);
90+
let mut buf = String::new();
91+
92+
loop {
93+
buf.clear();
94+
match reader.read_line(&mut buf) {
95+
Ok(0) => {
96+
// EOF — Vz closed the write end. Normal
97+
// shutdown path.
98+
tracing::debug!(
99+
target: "vm_console",
100+
vm_id = %vm_id,
101+
"kernel console EOF (vz closed write end)"
102+
);
103+
return;
104+
}
105+
Ok(_) => {
106+
// Trim the trailing newline (if any) so the
107+
// tracing event reads cleanly in JSON
108+
// formatters.
109+
let line = buf.trim_end_matches(['\n', '\r']);
110+
if line.is_empty() {
111+
continue;
112+
}
113+
tracing::info!(
114+
target: "vm_console",
115+
vm_id = %vm_id,
116+
"{line}"
117+
);
118+
}
119+
Err(e)
120+
if matches!(
121+
e.kind(),
122+
std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
123+
) =>
124+
{
125+
// Spurious wakeups — keep reading. The pipe
126+
// is blocking by default so WouldBlock is
127+
// unlikely, but handle it for resilience if
128+
// future code sets O_NONBLOCK.
129+
continue;
130+
}
131+
Err(e) => {
132+
tracing::warn!(
133+
target: "vm_console",
134+
vm_id = %vm_id,
135+
"kernel console read error: {e}"
136+
);
137+
return;
138+
}
139+
}
140+
}
141+
});
142+
143+
ConsoleForwarder { handle }
144+
}
145+
146+
#[cfg(test)]
147+
mod tests {
148+
use super::*;
149+
use std::io::Write;
150+
use std::os::fd::FromRawFd;
151+
152+
/// Build a `pipe(2)` pair and return `(read_file, write_file)`.
153+
fn pipe_pair() -> (std::fs::File, std::fs::File) {
154+
let mut fds = [0i32; 2];
155+
// SAFETY: standard POSIX pipe call; on success two valid
156+
// fds are written.
157+
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
158+
assert_eq!(rc, 0, "pipe(2) failed");
159+
// SAFETY: fds were just produced by pipe(2) and have no
160+
// other Rust owner.
161+
let r = unsafe { std::fs::File::from_raw_fd(fds[0]) };
162+
let w = unsafe { std::fs::File::from_raw_fd(fds[1]) };
163+
(r, w)
164+
}
165+
166+
#[tokio::test(flavor = "multi_thread")]
167+
async fn forwarder_exits_on_eof_and_joins_clean() {
168+
let (read, mut write) = pipe_pair();
169+
let forwarder = spawn_console_forwarder(read, "phase2-day3-eof".to_string());
170+
171+
// Send a few lines, then close the write end.
172+
writeln!(&mut write, "boot line A").unwrap();
173+
writeln!(&mut write, "boot line B").unwrap();
174+
drop(write); // close write end → reader sees EOF
175+
176+
forwarder
177+
.shutdown_and_join(Duration::from_secs(2))
178+
.await
179+
.expect("forwarder should complete on EOF within 2s");
180+
}
181+
182+
#[tokio::test(flavor = "multi_thread")]
183+
async fn forwarder_shutdown_times_out_if_writer_stays_open() {
184+
let (read, write) = pipe_pair();
185+
let forwarder = spawn_console_forwarder(read, "phase2-day3-timeout".to_string());
186+
187+
// Keep `write` alive so the read never sees EOF. The
188+
// shutdown_and_join must time out and surface a clear
189+
// error so the supervisor knows the guest is hung.
190+
let err = forwarder
191+
.shutdown_and_join(Duration::from_millis(200))
192+
.await
193+
.expect_err("forwarder must time out while writer is held");
194+
assert!(
195+
err.contains("did not finish within"),
196+
"expected timeout error, got: {err}"
197+
);
198+
199+
// Drop the writer so the spawned task can exit, leaving
200+
// no leaked threads behind the test.
201+
drop(write);
202+
}
203+
204+
#[tokio::test(flavor = "multi_thread")]
205+
async fn forwarder_skips_empty_lines() {
206+
// We can't easily intercept tracing events without
207+
// pulling in `tracing-subscriber` as a dev-dep, but we
208+
// can at least confirm the forwarder exits cleanly when
209+
// fed only blank lines + an EOF.
210+
let (read, mut write) = pipe_pair();
211+
let forwarder = spawn_console_forwarder(read, "phase2-day3-blanks".to_string());
212+
213+
writeln!(&mut write).unwrap();
214+
writeln!(&mut write).unwrap();
215+
writeln!(&mut write, "actual content").unwrap();
216+
drop(write);
217+
218+
forwarder
219+
.shutdown_and_join(Duration::from_secs(2))
220+
.await
221+
.expect("forwarder completes after blank lines + EOF");
222+
}
223+
}

0 commit comments

Comments
 (0)