#![forbid(unsafe_code)]
use std::io;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::terminal_capabilities::TerminalCapabilities;
pub mod seq {
pub const DECSC: &[u8] = b"\x1b7";
pub const DECRC: &[u8] = b"\x1b8";
pub const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
pub const RESET_STYLE: &[u8] = b"\x1b[0m";
pub const SGR_RESET: &[u8] = RESET_STYLE;
pub const SYNC_END: &[u8] = b"\x1b[?2026l";
pub const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
pub const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
pub const FOCUS_DISABLE: &[u8] = b"\x1b[?1004l";
pub const FOCUS_ENABLE: &[u8] = b"\x1b[?1004h";
pub const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
pub const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
pub const MOUSE_DISABLE: &[u8] = b"\x1b[?1000;1002;1006l\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l";
pub const MOUSE_DISABLE_MUX_SAFE: &[u8] =
b"\x1b[?1016l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1001l\x1b[?1005l\x1b[?1015l";
pub const MOUSE_ENABLE: &[u8] = b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1006;1000;1002h\x1b[?1006h\x1b[?1000h\x1b[?1002h";
pub const MOUSE_ENABLE_MUX_SAFE: &[u8] =
b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1006h\x1b[?1000h\x1b[?1002h";
pub const CURSOR_SHOW: &[u8] = b"\x1b[?25h";
pub const CURSOR_HIDE: &[u8] = b"\x1b[?25l";
pub const ALT_SCREEN_LEAVE: &[u8] = b"\x1b[?1049l";
pub const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h";
}
static KITTY_POP_LATCH: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, Default)]
pub struct KittyPopLatch;
impl KittyPopLatch {
#[inline]
pub fn try_claim() -> bool {
!KITTY_POP_LATCH.swap(true, Ordering::SeqCst)
}
#[inline]
pub fn is_claimed() -> bool {
KITTY_POP_LATCH.load(Ordering::SeqCst)
}
#[inline]
pub fn release() {
KITTY_POP_LATCH.store(false, Ordering::SeqCst);
}
}
#[cfg(test)]
pub(crate) fn kitty_latch_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|poison| poison.into_inner())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TeardownPlan {
pub emit_sync_end: bool,
pub reset_scroll_region: bool,
pub reset_style: bool,
pub pop_kitty_keyboard: bool,
pub disable_focus: bool,
pub disable_paste: bool,
pub disable_mouse: bool,
pub mouse_mux_safe: bool,
pub mouse_disable_override: Option<&'static [u8]>,
pub show_cursor: bool,
pub leave_alt_screen: bool,
}
impl TeardownPlan {
#[must_use]
pub fn from_capabilities(
caps: &TerminalCapabilities,
alt_screen: bool,
emit_sync_end: bool,
) -> Self {
Self {
emit_sync_end,
reset_scroll_region: true,
reset_style: true,
pop_kitty_keyboard: caps.kitty_keyboard && !caps.in_any_mux(),
disable_focus: caps.focus_events && !caps.in_any_mux(),
disable_paste: caps.bracketed_paste,
disable_mouse: caps.mouse_sgr,
mouse_mux_safe: caps.in_any_mux(),
mouse_disable_override: None,
show_cursor: true,
leave_alt_screen: alt_screen,
}
}
pub fn write(&self, w: &mut impl io::Write) -> io::Result<()> {
self.write_for_backend(w, "unknown")
}
pub fn write_for_backend(
&self,
w: &mut impl io::Write,
_backend: &'static str,
) -> io::Result<()> {
#[cfg(feature = "tracing")]
tracing::info!(
backend = _backend,
plan = ?self,
"teardown start"
);
if self.emit_sync_end {
w.write_all(seq::SYNC_END)?;
}
if self.reset_scroll_region {
w.write_all(seq::DECSC)?;
w.write_all(seq::RESET_SCROLL_REGION)?;
w.write_all(seq::DECRC)?;
}
if self.reset_style {
w.write_all(seq::RESET_STYLE)?;
}
if self.pop_kitty_keyboard {
w.write_all(seq::KITTY_KEYBOARD_DISABLE)?;
}
if self.disable_focus {
w.write_all(seq::FOCUS_DISABLE)?;
}
if self.disable_paste {
w.write_all(seq::BRACKETED_PASTE_DISABLE)?;
}
if self.disable_mouse {
let mouse_seq = self
.mouse_disable_override
.unwrap_or(if self.mouse_mux_safe {
seq::MOUSE_DISABLE_MUX_SAFE
} else {
seq::MOUSE_DISABLE
});
w.write_all(mouse_seq)?;
}
if self.show_cursor {
w.write_all(seq::CURSOR_SHOW)?;
}
if self.leave_alt_screen {
w.write_all(seq::ALT_SCREEN_LEAVE)?;
}
#[cfg(feature = "tracing")]
tracing::info!(backend = _backend, "teardown end");
Ok(())
}
}
static INSTALLED_HOOKS: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
thread_local! {
static PANIC_CLEANUP_SUPPRESS_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
pub fn with_panic_cleanup_suppressed<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
#[cfg(panic = "abort")]
{
f()
}
#[cfg(not(panic = "abort"))]
{
struct SuppressGuard;
impl Drop for SuppressGuard {
fn drop(&mut self) {
PANIC_CLEANUP_SUPPRESS_DEPTH.with(|depth| {
depth.set(depth.get().saturating_sub(1));
});
}
}
PANIC_CLEANUP_SUPPRESS_DEPTH.with(|depth| {
depth.set(depth.get().saturating_add(1));
});
let _guard = SuppressGuard;
f()
}
}
#[must_use]
pub fn panic_cleanup_suppressed() -> bool {
PANIC_CLEANUP_SUPPRESS_DEPTH.with(|depth| depth.get() > 0)
}
pub fn install_chained_panic_hook(name: &'static str, f: fn()) {
let mut installed = match INSTALLED_HOOKS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if installed.contains(&name) {
return;
}
installed.push(name);
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !panic_cleanup_suppressed() {
f();
}
previous(info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panic_cleanup_suppression_scope_restores_state() {
assert!(
!panic_cleanup_suppressed(),
"suppression should start disabled"
);
with_panic_cleanup_suppressed(|| {
if cfg!(panic = "abort") {
assert!(
!panic_cleanup_suppressed(),
"abort profile must not suppress panic cleanup"
);
return;
}
assert!(panic_cleanup_suppressed(), "suppression should be enabled");
with_panic_cleanup_suppressed(|| {
assert!(
panic_cleanup_suppressed(),
"nested suppression should remain enabled"
);
});
assert!(
panic_cleanup_suppressed(),
"outer suppression should still be enabled after nested scope"
);
});
assert!(
!panic_cleanup_suppressed(),
"suppression should be disabled after scope exits"
);
}
#[cfg(not(panic = "abort"))]
#[test]
fn chained_cleanup_hooks_skip_recovered_panics() {
thread_local! {
static CLEANUPS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
fn count_cleanup() {
CLEANUPS.with(|n| n.set(n.get() + 1));
}
install_chained_panic_hook("test-chained-cleanup-gate", count_cleanup);
let recovered =
with_panic_cleanup_suppressed(|| std::panic::catch_unwind(|| panic!("recovered")));
assert!(recovered.is_err());
assert_eq!(
CLEANUPS.with(std::cell::Cell::get),
0,
"a recovered panic ran terminal cleanup"
);
let unrecovered = std::panic::catch_unwind(|| panic!("not suppressed"));
assert!(unrecovered.is_err());
assert_eq!(CLEANUPS.with(std::cell::Cell::get), 1);
}
#[test]
fn write_emits_canonical_order() {
let mut expected = Vec::new();
expected.extend_from_slice(seq::SYNC_END);
expected.extend_from_slice(seq::DECSC);
expected.extend_from_slice(seq::RESET_SCROLL_REGION);
expected.extend_from_slice(seq::DECRC);
expected.extend_from_slice(seq::RESET_STYLE);
expected.extend_from_slice(seq::KITTY_KEYBOARD_DISABLE);
expected.extend_from_slice(seq::FOCUS_DISABLE);
expected.extend_from_slice(seq::BRACKETED_PASTE_DISABLE);
expected.extend_from_slice(seq::MOUSE_DISABLE);
expected.extend_from_slice(seq::CURSOR_SHOW);
expected.extend_from_slice(seq::ALT_SCREEN_LEAVE);
let plan = TeardownPlan {
emit_sync_end: true,
reset_scroll_region: true,
reset_style: true,
pop_kitty_keyboard: true,
disable_focus: true,
disable_paste: true,
disable_mouse: true,
mouse_mux_safe: false,
mouse_disable_override: None,
show_cursor: true,
leave_alt_screen: true,
};
let mut buf = Vec::new();
plan.write(&mut buf).unwrap();
assert_eq!(buf, expected);
}
#[test]
fn latch_claims_once() {
let _serial = kitty_latch_test_lock();
KittyPopLatch::release();
assert!(!KittyPopLatch::is_claimed());
assert!(KittyPopLatch::try_claim());
assert!(KittyPopLatch::is_claimed());
assert!(!KittyPopLatch::try_claim());
assert!(!KittyPopLatch::try_claim());
KittyPopLatch::release();
assert!(!KittyPopLatch::is_claimed());
assert!(KittyPopLatch::try_claim());
KittyPopLatch::release();
}
#[test]
fn a_released_latch_lets_a_second_teardown_pop_again() {
let _serial = kitty_latch_test_lock();
let caps = TerminalCapabilities::modern();
let plan_for = || {
let mut plan = TeardownPlan::from_capabilities(&caps, true, true);
if !KittyPopLatch::try_claim() {
plan.pop_kitty_keyboard = false;
}
plan.pop_kitty_keyboard
};
KittyPopLatch::release();
assert!(plan_for(), "the outstanding push must be popped");
assert!(!plan_for(), "and popped only once");
KittyPopLatch::release();
assert!(plan_for(), "a fresh push owes a fresh pop");
KittyPopLatch::release();
}
#[test]
fn from_capabilities_applies_mux_policy() {
let mut caps = TerminalCapabilities::modern();
caps.kitty_keyboard = true;
caps.focus_events = true;
caps.bracketed_paste = true;
caps.mouse_sgr = true;
let plan = TeardownPlan::from_capabilities(&caps, true, true);
assert!(plan.pop_kitty_keyboard);
assert!(plan.disable_focus);
assert!(plan.disable_paste);
assert!(plan.disable_mouse);
assert!(!plan.mouse_mux_safe);
assert!(plan.leave_alt_screen);
assert!(plan.emit_sync_end);
let tmux_caps = TerminalCapabilities::tmux();
let tmux_plan = TeardownPlan::from_capabilities(&tmux_caps, true, false);
assert!(!tmux_plan.pop_kitty_keyboard);
assert!(!tmux_plan.disable_focus);
assert!(tmux_plan.mouse_mux_safe);
assert!(!tmux_plan.emit_sync_end);
}
#[test]
fn chained_hook_runs_previous() {
static HOOK_A_RAN: AtomicBool = AtomicBool::new(false);
static HOOK_B_RAN: AtomicBool = AtomicBool::new(false);
fn hook_a() {
HOOK_A_RAN.store(true, Ordering::SeqCst);
}
fn hook_b() {
HOOK_B_RAN.store(true, Ordering::SeqCst);
}
install_chained_panic_hook("test-hook-a", hook_a);
install_chained_panic_hook("test-hook-b", hook_b);
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let _ = std::panic::catch_unwind(|| {
});
std::panic::set_hook(prev_hook);
let _ = std::panic::catch_unwind(|| {
panic!("test panic for chained hook");
});
assert!(HOOK_A_RAN.load(Ordering::SeqCst));
assert!(HOOK_B_RAN.load(Ordering::SeqCst));
}
#[test]
fn hook_runs_under_unwind() {
static UNWIND_HOOK_RAN: AtomicBool = AtomicBool::new(false);
static TEST_SESSION_ACTIVE: AtomicBool = AtomicBool::new(false);
fn session_hook() {
if TEST_SESSION_ACTIVE.load(Ordering::SeqCst) {
UNWIND_HOOK_RAN.store(true, Ordering::SeqCst);
}
}
install_chained_panic_hook("test-unwind-hook", session_hook);
TEST_SESSION_ACTIVE.store(true, Ordering::SeqCst);
let handle = std::thread::spawn(|| {
let _ = std::panic::catch_unwind(|| {
panic!("trigger hook under unwind");
});
});
let _ = handle.join();
TEST_SESSION_ACTIVE.store(false, Ordering::SeqCst);
assert!(
UNWIND_HOOK_RAN.load(Ordering::SeqCst),
"hook must run under panic=unwind when session is active"
);
}
}