Skip to content

Commit 15cfd9c

Browse files
authored
fix(interactive): flush stdout/stderr after streaming command output (#1226)
## Summary - Fix `clear` (and other commands producing output without trailing newlines) appearing delayed in interactive mode - Root cause: `print!()`/`eprint!()` in the streaming callback are line-buffered on TTY — ANSI escape codes from `clear` (`\x1b[2J\x1b[H`, no newline) stayed buffered until rustyline's next prompt draw - Add explicit `stdout().flush()` and `stderr().flush()` after each `print!()`/`eprint!()` in the streaming callback ## What changed `crates/bashkit-cli/src/interactive.rs`: - Added `use std::io::Write` import - Added `std::io::stdout().flush()` after `print!()` in the `exec_streaming` callback - Added `std::io::stderr().flush()` after `eprint!()` in the `exec_streaming` callback - Added test `clear_command_streams_ansi_escape_codes` verifying `clear` produces expected ANSI escape sequences via streaming ## Why Users reported that typing `clear` in interactive mode didn't clear the screen immediately — instead the screen cleared only when the *next* command was executed. This is because Rust's `print!()` macro uses line buffering when connected to a TTY, and the clear command's output contains no newline. ## Test plan - [x] New test: `clear_command_streams_ansi_escape_codes` — verifies `clear` emits `ESC[2J` and `ESC[H` via `exec_streaming` - [x] All 86 bashkit-cli tests pass - [x] All 2363 bashkit lib tests pass - [x] `cargo fmt --check` clean - [x] `cargo clippy -- -D warnings` clean - [x] Smoke test: `echo 'clear' | cargo run` emits correct ANSI codes - [x] Audited codebase for similar unflushed `print!`/`eprint!` — only other occurrence is in `main.rs` one-shot mode where `process::exit()` follows immediately (OS flushes on exit)
1 parent f744088 commit 15cfd9c

1 file changed

Lines changed: 33 additions & 0 deletions

File tree

crates/bashkit-cli/src/interactive.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use rustyline::hint::{Hint, Hinter};
1414
use rustyline::validate::Validator;
1515
use rustyline::{Config, Context, Editor, Helper};
1616
use std::borrow::Cow;
17+
use std::io::Write;
1718
use std::path::Path;
1819
use std::sync::Arc;
1920
use std::sync::atomic::{AtomicBool, Ordering};
@@ -505,9 +506,11 @@ pub async fn run(mut bash: bashkit::Bash) -> Result<i32> {
505506
Box::new(|stdout, stderr| {
506507
if !stdout.is_empty() {
507508
print!("{stdout}");
509+
let _ = std::io::stdout().flush();
508510
}
509511
if !stderr.is_empty() {
510512
eprint!("{stderr}");
513+
let _ = std::io::stderr().flush();
511514
}
512515
}),
513516
)
@@ -734,6 +737,36 @@ mod tests {
734737
assert_eq!(result.stdout, "yes\n");
735738
}
736739

740+
#[tokio::test]
741+
async fn clear_command_streams_ansi_escape_codes() {
742+
let mut bash = test_bash();
743+
let chunks: Arc<std::sync::Mutex<Vec<String>>> =
744+
Arc::new(std::sync::Mutex::new(Vec::new()));
745+
let chunks_cb = chunks.clone();
746+
let result = bash
747+
.exec_streaming(
748+
"clear",
749+
Box::new(move |stdout, _stderr| {
750+
if !stdout.is_empty() {
751+
chunks_cb.lock().unwrap().push(stdout.to_string());
752+
}
753+
}),
754+
)
755+
.await
756+
.unwrap();
757+
assert_eq!(result.exit_code, 0);
758+
let collected = chunks.lock().unwrap();
759+
let output: String = collected.iter().cloned().collect();
760+
assert!(
761+
output.contains("\x1b[2J"),
762+
"clear should emit ESC[2J: {output:?}"
763+
);
764+
assert!(
765+
output.contains("\x1b[H"),
766+
"clear should emit ESC[H: {output:?}"
767+
);
768+
}
769+
737770
#[tokio::test]
738771
async fn streaming_output_callback_invoked() {
739772
let mut bash = test_bash();

0 commit comments

Comments
 (0)