fix: restore caller panic hook when terminal session ends
This commit is contained in:
@@ -41,6 +41,8 @@ One crate, four modules, no workspace or frontend-specific types in the editing
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
|
||||
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities.
|
||||
|
||||
A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.
|
||||
@@ -101,3 +103,5 @@ ruby -c src/ruby_host.rb
|
||||
```
|
||||
|
||||
Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. Backend paste checks cover ignored modes, literal Unicode/newline insertion, rejection atomicity, and typing/paste/typing undo and redo. Session checks join real Ruby execution to capability/collision rejection, complete command/binding retirement after invalid effects or host failure, surviving plugins, core editing/saving, and broken-config fallback. The executable was also exercised in PTY sessions for editing/saving, rejected-effect retirement, plugin crash and hang recovery, modal bracketed paste, rejected-paste feedback, undo/redo, scrolling, resize, and terminal restoration.
|
||||
|
||||
Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output.
|
||||
|
||||
+176
-11
@@ -1,4 +1,8 @@
|
||||
use std::io::{self, Stdout};
|
||||
use std::{
|
||||
io::{self, Stdout},
|
||||
panic::{self, AssertUnwindSafe, PanicHookInfo},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
cursor::Show,
|
||||
@@ -28,7 +32,16 @@ use crate::{
|
||||
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
|
||||
const TAB_WIDTH: usize = 4;
|
||||
|
||||
struct TerminalSession;
|
||||
type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
|
||||
|
||||
struct HookState {
|
||||
previous: Option<PanicHook>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
struct TerminalSession {
|
||||
hook: Arc<Mutex<HookState>>,
|
||||
}
|
||||
|
||||
fn restore_terminal() {
|
||||
let _ = disable_raw_mode();
|
||||
@@ -41,29 +54,65 @@ fn restore_terminal() {
|
||||
}
|
||||
|
||||
impl TerminalSession {
|
||||
fn enter() -> io::Result<Self> {
|
||||
fn run(body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
|
||||
let hook = Arc::new(Mutex::new(HookState {
|
||||
previous: Some(panic::take_hook()),
|
||||
active: false,
|
||||
}));
|
||||
let during_session = Arc::clone(&hook);
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
let mut state = during_session
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if state.active {
|
||||
restore_terminal();
|
||||
state.active = false;
|
||||
}
|
||||
if let Some(previous) = &state.previous {
|
||||
previous(info);
|
||||
}
|
||||
}));
|
||||
let session = Self { hook };
|
||||
// set_hook is forbidden on a panicking thread. Catch first, drop the
|
||||
// owner while not panicking, then resume the original panic unchanged.
|
||||
let result = panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
enable_raw_mode()?;
|
||||
let session = Self;
|
||||
session
|
||||
.hook
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.active = true;
|
||||
execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
|
||||
Ok(session)
|
||||
body()
|
||||
}));
|
||||
drop(session);
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(payload) => panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalSession {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.hook.lock().unwrap_or_else(|error| error.into_inner());
|
||||
if state.active {
|
||||
restore_terminal();
|
||||
state.active = false;
|
||||
}
|
||||
let previous = state
|
||||
.previous
|
||||
.take()
|
||||
.expect("terminal session owns its prior hook");
|
||||
drop(state);
|
||||
panic::set_hook(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders session state and translates terminal input; the session owns plugin
|
||||
/// execution and retirement, so the adapter needs no lifecycle callback.
|
||||
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||
let previous_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
restore_terminal();
|
||||
previous_hook(info);
|
||||
}));
|
||||
let _session = TerminalSession::enter()?;
|
||||
TerminalSession::run(|| {
|
||||
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
|
||||
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
let (mut top, mut left) = (0, 0);
|
||||
@@ -80,6 +129,7 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn translate(key: KeyEvent) -> Option<Input> {
|
||||
@@ -231,4 +281,119 @@ mod tests {
|
||||
assert_eq!(visible_line(text, 2, 5), " 界e\u{301}");
|
||||
assert_eq!(visible_line(text, 5, 2), " e\u{301}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn terminal_session_scopes_the_callers_panic_hook() {
|
||||
const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE";
|
||||
if let Ok(mode) = std::env::var(CHILD) {
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
println!("CALLER_HOOK");
|
||||
}));
|
||||
if mode == "entry" {
|
||||
let mut editor = EditorSession::open(None, vec![], None).unwrap();
|
||||
assert!(run(&mut editor).is_err());
|
||||
} else {
|
||||
let result = panic::catch_unwind(|| {
|
||||
TerminalSession::run(|| {
|
||||
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
match mode.as_str() {
|
||||
"normal" => Ok(()),
|
||||
"error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
|
||||
"panic" => panic::panic_any(73_u64),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
})
|
||||
});
|
||||
match mode.as_str() {
|
||||
"normal" => result.unwrap().unwrap(),
|
||||
"error" => assert_eq!(
|
||||
result.unwrap().unwrap_err().kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
),
|
||||
"panic" => assert_eq!(result.unwrap_err().downcast_ref::<u64>(), Some(&73)),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
println!("AFTER_TERMINAL_RETURN");
|
||||
let _ = panic::catch_unwind(|| panic!("outside the terminal session"));
|
||||
return;
|
||||
}
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
unsafe extern "C" {
|
||||
fn setsid() -> i32;
|
||||
}
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.args([
|
||||
"--exact",
|
||||
"tui::tests::terminal_session_scopes_the_callers_panic_hook",
|
||||
"--nocapture",
|
||||
"--color",
|
||||
"never",
|
||||
])
|
||||
.env(CHILD, "entry")
|
||||
.stdin(Stdio::null());
|
||||
// SAFETY: setsid is async-signal-safe and detaches the child from any
|
||||
// controlling terminal without touching inherited Rust state.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if setsid() == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
}
|
||||
let check = |output: std::process::Output| {
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let after = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap().1;
|
||||
assert!(after.contains("CALLER_HOOK"), "{after:?}");
|
||||
assert!(
|
||||
!after.contains('\u{1b}'),
|
||||
"terminal hook outlived its session: {after:?}"
|
||||
);
|
||||
};
|
||||
check(command.output().unwrap());
|
||||
|
||||
// Ruby's standard-library PTY gives each positive-entry scenario a real
|
||||
// terminal, keeping process-global hooks isolated from the test runner.
|
||||
for mode in ["normal", "error", "panic"] {
|
||||
let output =
|
||||
Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
|
||||
.args([
|
||||
"-rpty",
|
||||
"-e",
|
||||
r#"
|
||||
PTY.spawn(*ARGV) do |output, input, pid|
|
||||
input.close
|
||||
begin
|
||||
loop { STDOUT.write(output.readpartial(4096)) }
|
||||
rescue EOFError, Errno::EIO
|
||||
end
|
||||
Process.wait(pid)
|
||||
exit($?.exitstatus || 1)
|
||||
end
|
||||
"#,
|
||||
"--",
|
||||
])
|
||||
.arg(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"tui::tests::terminal_session_scopes_the_callers_panic_hook",
|
||||
"--nocapture",
|
||||
"--color",
|
||||
"never",
|
||||
])
|
||||
.env(CHILD, mode)
|
||||
.output()
|
||||
.unwrap();
|
||||
check(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user