Skip to content

Commit 8ebccf3

Browse files
feat: interactive mode, daemon logging, fresh setup, HOME env
Interactive mode (vz run -i): - Terminal set to raw mode via crossterm - Stdin forwarded to guest PTY via write_exec_stdin RPC - Arrow keys, ctrl sequences, paste all work - Terminal restored on exit - Tested: compiled+ran Rust in interactive bash session Daemon logging: - Daemon writes log file directly via tracing with_writer (not stderr) - Fixes empty log file issue (tracing to stderr + file redirect didn't flush) - vz logs now shows daemon output immediately - Smarter error when daemon predates log support Other fixes: - HOME=/root set in both setup exec and run exec (rustup, npm need it) - --fresh now clears setup hash on both host and guest (forces re-run) - getcwd() warning filtered in setup output (was only filtered in run) - Stale state store lock files cleaned up on daemon spawn - Stale log file removed on daemon restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ea78863 commit 8ebccf3

4 files changed

Lines changed: 166 additions & 21 deletions

File tree

crates/vz-cli/src/commands/dev.rs

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
use std::collections::{BTreeMap, HashMap};
88
use std::io::Write as _;
99
use std::path::{Path, PathBuf};
10+
use std::sync::Arc;
11+
use std::sync::atomic::{AtomicBool, Ordering};
1012

1113
use anyhow::{Context, anyhow, bail};
1214
use clap::Args;
15+
use crossterm::terminal;
1316
use serde::Deserialize;
1417
use sha2::{Digest, Sha256};
1518
use tracing::debug;
@@ -235,6 +238,19 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
235238
eprintln!("VM ready.");
236239

237240
// Run setup commands if needed.
241+
// When --fresh, force re-run by clearing any cached hashes.
242+
if args.fresh {
243+
if let Ok(path) = host_setup_hash_path(&sandbox_id) {
244+
let _ = std::fs::remove_file(path);
245+
}
246+
let container_id = resolve_container(&mut client, &sandbox_id).await?;
247+
let _ = exec_quiet(
248+
&mut client,
249+
&container_id,
250+
"rm -f /run/vz-oci/volumes/.vz-setup-hash",
251+
)
252+
.await;
253+
}
238254
run_setup_if_needed(&mut client, &sandbox_id, &config).await?;
239255
}
240256

@@ -245,6 +261,11 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
245261
let shell_command = args.command.join(" ");
246262
let mut env_map = config.env.clone();
247263

264+
// Ensure HOME is always set — many tools (rustup, npm, etc.) depend on it.
265+
if !env_map.contains_key("HOME") {
266+
env_map.insert("HOME".to_string(), "/root".to_string());
267+
}
268+
248269
// Auto-detect Rust projects and set CARGO_TARGET_DIR to persistent disk
249270
// so build artifacts survive VM restarts.
250271
if !env_map.contains_key("CARGO_TARGET_DIR") && project_dir.join("Cargo.toml").exists() {
@@ -295,7 +316,69 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
295316
let execution_payload = execution
296317
.execution
297318
.ok_or_else(|| anyhow!("daemon missing execution payload"))?;
298-
let execution_id = execution_payload.execution_id;
319+
let execution_id = execution_payload.execution_id.clone();
320+
321+
// For interactive mode: enable raw terminal and forward stdin to the PTY.
322+
let stdin_stop = Arc::new(AtomicBool::new(false));
323+
let stdin_handle = if args.interactive {
324+
terminal::enable_raw_mode().context("failed to enable raw mode")?;
325+
326+
let stop = Arc::clone(&stdin_stop);
327+
let exec_id = execution_id.clone();
328+
let mut stdin_client = client.clone();
329+
Some(tokio::task::spawn_blocking(move || {
330+
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
331+
while !stop.load(Ordering::Relaxed) {
332+
if !event::poll(std::time::Duration::from_millis(100)).unwrap_or(false) {
333+
continue;
334+
}
335+
let Ok(ev) = event::read() else { break };
336+
let bytes = match ev {
337+
Event::Key(key)
338+
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
339+
{
340+
match key.code {
341+
KeyCode::Char(c)
342+
if key.modifiers.contains(KeyModifiers::CONTROL) =>
343+
{
344+
vec![c as u8 & 0x1f]
345+
}
346+
KeyCode::Char(c) => {
347+
let mut buf = [0u8; 4];
348+
c.encode_utf8(&mut buf);
349+
buf[..c.len_utf8()].to_vec()
350+
}
351+
KeyCode::Enter => vec![b'\r'],
352+
KeyCode::Backspace => vec![0x7f],
353+
KeyCode::Tab => vec![b'\t'],
354+
KeyCode::Esc => vec![0x1b],
355+
KeyCode::Up => vec![0x1b, b'[', b'A'],
356+
KeyCode::Down => vec![0x1b, b'[', b'B'],
357+
KeyCode::Right => vec![0x1b, b'[', b'C'],
358+
KeyCode::Left => vec![0x1b, b'[', b'D'],
359+
KeyCode::Home => vec![0x1b, b'[', b'H'],
360+
KeyCode::End => vec![0x1b, b'[', b'F'],
361+
KeyCode::Delete => vec![0x1b, b'[', b'3', b'~'],
362+
_ => continue,
363+
}
364+
}
365+
Event::Paste(text) => text.into_bytes(),
366+
_ => continue,
367+
};
368+
369+
let rt = tokio::runtime::Handle::current();
370+
let _ = rt.block_on(stdin_client.write_exec_stdin(
371+
runtime_v2::WriteExecStdinRequest {
372+
execution_id: exec_id.clone(),
373+
data: bytes,
374+
metadata: None,
375+
},
376+
));
377+
}
378+
}))
379+
} else {
380+
None
381+
};
299382

300383
let mut stream = client
301384
.stream_exec_output(runtime_v2::StreamExecOutputRequest {
@@ -317,21 +400,31 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
317400
let _ = std::io::stdout().flush();
318401
}
319402
Some(runtime_v2::exec_output_event::Payload::Stderr(bytes)) => {
320-
// Filter the harmless getcwd() warning from the shell.
321-
// The kernel's getcwd() syscall fails with stacked
322-
// overlay+VirtioFS mounts but CWD is actually correct.
323403
write_filtered_stderr(&bytes);
324404
}
325405
Some(runtime_v2::exec_output_event::Payload::ExitCode(code)) => {
326406
exit_code = code;
327407
}
328408
Some(runtime_v2::exec_output_event::Payload::Error(error)) => {
409+
if args.interactive {
410+
stdin_stop.store(true, Ordering::Relaxed);
411+
let _ = terminal::disable_raw_mode();
412+
}
329413
bail!("execution error: {error}");
330414
}
331415
None => {}
332416
}
333417
}
334418

419+
// Clean up interactive mode.
420+
if args.interactive {
421+
stdin_stop.store(true, Ordering::Relaxed);
422+
let _ = terminal::disable_raw_mode();
423+
if let Some(handle) = stdin_handle {
424+
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
425+
}
426+
}
427+
335428
if exit_code != 0 {
336429
std::process::exit(exit_code);
337430
}
@@ -704,7 +797,9 @@ async fn exec_streaming(
704797
"-c".to_string(),
705798
format!("cd / && {command}"),
706799
],
707-
env_override: HashMap::new(),
800+
env_override: HashMap::from([
801+
("HOME".to_string(), "/root".to_string()),
802+
]),
708803
timeout_secs: 3600,
709804
pty_mode: runtime_v2::create_execution_request::PtyMode::Disabled as i32,
710805
})
@@ -731,8 +826,7 @@ async fn exec_streaming(
731826
let _ = std::io::stdout().flush();
732827
}
733828
Some(runtime_v2::exec_output_event::Payload::Stderr(bytes)) => {
734-
let _ = std::io::stderr().write_all(&bytes);
735-
let _ = std::io::stderr().flush();
829+
write_filtered_stderr(&bytes);
736830
}
737831
Some(runtime_v2::exec_output_event::Payload::ExitCode(code)) => {
738832
exit_code = Some(code);

crates/vz-cli/src/commands/dev_logs.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,24 @@ pub async fn cmd_dev_logs(args: DevLogsArgs) -> anyhow::Result<()> {
2323
let log_path = resolve_log_path()?;
2424

2525
if !log_path.exists() {
26+
// Check if the daemon socket exists — if so, the daemon was started
27+
// before log-file support was added.
28+
let socket_path = log_path.with_extension("sock");
29+
if socket_path.exists() {
30+
bail!(
31+
"no log file found (daemon was started before log support).\n\
32+
Restart the daemon to enable logging:\n\
33+
\n vz stop && vz run <command>"
34+
);
35+
}
2636
bail!(
2737
"no daemon log file found at {}\n\
28-
The daemon may not have been started yet. Run `vz run` first.",
38+
The daemon has not been started yet. Run `vz run` first.",
2939
log_path.display()
3040
);
3141
}
3242

3343
if args.follow {
34-
// Use tail -f for follow mode.
3544
let status = std::process::Command::new("tail")
3645
.arg("-f")
3746
.arg("-n")

crates/vz-runtimed-client/src/lib.rs

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -348,17 +348,41 @@ impl DaemonClient {
348348

349349
// Remove stale socket so spawn_daemon gets a clean bind.
350350
let _ = std::fs::remove_file(socket_path);
351+
// Remove stale log file so the new daemon gets a fresh one.
352+
let _ = std::fs::remove_file(socket_path.with_extension("log"));
351353

352354
// Brief pause for process cleanup.
353355
std::thread::sleep(Duration::from_millis(200));
354356
}
355357

358+
/// Remove stale state store lock files that prevent daemon startup.
359+
///
360+
/// Called before spawning to clean up after ungraceful daemon termination.
361+
fn clean_stale_lock(config: &DaemonClientConfig) {
362+
if let Some(state_store_path) = &config.state_store_path {
363+
let lock_path = state_store_path.with_extension("db.lock");
364+
if lock_path.exists() {
365+
let _ = std::fs::remove_file(&lock_path);
366+
}
367+
// Also try without the .db extension in case the path doesn't end in .db
368+
let mut lock_path_alt = state_store_path.as_os_str().to_owned();
369+
lock_path_alt.push(".lock");
370+
let lock_path_alt = Path::new(&lock_path_alt);
371+
if lock_path_alt.exists() {
372+
let _ = std::fs::remove_file(lock_path_alt);
373+
}
374+
}
375+
}
376+
356377
fn spawn_daemon(config: &DaemonClientConfig) -> Result<()> {
357378
let binary = resolve_daemon_binary(config)?;
358379
if !binary.exists() {
359380
return Err(DaemonClientError::BinaryNotFound { path: binary });
360381
}
361382

383+
// Clean up stale lock files from ungraceful daemon termination.
384+
Self::clean_stale_lock(config);
385+
362386
if let Some(parent) = config.socket_path.parent()
363387
&& !parent.as_os_str().is_empty()
364388
{
@@ -374,20 +398,13 @@ impl DaemonClient {
374398
std::fs::create_dir_all(runtime_data_dir)?;
375399
}
376400

377-
// Direct daemon stderr to a log file for `vz logs` support.
378-
let log_file_path = config.socket_path.with_extension("log");
379-
let stderr_target = std::fs::OpenOptions::new()
380-
.create(true)
381-
.append(true)
382-
.open(&log_file_path)
383-
.map(Stdio::from)
384-
.unwrap_or_else(|_| Stdio::null());
385-
401+
// Daemon writes its own log file via tracing (next to the socket),
402+
// so we can discard spawned process stdio.
386403
let mut command = Command::new(&binary);
387404
command
388405
.stdin(Stdio::null())
389406
.stdout(Stdio::null())
390-
.stderr(stderr_target)
407+
.stderr(Stdio::null())
391408
.arg("--socket-path")
392409
.arg(&config.socket_path);
393410

crates/vz-runtimed/src/main.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ struct Cli {
4141

4242
#[tokio::main]
4343
async fn main() -> Result<()> {
44-
init_tracing();
4544
let cli = Cli::parse();
4645

46+
// Write logs to a file next to the socket for `vz logs` support.
47+
let log_file_path = cli.socket_path.with_extension("log");
48+
init_tracing(Some(&log_file_path));
49+
4750
let daemon = Arc::new(
4851
RuntimeDaemon::start_with_checkpoint_retention_policy(
4952
RuntimedConfig {
@@ -87,8 +90,30 @@ async fn main() -> Result<()> {
8790
Ok(())
8891
}
8992

90-
fn init_tracing() {
93+
fn init_tracing(log_file: Option<&std::path::Path>) {
9194
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
95+
96+
if let Some(path) = log_file {
97+
if let Some(parent) = path.parent() {
98+
let _ = std::fs::create_dir_all(parent);
99+
}
100+
if let Ok(file) = std::fs::OpenOptions::new()
101+
.create(true)
102+
.append(true)
103+
.open(path)
104+
{
105+
tracing_subscriber::fmt()
106+
.with_env_filter(env_filter)
107+
.with_target(false)
108+
.with_ansi(false)
109+
.compact()
110+
.with_writer(file)
111+
.init();
112+
return;
113+
}
114+
}
115+
116+
// Fallback: write to stderr (for interactive use / debugging).
92117
tracing_subscriber::fmt()
93118
.with_env_filter(env_filter)
94119
.with_target(false)

0 commit comments

Comments
 (0)