From a0a63ac6f1fe48698735b7d83a7bd54a3757753f Mon Sep 17 00:00:00 2001 From: Peron Date: Fri, 24 Apr 2026 16:10:00 +0800 Subject: [PATCH] feat(sdk): capture firecracker stdio via console_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firecracker pipes the guest serial console (ttyS0) to its own stdout, and FirecrackerProcessBuilder::spawn was leaving stdio on inherit — so kernel boot output landed wherever the parent's stdout happened to go, and a closed parent pipe produced `Failed the write to serial: BrokenPipe` spam in firecracker's own log file. Add four additive knobs to FirecrackerProcessBuilder: - console_path(path): open the file in create+append mode at spawn time; use it for stdout and stderr (cloned fd) unless a per-channel override is set. Typical use: capturing the guest serial console into an instance-local log file. - stdin(Stdio) / stdout(Stdio) / stderr(Stdio): raw Stdio overrides for callers that need finer control. Resolution is "explicit per-channel override wins, then console_path, then command default (inherit)". Factored into `apply_stdio` so the matrix is testable without a live firecracker binary. fc-cli gains a --console-path flag on the firecracker backend; the jailer backend rejects it for now (TODO near the jailer builder spawn documents a follow-up). Additive only — bump 0.3.0 -> 0.3.1. --- Cargo.lock | 261 ++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 10 +- fc-cli/src/main.rs | 16 +++ fc-sdk/Cargo.toml | 3 + fc-sdk/src/process.rs | 234 ++++++++++++++++++++++++++++++++++++- 5 files changed, 505 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 229075f..8e23ff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -235,9 +241,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fc-api" -version = "0.3.0" +version = "0.3.1" dependencies = [ "futures", "openapiv3", @@ -252,7 +264,7 @@ dependencies = [ [[package]] name = "fc-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "firecracker", @@ -261,26 +273,33 @@ dependencies = [ [[package]] name = "fc-sdk" -version = "0.3.0" +version = "0.3.1" dependencies = [ "fc-api", "libc", "reqwest", "serde", "serde_json", + "tempfile", "thiserror", "tokio", ] [[package]] name = "firecracker" -version = "0.3.0" +version = "0.3.1" dependencies = [ "fc-api", "fc-sdk", "sha2", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -395,6 +414,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -403,7 +444,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -577,6 +618,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -605,7 +652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -648,11 +695,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -868,6 +927,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -912,7 +977,7 @@ version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "memchr", ] @@ -951,6 +1016,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1182,6 +1260,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1382,6 +1473,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1439,6 +1536,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1498,6 +1613,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -1511,6 +1648,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -1610,6 +1759,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 68319f7..40fd294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,16 +4,16 @@ resolver = "3" [workspace.package] edition = "2024" -version = "0.3.0" +version = "0.3.1" authors = ["ArcBox Labs ", "AprilNEA "] license = "MIT OR Apache-2.0" repository = "https://github.com/arcboxlabs/firecracker-client" [workspace.dependencies] -firecracker = { version = "0.3.0", path = "." } -fc-api = { version = "0.3.0", path = "fc-api" } -fc-sdk = { version = "0.3.0", path = "fc-sdk" } -fc-cli = { version = "0.3.0", path = "fc-cli" } +firecracker = { version = "0.3.1", path = "." } +fc-api = { version = "0.3.1", path = "fc-api" } +fc-sdk = { version = "0.3.1", path = "fc-sdk" } +fc-cli = { version = "0.3.1", path = "fc-cli" } openapiv3 = "2" progenitor = "0.11" progenitor-client = "0.11" diff --git a/fc-cli/src/main.rs b/fc-cli/src/main.rs index d620056..36553c5 100644 --- a/fc-cli/src/main.rs +++ b/fc-cli/src/main.rs @@ -186,6 +186,13 @@ struct StartArgs { #[arg(long)] log_level: Option, + /// Path that receives Firecracker's stdout + stderr (guest serial console). + /// + /// Opened in create+append mode. Without this flag stdio inherits from the + /// parent process. Firecracker backend only. + #[arg(long)] + console_path: Option, + /// Socket readiness timeout (seconds). #[arg(long, default_value_t = 5)] socket_timeout_secs: u64, @@ -359,10 +366,19 @@ async fn spawn_process( if let Some(log_level) = &args.log_level { builder = builder.log_level(log_level.clone()); } + if let Some(console_path) = &args.console_path { + builder = builder.console_path(console_path.clone()); + } Ok(builder.spawn().await?) } StartBackend::Jailer => { + if args.console_path.is_some() { + return Err(invalid_input( + "--console-path is only supported when --backend firecracker", + ) + .into()); + } let id = args .id .clone() diff --git a/fc-sdk/Cargo.toml b/fc-sdk/Cargo.toml index eefe935..1a5b191 100644 --- a/fc-sdk/Cargo.toml +++ b/fc-sdk/Cargo.toml @@ -16,3 +16,6 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/fc-sdk/src/process.rs b/fc-sdk/src/process.rs index 40a0fbc..48ed86f 100644 --- a/fc-sdk/src/process.rs +++ b/fc-sdk/src/process.rs @@ -12,6 +12,7 @@ //! # async fn example() -> fc_sdk::Result<()> { //! let process = FirecrackerProcessBuilder::new("firecracker", "/tmp/firecracker.sock") //! .id(VmId::new("my-vm")?) +//! .console_path("/tmp/firecracker-console.log") //! .spawn() //! .await?; //! @@ -72,7 +73,9 @@ //! # } //! ``` +use std::fs::OpenOptions; use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::time::Duration; use tokio::process::{Child, Command}; @@ -127,6 +130,10 @@ pub struct FirecrackerProcessBuilder { socket_timeout: Duration, socket_poll_interval: Duration, cleanup_socket: bool, + console_path: Option, + stdin: Option, + stdout: Option, + stderr: Option, } impl FirecrackerProcessBuilder { @@ -150,6 +157,10 @@ impl FirecrackerProcessBuilder { socket_timeout: Duration::from_secs(5), socket_poll_interval: Duration::from_millis(50), cleanup_socket: true, + console_path: None, + stdin: None, + stdout: None, + stderr: None, } } @@ -246,6 +257,58 @@ impl FirecrackerProcessBuilder { self } + /// Route the guest serial console (firecracker's stdout) and any stderr + /// diagnostics to `path`. + /// + /// Firecracker pipes the guest ttyS0 to its own stdout, so when the + /// kernel cmdline includes `console=ttyS0` the boot log and any later + /// serial output land wherever the spawned firecracker process's stdout + /// goes. This file is opened in create+append mode at [`spawn`] time. + /// + /// Explicit [`stdout`] / [`stderr`] overrides take precedence per-channel; + /// [`stdin`] is left at the process default regardless. + /// + /// [`spawn`]: Self::spawn + /// [`stdin`]: Self::stdin + /// [`stdout`]: Self::stdout + /// [`stderr`]: Self::stderr + pub fn console_path(mut self, path: impl Into) -> Self { + self.console_path = Some(path.into()); + self + } + + /// Override stdin for the spawned Firecracker process. + /// + /// Passed through verbatim to [`tokio::process::Command::stdin`]. + pub fn stdin(mut self, stdio: Stdio) -> Self { + self.stdin = Some(stdio); + self + } + + /// Override stdout for the spawned Firecracker process. + /// + /// Passed through verbatim to [`tokio::process::Command::stdout`]. When + /// set, this takes precedence over [`console_path`] for the stdout + /// channel. + /// + /// [`console_path`]: Self::console_path + pub fn stdout(mut self, stdio: Stdio) -> Self { + self.stdout = Some(stdio); + self + } + + /// Override stderr for the spawned Firecracker process. + /// + /// Passed through verbatim to [`tokio::process::Command::stderr`]. When + /// set, this takes precedence over [`console_path`] for the stderr + /// channel. + /// + /// [`console_path`]: Self::console_path + pub fn stderr(mut self, stdio: Stdio) -> Self { + self.stderr = Some(stdio); + self + } + /// Build the command-line arguments for the Firecracker process. fn build_args(&self) -> Vec { let mut args = vec![ @@ -312,15 +375,23 @@ impl FirecrackerProcessBuilder { } /// Spawn the Firecracker process and wait for the socket to become available. - pub async fn spawn(self) -> Result { + pub async fn spawn(mut self) -> Result { if self.cleanup_socket && self.socket_path.exists() { std::fs::remove_file(&self.socket_path).ok(); } - let child = Command::new(&self.firecracker_bin) - .args(self.build_args()) - .spawn() - .map_err(Error::SpawnFailed)?; + let args = self.build_args(); + let mut command = Command::new(&self.firecracker_bin); + command.args(args); + apply_stdio( + &mut command, + self.stdin.take(), + self.stdout.take(), + self.stderr.take(), + self.console_path.as_deref(), + )?; + + let child = command.spawn().map_err(Error::SpawnFailed)?; let pid = child.id(); let socket_path = self.socket_path.clone(); @@ -352,6 +423,61 @@ impl FirecrackerProcessBuilder { } } +/// Wire optional stdio overrides and `console_path` into a [`Command`]. +/// +/// Resolution per channel: an explicit override (`stdin` / `stdout` / +/// `stderr`) wins; otherwise `console_path` fills stdout + stderr from the +/// same file (opened in create+append mode, cloned for the second channel); +/// otherwise the channel is left at `Command`'s default (inherit from the +/// parent process). +fn apply_stdio( + command: &mut Command, + stdin: Option, + stdout: Option, + stderr: Option, + console_path: Option<&Path>, +) -> Result<()> { + if let Some(s) = stdin { + command.stdin(s); + } + + let open_console = || -> std::io::Result { + OpenOptions::new() + .create(true) + .append(true) + .open(console_path.expect("called only when console_path is set")) + }; + + match (stdout, stderr, console_path) { + (Some(out), Some(err), _) => { + command.stdout(out); + command.stderr(err); + } + (Some(out), None, Some(_)) => { + command.stdout(out); + command.stderr(Stdio::from(open_console()?)); + } + (Some(out), None, None) => { + command.stdout(out); + } + (None, Some(err), Some(_)) => { + command.stdout(Stdio::from(open_console()?)); + command.stderr(err); + } + (None, Some(err), None) => { + command.stderr(err); + } + (None, None, Some(_)) => { + let file = open_console()?; + command.stdout(Stdio::from(file.try_clone()?)); + command.stderr(Stdio::from(file)); + } + (None, None, None) => {} + } + + Ok(()) +} + // ============================================================================= // JailerProcessBuilder // ============================================================================= @@ -553,6 +679,10 @@ impl JailerProcessBuilder { } /// Spawn the Jailer process and wait for the Firecracker socket to become available. + // TODO: mirror FirecrackerProcessBuilder's `console_path` / `stdin` / `stdout` + // / `stderr` knobs once a caller actually needs to capture stdio through the + // jailer chroot. Jailer's optional `--daemonize` detaches stdio, so the + // semantics need a bit more care than the direct case. pub async fn spawn(self) -> Result { let socket_path = self.socket_path(); let socket_timeout = self.socket_timeout; @@ -727,6 +857,100 @@ impl Drop for FirecrackerProcess { mod tests { use super::*; + #[tokio::test] + async fn apply_stdio_routes_both_channels_to_console_path() { + let temp = tempfile::TempDir::new().unwrap(); + let console = temp.path().join("console.log"); + + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "printf OUT; printf ERR >&2"]); + apply_stdio(&mut cmd, None, None, None, Some(&console)).unwrap(); + + let status = cmd.spawn().unwrap().wait().await.unwrap(); + assert!(status.success()); + + let contents = std::fs::read_to_string(&console).unwrap(); + // stdout + stderr interleave is not ordering-stable, but both channels + // must have reached the single file. + assert!( + contents.contains("OUT"), + "missing stdout marker in {contents:?}" + ); + assert!( + contents.contains("ERR"), + "missing stderr marker in {contents:?}" + ); + } + + #[tokio::test] + async fn apply_stdio_explicit_channel_overrides_console_path() { + let temp = tempfile::TempDir::new().unwrap(); + let console = temp.path().join("console.log"); + + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "printf OUT; printf ERR >&2"]); + // Send stdout to /dev/null; stderr still falls through to console_path. + apply_stdio(&mut cmd, None, Some(Stdio::null()), None, Some(&console)).unwrap(); + + let status = cmd.spawn().unwrap().wait().await.unwrap(); + assert!(status.success()); + + let contents = std::fs::read_to_string(&console).unwrap(); + assert!( + !contents.contains("OUT"), + "stdout override leaked into console file: {contents:?}" + ); + assert!( + contents.contains("ERR"), + "missing stderr marker in {contents:?}" + ); + } + + #[tokio::test] + async fn apply_stdio_appends_to_existing_console_file() { + let temp = tempfile::TempDir::new().unwrap(); + let console = temp.path().join("console.log"); + std::fs::write(&console, "prior\n").unwrap(); + + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "printf after"]); + apply_stdio(&mut cmd, None, None, None, Some(&console)).unwrap(); + + let status = cmd.spawn().unwrap().wait().await.unwrap(); + assert!(status.success()); + + let contents = std::fs::read_to_string(&console).unwrap(); + assert!( + contents.starts_with("prior\n"), + "append mode clobbered file: {contents:?}" + ); + assert!( + contents.ends_with("after"), + "append mode dropped new bytes: {contents:?}" + ); + } + + #[test] + fn apply_stdio_returns_io_error_when_console_parent_does_not_exist() { + let mut cmd = Command::new("/bin/true"); + let missing = PathBuf::from("/tmp/fc-sdk-apply-stdio-nonexistent-parent/console.log"); + let err = apply_stdio(&mut cmd, None, None, None, Some(&missing)) + .expect_err("missing parent dir must surface as Io error"); + assert!( + matches!(err, Error::Io(_)), + "unexpected error variant: {err:?}" + ); + } + + #[test] + fn apply_stdio_noop_when_no_overrides_or_console_path() { + // Smoke test: the no-config path must not error, must not touch + // filesystem, and leaves the command free to inherit the parent's + // stdio on spawn. + let mut cmd = Command::new("/bin/true"); + apply_stdio(&mut cmd, None, None, None, None).unwrap(); + } + #[test] fn test_firecracker_builder_args() { let builder = FirecrackerProcessBuilder::new("/usr/bin/firecracker", "/tmp/fc.sock")