fix(exit): diagnostics after the terminal may be gone are best-effort writes

With fd 2 dead (a closed pane, an exited pipe reader) every write fails,
eprintln! panics on that failure, and under panic = "abort" the process
dies by SIGABRT with a crash report on the next launch instead of its
exit code. Exit reports, headless plain-format diagnostics, update-exit,
setup-result and leader-management messages now write through
best_effort_stderr::eprint_line: one attempt, failure reported, never a
panic.

Ported from upstream 1.0.19 (grok-build best_effort_stderr.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 10:39:20 -04:00
1 parent 901203c2b2
commit 17d4ba08f9
4 files changed
+95 -35

No files matched your search

+49 -26
View File
@@ -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");
@@ -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"));
}
}
+12 -9
View File
@@ -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 } => {
+1
View File
@@ -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;