diff --git a/crates/codegen/kigi-bin/src/main.rs b/crates/codegen/kigi-bin/src/main.rs index 3f72ef1..7fb25b0 100644 --- a/crates/codegen/kigi-bin/src/main.rs +++ b/crates/codegen/kigi-bin/src/main.rs @@ -146,27 +146,33 @@ async fn run_setup_command(json: bool) { .expect("setup report has no non-serializable values"); println!("{out}"); if !report.configured { - eprintln!( - "Your team doesn't have a managed configuration yet. Ask a team admin to provision one." + kigi_tui::best_effort_stderr::eprint_line( + "Your team doesn't have a managed configuration yet. Ask a team admin to provision one.", ); } } Err(e) => { - eprintln!("Couldn't fetch managed configuration. {e}"); + kigi_tui::best_effort_stderr::eprint_line(&format!( + "Couldn't fetch managed configuration. {e}" + )); std::process::exit(1); } } return; } match managed_config::run_setup().await { - SetupOutcome::Installed => eprintln!("Applied managed configuration."), + SetupOutcome::Installed => { + kigi_tui::best_effort_stderr::eprint_line("Applied managed configuration.") + } SetupOutcome::NothingConfigured => { - eprintln!( - "Your team doesn't have a managed configuration yet. Ask a team admin to provision one." + kigi_tui::best_effort_stderr::eprint_line( + "Your team doesn't have a managed configuration yet. Ask a team admin to provision one.", ); } SetupOutcome::Failed(e) => { - eprintln!("Couldn't apply managed configuration. {e}"); + kigi_tui::best_effort_stderr::eprint_line(&format!( + "Couldn't apply managed configuration. {e}" + )); std::process::exit(1); } } @@ -208,8 +214,8 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> { println!("{info:#?}"); } else { print_leader_descriptor(&descriptor); - eprintln!( - " (detailed info unavailable — leader does not advertise control capabilities)" + kigi_tui::best_effort_stderr::eprint_line( + " (detailed info unavailable — leader does not advertise control capabilities)", ); } client.cancel(); @@ -220,7 +226,7 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> { async fn kill_leaders() -> Result<()> { let leaders = kigi_shell::leader::discover_leaders().await; if leaders.is_empty() { - eprintln!("No leader candidates found."); + kigi_tui::best_effort_stderr::eprint_line("No leader candidates found."); return Ok(()); } let mut killed = 0u32; @@ -231,7 +237,9 @@ async fn kill_leaders() -> Result<()> { }; if !kigi_shell::util::is_kigi_process(pid) { if let Some(ref lock) = d.lock_path { - eprintln!(" PID {pid} is not a kigi process, removing stale lock"); + kigi_tui::best_effort_stderr::eprint_line(&format!( + " PID {pid} is not a kigi process, removing stale lock" + )); let _ = std::fs::remove_file(lock); cleaned += 1; } @@ -240,19 +248,23 @@ async fn kill_leaders() -> Result<()> { } continue; } - eprintln!(" Killing leader PID {pid}"); + kigi_tui::best_effort_stderr::eprint_line(&format!(" Killing leader PID {pid}")); if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) { - eprintln!(" warning: failed to terminate PID {pid}: {e}"); + kigi_tui::best_effort_stderr::eprint_line(&format!( + " warning: failed to terminate PID {pid}: {e}" + )); continue; } killed += 1; } if killed > 0 { - eprintln!("Killed {killed} leader process(es)."); + kigi_tui::best_effort_stderr::eprint_line(&format!("Killed {killed} leader process(es).")); } else if cleaned > 0 { - eprintln!("No live leader processes found (cleaned up {cleaned} stale lock(s))."); + kigi_tui::best_effort_stderr::eprint_line(&format!( + "No live leader processes found (cleaned up {cleaned} stale lock(s))." + )); } else { - eprintln!("No live leader processes found."); + kigi_tui::best_effort_stderr::eprint_line("No live leader processes found."); } Ok(()) } @@ -295,7 +307,7 @@ fn print_leader_descriptor(d: &LeaderDescriptor) { .map(|p| p.display().to_string()) .unwrap_or_else(|| "?".into()); let state = format!("{:?}", d.classification); - eprintln!(" PID {pid} ({state}) -- {sock}"); + kigi_tui::best_effort_stderr::eprint_line(&format!(" PID {pid} ({state}) -- {sock}")); } fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value { serde_json::json!( @@ -1233,10 +1245,12 @@ fn main() { // A host out of threads (RLIMIT_NPROC / pids.max) is an // environment problem, not a bug — report it, don't panic. kigi_tty_utils::restore_native_stderr(); - eprintln!("Error: failed to start the async runtime: {e}"); - eprintln!( + kigi_tui::best_effort_stderr::eprint_line(&format!( + "Error: failed to start the async runtime: {e}" + )); + kigi_tui::best_effort_stderr::eprint_line( "The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \ - close other processes or raise the limit, then retry." + close other processes or raise the limit, then retry.", ); std::process::exit(1); } @@ -1245,7 +1259,8 @@ fn main() { kigi_log::debug_log::flush(); if let Err(e) = result { kigi_tty_utils::restore_native_stderr(); - eprintln!("Error: {e:#}"); + // fd 2 may be a closed pane: one attempt, never a panic under panic = "abort". + kigi_tui::best_effort_stderr::eprint_line(&format!("Error: {e:#}")); std::process::exit(1); } } @@ -1603,9 +1618,13 @@ async fn async_main() -> Result<()> { // A terminal that stopped reading at teardown would block these prints. if exit.terminal_reading { if installed { - eprintln!("Update installed. Run `kigi` to start."); + kigi_tui::best_effort_stderr::eprint_line( + "Update installed. Run `kigi` to start.", + ); } else { - eprintln!("Update did not complete. Run `kigi update` to retry."); + kigi_tui::best_effort_stderr::eprint_line( + "Update did not complete. Run `kigi update` to retry.", + ); } } Ok(()) @@ -1634,7 +1653,7 @@ async fn finish_update_on_exit( return false; } if let Some(reason) = reason { - eprintln!("{reason}"); + kigi_tui::best_effort_stderr::eprint_line(&reason); } auto_update::run_update_if_available( auto_update::UpdateRunMode::Blocking, @@ -1647,7 +1666,9 @@ async fn finish_update_on_exit( match adopted { Some(handle) => { if terminal_reading { - eprintln!("Waiting for the update download to finish..."); + kigi_tui::best_effort_stderr::eprint_line( + "Waiting for the update download to finish...", + ); } match handle.await { Ok(Ok(status)) if status.success() => true, @@ -1821,7 +1842,9 @@ async fn signal_leaders_to_relaunch(installed_version: &str) { to_version, .. })) => { - eprintln!(" ↻ Relaunching shared session (leader {from_version} → {to_version})…"); + kigi_tui::best_effort_stderr::eprint_line(&format!( + " ↻ Relaunching shared session (leader {from_version} → {to_version})…" + )); } Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => { tracing::debug!(% reason, "Leader declined relaunch"); diff --git a/crates/codegen/kigi-tui/src/best_effort_stderr.rs b/crates/codegen/kigi-tui/src/best_effort_stderr.rs new file mode 100644 index 0000000..cac3bee --- /dev/null +++ b/crates/codegen/kigi-tui/src/best_effort_stderr.rs @@ -0,0 +1,33 @@ +//! Stderr lines for paths where fd 2 may be dead: a closed pane fails every write, `eprintln!` panics on it, and under `panic = "abort"` that is a SIGABRT plus a crash report. + +use std::io::Write; + +/// Write `line` and a newline to `w`; `false` when the write failed. +pub fn write_line(w: &mut impl Write, line: &str) -> bool { + writeln!(w, "{line}").is_ok() +} + +/// [`write_line`] to process stderr, outcome discarded. +pub fn eprint_line(line: &str) { + write_line(&mut std::io::stderr(), line); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_line_appends_a_newline_and_reports_success() { + let mut buf = Vec::new(); + assert!(write_line(&mut buf, "Finishing session…")); + assert_eq!(buf, "Finishing session…\n".as_bytes()); + } + + /// A closed read end fails every write with EPIPE, like the dead tty a closed pane leaves. + #[test] + fn write_line_reports_failure_on_a_dead_pipe() { + let (reader, mut writer) = std::io::pipe().expect("pipe"); + drop(reader); + assert!(!write_line(&mut writer, "unreachable")); + } +} diff --git a/crates/codegen/kigi-tui/src/headless.rs b/crates/codegen/kigi-tui/src/headless.rs index 719463f..bbc58b1 100644 --- a/crates/codegen/kigi-tui/src/headless.rs +++ b/crates/codegen/kigi-tui/src/headless.rs @@ -4,6 +4,7 @@ //! `spawn_kigi_shell`, sends the ACP lifecycle (init → auth → session → prompt), //! streams text to stdout, and exits cleanly via `CancellationToken`. +use crate::best_effort_stderr::eprint_line; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -459,7 +460,7 @@ impl HeadlessEmitter { fn on_error(&self, message: &str) { match self.format { - OutputFormat::Plain => eprintln!("{message}"), + OutputFormat::Plain => eprint_line(message), OutputFormat::StreamingJson | OutputFormat::Json => { let mut err = serde_json::json!({"type":"error","message": message}); if let Some(usage) = &self.usage { @@ -1276,7 +1277,7 @@ pub async fn run_single_turn( == Some("max_turns_reached"); if is_max_turns { match emitter.format { - OutputFormat::Plain => eprintln!("Max turns reached"), + OutputFormat::Plain => eprint_line("Max turns reached"), OutputFormat::StreamingJson => { println!("{}", serde_json::json!({"type": "max_turns_reached"})) } @@ -1644,7 +1645,9 @@ fn handle_ext_notification( ); } OutputFormat::Plain => { - eprintln!("Auto-compacting conversation ({percentage}% full)..."); + eprint_line(&format!( + "Auto-compacting conversation ({percentage}% full)..." + )); } OutputFormat::Json => {} }, @@ -1652,7 +1655,7 @@ fn handle_ext_notification( OutputFormat::StreamingJson => { println!("{}", serde_json::json!({"type": "auto_compact_completed"})); } - OutputFormat::Plain => eprintln!("Conversation compacted."), + OutputFormat::Plain => eprint_line("Conversation compacted."), OutputFormat::Json => {} }, XaiUpdate::AutoCompactFailed { error } => match format { @@ -1664,9 +1667,9 @@ fn handle_ext_notification( } OutputFormat::Plain => { if error.trim().is_empty() { - eprintln!("Auto-compact failed."); + eprint_line("Auto-compact failed."); } else { - eprintln!("Auto-compact failed: {error}"); + eprint_line(&format!("Auto-compact failed: {error}")); } } OutputFormat::Json => {} @@ -1675,7 +1678,7 @@ fn handle_ext_notification( OutputFormat::StreamingJson => { println!("{}", serde_json::json!({"type": "auto_compact_cancelled"})); } - OutputFormat::Plain => eprintln!("Auto-compact cancelled."), + OutputFormat::Plain => eprint_line("Auto-compact cancelled."), OutputFormat::Json => {} }, XaiUpdate::AutoContinueCompleted { total_tokens } => match format { @@ -1685,7 +1688,7 @@ fn handle_ext_notification( serde_json::json!({"type": "auto_continue_completed", "total_tokens": total_tokens}) ); } - OutputFormat::Plain => eprintln!("Resumed after compaction."), + OutputFormat::Plain => eprint_line("Resumed after compaction."), OutputFormat::Json => {} }, XaiUpdate::ImageCompressed { message } => match format { @@ -1695,7 +1698,7 @@ fn handle_ext_notification( serde_json::json!({"type": "image_compressed", "message": message}) ); } - OutputFormat::Plain => eprintln!("{message}"), + OutputFormat::Plain => eprint_line(&message), OutputFormat::Json => {} }, XaiUpdate::SubagentSpawned { subagent_id } => { diff --git a/crates/codegen/kigi-tui/src/lib.rs b/crates/codegen/kigi-tui/src/lib.rs index 2dd5a95..fe8c533 100644 --- a/crates/codegen/kigi-tui/src/lib.rs +++ b/crates/codegen/kigi-tui/src/lib.rs @@ -5,6 +5,7 @@ pub mod acp; pub mod actions; pub mod app; +pub mod best_effort_stderr; pub mod client_identity; pub mod completions_cmd; mod config_toml_edit;