Skip to content
Open
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
56 changes: 56 additions & 0 deletions src/boxlite/src/portal/interfaces/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,23 @@ impl ExecProtocol {
tracing::trace!(len = chunk.data.len(), "Received exec stderr");
stderr.send_bytes(chunk.data);
}
Some(exec_output::Event::Dropped(dropped)) => {
tracing::warn!(
stdout_bytes = dropped.stdout_bytes,
stderr_bytes = dropped.stderr_bytes,
"Guest output buffer dropped older output"
);
if dropped.stdout_bytes > 0 {
stdout.flush();
}
if dropped.stderr_bytes > 0 {
stderr.flush();
}
let _ = stderr.tx.send(format!(
"[boxlite] output dropped (stdout: {} bytes, stderr: {} bytes)\n",
dropped.stdout_bytes, dropped.stderr_bytes
));
}
None => {}
}
}
Expand Down Expand Up @@ -1162,6 +1179,45 @@ mod tests {
assert!(stderr_rx.try_recv().is_err());
}

#[test]
fn output_drop_only_flushes_the_affected_utf8_decoder() {
use boxlite_shared::{OutputDropped, Stderr as StderrMsg, exec_output};

let (stdout_tx, mut stdout_rx) = mpsc::unbounded_channel::<String>();
let (stderr_tx, mut stderr_rx) = mpsc::unbounded_channel::<String>();
let mut stdout = DecodedStream::new(stdout_tx);
let mut stderr = DecodedStream::new(stderr_tx);

let output = |event| ExecOutput { event: Some(event) };
ExecProtocol::route_output(
output(exec_output::Event::Stderr(StderrMsg { data: vec![0xE2] })),
&mut stdout,
&mut stderr,
);
ExecProtocol::route_output(
output(exec_output::Event::Dropped(OutputDropped {
stdout_bytes: 1,
stderr_bytes: 0,
})),
&mut stdout,
&mut stderr,
);
ExecProtocol::route_output(
output(exec_output::Event::Stderr(StderrMsg {
data: vec![0x94, 0x80],
})),
&mut stdout,
&mut stderr,
);

assert!(stdout_rx.try_recv().is_err());
assert_eq!(
stderr_rx.try_recv().ok(),
Some("[boxlite] output dropped (stdout: 1 bytes, stderr: 0 bytes)\n".to_string())
);
assert_eq!(stderr_rx.try_recv().ok(), Some("─".to_string()));
}

/// Flushing a DecodedStream must drain held-over bytes, leave the
/// decoder in a valid drained state, and be idempotent. The attach loop
/// flushes both streams on every exit path (clean EOF, transport error,
Expand Down
86 changes: 86 additions & 0 deletions src/boxlite/tests/run_main_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,92 @@ async fn attached_stdout(opts: BoxOptions) -> String {
stdout
}

#[tokio::test]
async fn main_command_exits_after_large_output_without_attach() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");

let handle = runtime
.create(
main_command_opts(&["sh", "-c", "head -c 1048576 /dev/zero; exit 23"], false),
None,
)
.await
.expect("create box");

let completed = tokio::time::timeout(std::time::Duration::from_secs(30), async {
handle.start().await.expect("start box");
loop {
if handle.info().status == boxlite::BoxStatus::Stopped {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
})
.await
.unwrap_or(false);

let _ = handle.stop().await;
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;

assert!(
completed,
"a main command that has no Attach consumer must still drain and exit"
);
}

#[tokio::test]
async fn late_attach_reports_output_dropped() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");

let handle = runtime
.create(
main_command_opts(&["sh", "-c", "head -c 2097152 /dev/zero; sleep 30"], false),
None,
)
.await
.expect("create box");

handle.start().await.expect("start box");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;

let mut execution = handle
.attach(None)
.await
.expect("attach to the main command");
let mut stderr = execution.stderr().expect("stderr stream");
let dropped = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Some(chunk) = stderr.next().await {
if chunk.contains("[boxlite] output dropped") {
return Some(chunk);
}
}
None
})
.await
.unwrap_or(None);

let _ = handle.stop().await;
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;

let dropped = dropped.expect("late attach must report overwritten output");
assert!(
dropped.contains("stdout:") && dropped.contains("stderr: 0 bytes"),
"stdout-only loss must preserve the stderr decoder: {dropped:?}"
);
}

/// `tty: true` must give the *main command* a real terminal.
///
/// `test -t 0` asks the kernel, so this cannot pass unless init's fd 0 really
Expand Down
8 changes: 3 additions & 5 deletions src/guest/src/service/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#[cfg(target_os = "linux")]
pub mod exec_handle;
pub(in crate::service) mod executor;
mod output;
pub(in crate::service) mod registry;
pub(in crate::service) mod state;
mod timeout;
Expand All @@ -35,7 +36,6 @@ use boxlite_shared::{
};
use futures::stream::Stream;
use std::pin::Pin;
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status, Streaming};
use tracing::{debug, info, warn};

Expand Down Expand Up @@ -82,11 +82,9 @@ impl Execution for GuestServer {
.ok_or_else(|| Status::not_found(format!("Execution not found: {}", exec_id)))?;

// Call state directly
let rx = state.attach(&exec_id).await?;
let output = state.attach().await?;

Ok(Response::new(
Box::pin(ReceiverStream::new(rx)) as Self::AttachStream
))
Ok(Response::new(output))
}

async fn send_input(
Expand Down
Loading
Loading