use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::cursor::Hide;
use crossterm::event::{EnableBracketedPaste, EnableFocusChange, EnableMouseCapture};
use crossterm::terminal::{self, Clear, ClearType, EnterAlternateScreen};
pub(crate) static TTY_FD_PATH: OnceLock<bool> = OnceLock::new();
static LOG_PATH: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
pub fn set_log_path(path: Option<std::path::PathBuf>) {
let _ = LOG_PATH.set(path);
}
const PANIC_RESET_SEQ: &[u8] = b"\x1b[0m\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b]0;\x1b\\\x1b[?1049l\x1b[?25h";
static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
static UI_THREAD_ID: OnceLock<std::thread::ThreadId> = OnceLock::new();
static PANIC_HOOK_FIRED: AtomicBool = AtomicBool::new(false);
fn thread_owns_terminal(
ui_thread: Option<std::thread::ThreadId>,
current: std::thread::ThreadId,
) -> bool {
ui_thread == Some(current)
}
fn log_path_hint() -> String {
match LOG_PATH.get().and_then(|opt| opt.clone()) {
Some(p) => p.display().to_string(),
None => "stderr (run with --verbose or DIRGE_LOG to capture a log file)".to_string(),
}
}
fn format_panic_notice(payload: &str, location: Option<&str>, log_hint: &str) -> String {
let at = location.unwrap_or("unknown location");
format!(
"\r\n\x1b[1;31mdirge panicked:\x1b[0m {payload}\r\n at {at}\r\n full backtrace in the log: {log_hint}\r\n"
)
}
pub fn install_panic_hook() {
let _ = UI_THREAD_ID.set(std::thread::current().id());
if PANIC_HOOK_INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !thread_owns_terminal(UI_THREAD_ID.get().copied(), std::thread::current().id()) {
previous(info);
return;
}
if let Some(mut tty) = open_tty_for_write() {
let _ = tty.write_all(PANIC_RESET_SEQ);
let _ = tty.flush();
}
let _ = terminal::disable_raw_mode();
PANIC_HOOK_FIRED.store(true, Ordering::SeqCst);
let payload = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "Box<dyn Any>".to_string());
let location = info.location().map(|l| l.to_string());
let notice = format_panic_notice(&payload, location.as_deref(), &log_path_hint());
if let Some(mut tty) = open_tty_for_write() {
let _ = tty.write_all(notice.as_bytes());
let _ = tty.flush();
}
previous(info);
}));
}
pub(crate) static EVENT_READER_SHUTDOWN: AtomicBool = AtomicBool::new(false);
pub(crate) static EVENT_READER_EXITED: AtomicBool = AtomicBool::new(false);
pub(crate) static READER_HANDLE: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
pub struct TerminalGuard {
#[cfg(unix)]
saved_stdout_fd: Option<libc::c_int>,
#[cfg(unix)]
saved_stderr_fd: Option<libc::c_int>,
}
impl TerminalGuard {
pub fn new() -> std::io::Result<Self> {
EVENT_READER_SHUTDOWN.store(false, Ordering::Relaxed);
EVENT_READER_EXITED.store(false, Ordering::Relaxed);
PANIC_HOOK_FIRED.store(false, Ordering::SeqCst);
install_panic_hook();
let mut tty_writer: Box<dyn std::io::Write> = match open_tty_for_write() {
Some(f) => Box::new(f),
None => Box::new(std::io::stdout()),
};
tty_writer.execute(EnterAlternateScreen)?;
tty_writer.execute(Clear(ClearType::All))?;
tty_writer.execute(EnableBracketedPaste)?;
tty_writer.execute(EnableMouseCapture)?;
tty_writer.execute(EnableFocusChange)?;
tty_writer.execute(Hide)?;
terminal::enable_raw_mode()?;
let _ = tty_writer.flush();
drop(tty_writer);
#[cfg(unix)]
let (saved_stdout_fd, saved_stderr_fd) = redirect_stdout_stderr_to_log();
#[cfg(not(unix))]
let _ = ();
let _ = TTY_FD_PATH.set(true);
#[cfg(unix)]
return Ok(TerminalGuard {
saved_stdout_fd,
saved_stderr_fd,
});
#[cfg(not(unix))]
return Ok(TerminalGuard {});
}
}
pub(crate) fn open_tty_for_write() -> Option<std::fs::File> {
std::fs::OpenOptions::new()
.read(false)
.write(true)
.open("/dev/tty")
.ok()
}
pub(crate) fn tty_size() -> (u16, u16) {
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
let f = match std::fs::OpenOptions::new()
.read(true)
.write(false)
.open("/dev/tty")
{
Ok(f) => f,
Err(_) => return (80, 24),
};
let fd = f.as_raw_fd();
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) };
if rc < 0 || ws.ws_col == 0 || ws.ws_row == 0 {
return (80, 24);
}
(ws.ws_col, ws.ws_row)
}
#[cfg(not(unix))]
{
crossterm::terminal::size().unwrap_or((80, 24))
}
}
#[cfg(unix)]
fn redirect_stdout_stderr_to_log() -> (Option<libc::c_int>, Option<libc::c_int>) {
let configured = LOG_PATH
.get()
.and_then(|opt| opt.clone())
.unwrap_or_else(|| std::path::PathBuf::from("/dev/null"));
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&configured)
.or_else(|_| std::fs::OpenOptions::new().write(true).open("/dev/null"));
let file = match file {
Ok(f) => f,
Err(_) => return (None, None),
};
use std::os::fd::AsRawFd;
let target_fd = file.as_raw_fd();
let saved_stdout_fd = unsafe { libc::dup(1) };
let saved_stderr_fd = unsafe { libc::dup(2) };
unsafe {
libc::dup2(target_fd, 1);
libc::dup2(target_fd, 2);
}
drop(file);
(
if saved_stdout_fd >= 0 {
Some(saved_stdout_fd)
} else {
None
},
if saved_stderr_fd >= 0 {
Some(saved_stderr_fd)
} else {
None
},
)
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
EVENT_READER_SHUTDOWN.store(true, Ordering::Relaxed);
if PANIC_HOOK_FIRED.load(Ordering::SeqCst) {
#[cfg(unix)]
unsafe {
if let Some(orig) = self.saved_stdout_fd {
libc::dup2(orig, 1);
libc::close(orig);
}
if let Some(orig) = self.saved_stderr_fd {
libc::dup2(orig, 2);
libc::close(orig);
}
}
return;
}
wait_for_reader_exit(Duration::from_millis(50));
let mut tty_writer: Box<dyn std::io::Write> = match open_tty_for_write() {
Some(f) => Box::new(f),
None => Box::new(std::io::stdout()),
};
let stdout = &mut tty_writer;
let _ = stdout.write_all(
b"\x1b[0m\
\x1b[?25h\
\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?1015l\
\x1b[?2004l\
\x1b]0;\x1b\\\
\x1b[?1049l",
);
let _ = stdout.flush();
#[cfg(unix)]
sync_and_drain_via_sentinel(stdout, Duration::from_millis(100));
let _ = terminal::disable_raw_mode();
let _ = stdout.write_all(b"\x1b[?25h");
let _ = stdout.flush();
drop(tty_writer);
#[cfg(unix)]
unsafe {
if let Some(orig) = self.saved_stdout_fd {
libc::dup2(orig, 1);
libc::close(orig);
}
if let Some(orig) = self.saved_stderr_fd {
libc::dup2(orig, 2);
libc::close(orig);
}
}
}
}
pub(crate) fn wait_for_reader_exit(budget: Duration) {
let deadline = std::time::Instant::now() + budget;
while !EVENT_READER_EXITED.load(Ordering::Acquire) {
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
}
#[cfg(unix)]
pub(crate) fn join_reader(budget: Duration) {
let handle = match READER_HANDLE.lock() {
Ok(mut guard) => guard.take(),
Err(_) => return,
};
let Some(handle) = handle else {
return;
};
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let done2 = std::sync::Arc::clone(&done);
std::thread::spawn(move || {
std::thread::sleep(budget);
done2.store(true, std::sync::atomic::Ordering::Relaxed);
});
while !done.load(std::sync::atomic::Ordering::Relaxed) {
if handle.is_finished() {
let _ = handle.join();
return;
}
std::thread::sleep(Duration::from_millis(2));
}
if let Ok(mut guard) = READER_HANDLE.lock() {
*guard = Some(handle);
}
}
#[cfg(unix)]
pub(crate) fn drain_stdin_nonblocking() -> Vec<u8> {
let fd_in: libc::c_int = 0;
let original_flags = unsafe { libc::fcntl(fd_in, libc::F_GETFL) };
if original_flags < 0 {
return Vec::new();
}
let nb_flags = original_flags | libc::O_NONBLOCK;
if unsafe { libc::fcntl(fd_in, libc::F_SETFL, nb_flags) } < 0 {
return Vec::new();
}
let mut drained = Vec::with_capacity(256);
let mut buf = [0u8; 1024];
loop {
let n = unsafe { libc::read(fd_in, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n > 0 {
drained.extend_from_slice(&buf[..n as usize]);
continue;
}
if n == 0 {
break;
}
let err = std::io::Error::last_os_error().raw_os_error();
match err {
Some(e) if e == libc::EAGAIN || e == libc::EWOULDBLOCK => break,
Some(libc::EINTR) => continue,
_ => break,
}
}
let _ = unsafe { libc::fcntl(fd_in, libc::F_SETFL, original_flags) };
drained
}
#[cfg(unix)]
pub(crate) fn sync_and_drain_via_sentinel(stdout: &mut dyn std::io::Write, budget: Duration) {
let fd_in: libc::c_int = 0;
let original_flags = unsafe { libc::fcntl(fd_in, libc::F_GETFL) };
if original_flags < 0 {
return;
}
let nb_flags = original_flags | libc::O_NONBLOCK;
if unsafe { libc::fcntl(fd_in, libc::F_SETFL, nb_flags) } < 0 {
return;
}
if stdout.write_all(b"\x1b[5n").is_err() {
let _ = unsafe { libc::fcntl(fd_in, libc::F_SETFL, original_flags) };
return;
}
let _ = stdout.flush();
let deadline = std::time::Instant::now() + budget;
let mut buf = [0u8; 1024];
let mut match_state: u8 = 0;
let mut got_reply = false;
while !got_reply && std::time::Instant::now() < deadline {
let n = unsafe { libc::read(fd_in, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n > 0 {
for &b in &buf[..n as usize] {
match (match_state, b) {
(0, 0x1b) => match_state = 1,
(1, b'[') => match_state = 2,
(2, b'0') => match_state = 3,
(3, b'n') => {
got_reply = true;
break;
}
(_, 0x1b) => match_state = 1,
_ => match_state = 0,
}
}
continue;
}
if n == 0 {
break;
}
let err = std::io::Error::last_os_error().raw_os_error();
match err {
Some(e) if e == libc::EAGAIN || e == libc::EWOULDBLOCK => {
std::thread::sleep(Duration::from_millis(4));
}
Some(libc::EINTR) => continue,
_ => break,
}
}
let _ = unsafe { libc::fcntl(fd_in, libc::F_SETFL, original_flags) };
}
#[cfg(unix)]
pub(crate) fn suspend_tui_for_subprocess(
user_tx: &tokio::sync::mpsc::UnboundedSender<crate::event::UserEvent>,
) -> Option<Vec<u8>> {
EVENT_READER_SHUTDOWN.store(true, Ordering::Relaxed);
join_reader(Duration::from_millis(50));
let mut tty = match open_tty_for_write() {
Some(t) => t,
None => {
EVENT_READER_SHUTDOWN.store(false, Ordering::Relaxed);
EVENT_READER_EXITED.store(false, Ordering::Relaxed);
crate::ui::input_reader::spawn_input_reader(user_tx.clone());
return None;
}
};
let _ = tty.write_all(
b"\x1b[0m\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b]0;\x1b\\\x1b[?1049l",
);
let _ = tty.flush();
let drained_stdin = drain_stdin_nonblocking();
let _ = tty.write_all(b"\x1b[?25h"); let _ = tty.flush();
Some(drained_stdin)
}
#[cfg(unix)]
pub(crate) fn resume_tui_after_subprocess(
renderer: &mut crate::ui::renderer::Renderer,
user_tx: &tokio::sync::mpsc::UnboundedSender<crate::event::UserEvent>,
) {
if let Some(mut tty) = open_tty_for_write() {
let _ = tty.write_all(
b"\x1b[?1049h\x1b[2J\x1b[?25l\x1b[?2004h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1004h",
);
let _ = tty.flush();
}
renderer.reset_tui();
renderer.set_needs_repaint();
if let Some(mut tty) = open_tty_for_write() {
sync_and_drain_via_sentinel(&mut tty, Duration::from_millis(100));
}
EVENT_READER_SHUTDOWN.store(false, Ordering::Relaxed);
EVENT_READER_EXITED.store(false, Ordering::Relaxed);
crate::ui::input_reader::spawn_input_reader(user_tx.clone());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panic_notice_carries_message_location_and_log() {
let notice = format_panic_notice(
"index out of bounds: the len is 0 but the index is 3",
Some("src/ui/mod.rs:42:9"),
"/home/x/.dirge/dirge.log",
);
assert!(notice.contains("dirge panicked"));
assert!(notice.contains("index out of bounds"));
assert!(notice.contains("src/ui/mod.rs:42:9"));
assert!(notice.contains("/home/x/.dirge/dirge.log"));
assert!(notice.contains("\r\n"));
}
#[test]
fn panic_notice_tolerates_unknown_location() {
let notice = format_panic_notice("boom", None, "stderr");
assert!(notice.contains("boom"));
assert!(notice.contains("stderr"));
}
#[test]
fn ui_thread_panic_resets_terminal() {
let me = std::thread::current().id();
assert!(thread_owns_terminal(Some(me), me));
}
#[test]
fn worker_thread_panic_leaves_terminal_alone() {
let ui = std::thread::current().id();
let worker = std::thread::spawn(std::thread::current)
.join()
.unwrap()
.id();
assert_ne!(ui, worker);
assert!(!thread_owns_terminal(Some(ui), worker));
}
#[test]
fn unknown_ui_thread_means_no_reset() {
assert!(!thread_owns_terminal(None, std::thread::current().id()));
}
}