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.
|
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.
|
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.
|
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.
|
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::{
|
use crossterm::{
|
||||||
cursor::Show,
|
cursor::Show,
|
||||||
@@ -28,7 +32,16 @@ use crate::{
|
|||||||
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
|
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
|
||||||
const TAB_WIDTH: usize = 4;
|
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() {
|
fn restore_terminal() {
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
@@ -41,29 +54,65 @@ fn restore_terminal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalSession {
|
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()?;
|
enable_raw_mode()?;
|
||||||
let session = Self;
|
session
|
||||||
|
.hook
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.active = true;
|
||||||
execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
|
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 {
|
impl Drop for TerminalSession {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
let mut state = self.hook.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
if state.active {
|
||||||
restore_terminal();
|
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
|
/// Renders session state and translates terminal input; the session owns plugin
|
||||||
/// execution and retirement, so the adapter needs no lifecycle callback.
|
/// execution and retirement, so the adapter needs no lifecycle callback.
|
||||||
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||||
let previous_hook = std::panic::take_hook();
|
TerminalSession::run(|| {
|
||||||
std::panic::set_hook(Box::new(move |info| {
|
|
||||||
restore_terminal();
|
|
||||||
previous_hook(info);
|
|
||||||
}));
|
|
||||||
let _session = TerminalSession::enter()?;
|
|
||||||
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
|
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
|
||||||
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||||
let (mut top, mut left) = (0, 0);
|
let (mut top, mut left) = (0, 0);
|
||||||
@@ -80,6 +129,7 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn translate(key: KeyEvent) -> Option<Input> {
|
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, 2, 5), " 界e\u{301}");
|
||||||
assert_eq!(visible_line(text, 5, 2), " 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