Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,45 @@ const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// Frame rate (fps) used when the user has been idle for longer than [`IDLE_TIMEOUT`].
const IDLE_FRAME_RATE: f64 = 0.2;

/// Cap on `event_reader.poll` wait so pending Bash traps are noticed quickly even
/// when the idle frame rate would otherwise block for several seconds.
const SIGNAL_POLL_CAP: Duration = Duration::from_millis(100);

/// Set by [`flyline_sigalrm_handler`] while the TUI is active (TMOUT / SIGALRM).
static SIGALRM_RECEIVED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

extern "C" fn flyline_sigalrm_handler(_sig: libc::c_int) {
SIGALRM_RECEIVED.store(true, std::sync::atomic::Ordering::Relaxed);
}

pub(crate) fn clear_sigalrm_received() {
SIGALRM_RECEIVED.store(false, std::sync::atomic::Ordering::Relaxed);
}

fn take_sigalrm_received() -> bool {
SIGALRM_RECEIVED.swap(false, std::sync::atomic::Ordering::Relaxed)
}

/// Install flyline's SIGALRM handler; returns the previous handler.
pub(crate) fn install_sigalrm_handler() -> libc::sighandler_t {
clear_sigalrm_received();
// SAFETY: only swaps the process SIGALRM disposition; the handler is
// async-signal-safe (atomic store only).
unsafe {
libc::signal(
libc::SIGALRM,
flyline_sigalrm_handler as *const () as libc::sighandler_t,
)
}
}

pub(crate) fn restore_sigalrm_handler(previous: libc::sighandler_t) {
// SAFETY: restores the disposition Bash had before we entered the TUI.
unsafe {
libc::signal(libc::SIGALRM, previous);
}
}

fn restore_terminal(write: &mut impl std::io::Write) {
let reset = |code| Csi::Mode(DecMode::ResetDecPrivateMode(DecPrivateMode::Code(code)));
let _ = write!(
Expand Down Expand Up @@ -186,6 +225,9 @@ fn stdin_unavailable_reason() -> Option<&'static str> {
pub enum ExitState {
WithCommand(String),
WithoutCommand,
/// SIGALRM fired while the TUI was active (e.g. interactive `TMOUT`).
/// Caller MUST restore Bash's SIGALRM handler and `raise(SIGALRM)`.
TimedOut,
EOF,
}

Expand Down Expand Up @@ -214,7 +256,11 @@ pub fn get_command(settings: &mut Settings) -> ExitState {

let app = time_it!("startup: app creation", App::new(settings));

// Intercept SIGALRM so TMOUT's alrm_catcher cannot longjmp through the TUI.
// On timeout we exit cleanly; Flyline::get restores the handler and re-raises.
let prev_sigalrm = install_sigalrm_handler();
let end_state = app.run();
restore_sigalrm_handler(prev_sigalrm);

restore_terminal(&mut std::io::stdout());

Expand Down Expand Up @@ -936,8 +982,10 @@ impl<'a> App<'a> {
self.settings.frame_rate as f64
};
let min_refresh_rate: Duration = Duration::from_millis((1000.0 / effective_fps) as u64);
// Wake often enough to run pending Bash traps promptly while idle.
let poll_timeout = min_refresh_rate.min(SIGNAL_POLL_CAP);

redraw = match poll_terminal_event(&event_reader, min_refresh_rate) {
redraw = match poll_terminal_event(&event_reader, poll_timeout) {
Comment on lines +986 to +988

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would effectively disable the 5s refresh rate on idle?

If we really want flyline to always be responsive to signals, then we can add a setting to control idle fps: flyline --set-idle-frame-rate default to 0.2.

Ok(Some(event)) => {
let r = match event {
TerminaEvent::Key(key) => {
Expand Down Expand Up @@ -1097,7 +1145,12 @@ impl<'a> App<'a> {
};
r
}
Ok(None) => true,
// Timeout or signal wake: redraw only when the frame interval elapsed.
Ok(None) => false,
Err(err) if err.kind() == ErrorKind::Interrupted => {
// EINTR from a Bash trap handler — check pending traps below.
false
}
Err(err) => {
log::info!(
"Terminal input problem, setting mode to exiting with EOF: {}",
Expand Down Expand Up @@ -1128,6 +1181,32 @@ impl<'a> App<'a> {
self.mode = AppRunningState::Exiting(ExitState::WithoutCommand);
break 'main_loop;
}

// TMOUT / SIGALRM: our handler only sets a flag; exit cleanly then
// re-raise after terminal restore so Bash's alrm_catcher can longjmp.
if take_sigalrm_received() {
log::info!("SIGALRM received, exiting for Bash timeout/trap handling");
self.mode = AppRunningState::Exiting(ExitState::TimedOut);
break 'main_loop;
}

// External SIGINT (not Ctrl+C key in raw mode): tear down then let Bash QUIT.
if shell::backend().interrupt_pending() {
log::info!("interrupt_state set, exiting so Bash can handle SIGINT");
self.mode = AppRunningState::Exiting(ExitState::WithoutCommand);
break 'main_loop;
}

// Readline parity: run pending traps / Bash signal event hook without
// ending the editing session (preserves the partial input line).
if shell::backend().run_pending_traps() {
// Redraw so style changes apply.
// Do not expand the viewport to full height — that looks like a
// screen clear and is unnecessary for traps that write little/no
// stdout.
self.needs_full_redraw = true;
redraw = true;
}
}

shell::backend().deprep_terminal();
Expand Down Expand Up @@ -1156,6 +1235,8 @@ impl<'a> App<'a> {

if matches!(self.mode, AppRunningState::Exiting(ExitState::EOF)) {
ExitState::EOF
} else if matches!(self.mode, AppRunningState::Exiting(ExitState::TimedOut)) {
ExitState::TimedOut
} else {
ExitState::WithoutCommand
}
Expand Down
158 changes: 114 additions & 44 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use libc::{c_char, c_int};
use std::cell::Cell;
use std::sync::Mutex;

#[global_allocator]
Expand Down Expand Up @@ -70,6 +71,52 @@ pub use grammar::dparser;
// Global state for our custom input stream
static FLYLINE_INSTANCE_PTR: Mutex<Option<Box<Flyline>>> = Mutex::new(None);

// While `flyline_get_char` is running `Flyline::get` / `App::run`, Bash may invoke
// pending traps that call the `flyline` builtin (again). That re-enters
// `flyline_call_command` on the same thread. We release `FLYLINE_INSTANCE_PTR`
// during get() and point this TLS at the active instance so re-entrant builtin
// calls skip the mutex (a non-reentrant `Mutex` would deadlock). Bash's trap path
// is single-threaded; unload must not run while get is active.
thread_local! {
static ACTIVE_FLYLINE: Cell<*mut Flyline> = const { Cell::new(std::ptr::null_mut()) };
}

/// Sets [`ACTIVE_FLYLINE`] for the duration of a `get` call; clears it on drop
/// (including panic unwind).
struct ActiveFlylineGuard;

impl ActiveFlylineGuard {
Comment on lines +74 to +88

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with my proposed solution, I think all this goes away.

/// # Safety
/// `flyline` must remain valid and not be freed (e.g. via unload) until this
/// guard is dropped.
unsafe fn activate(flyline: *mut Flyline) -> Self {
ACTIVE_FLYLINE.with(|c| c.set(flyline));
Self
}
}

impl Drop for ActiveFlylineGuard {
fn drop(&mut self) {
ACTIVE_FLYLINE.with(|c| c.set(std::ptr::null_mut()));
}
}

fn with_flyline_mut<R>(f: impl FnOnce(&mut Flyline) -> R) -> Option<R> {
// Prefer the in-get instance so trap handlers can call `flyline` without
// deadlocking on FLYLINE_INSTANCE_PTR.
let active = ACTIVE_FLYLINE.with(|c| c.get());
if !active.is_null() {
// SAFETY: ACTIVE_FLYLINE is only set from flyline_get_char to a Box<Flyline>
// that outlives the get() call; Bash is single-threaded on this path.
return Some(f(unsafe { &mut *active }));
}

let mut guard = FLYLINE_INSTANCE_PTR
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.as_mut().map(|boxed| f(boxed))
}

fn catch_unwind_safe<T>(f: impl FnOnce() -> T) -> Result<T, ()> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| ())
}
Expand All @@ -89,63 +136,66 @@ fn report_error_no_panic(message: &str) {
// C-compatible getter function that bash will call
#[cfg(not(test))]
extern "C" fn flyline_get_char() -> c_int {
if let Some(boxed) = FLYLINE_INSTANCE_PTR
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
match catch_unwind_safe(|| boxed.get()) {
Ok(c) => c,
Err(_) => {
// writing to stderr can panic if master pty side has been closed.
report_stderr_no_panic(
"flyline: app panicked; recovering with EOF. Please create an issue with the steps to reproduce at https://github.com/HalFrgrd/flyline/issues.",
);
report_error_no_panic("app panicked; recovering with EOF");

std::thread::sleep(std::time::Duration::from_millis(1000));
bash_symbols::EOF
// Take a raw pointer and release FLYLINE_INSTANCE_PTR before get()/App::run.
// Pending Bash traps may call the `flyline` builtin (e.g. set-style); those
// re-enter via ACTIVE_FLYLINE / with_flyline_mut. Holding the mutex across
// get() would deadlock on that path.
let flyline_ptr: *mut Flyline = {
let mut guard = FLYLINE_INSTANCE_PTR
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.as_mut() {
Some(boxed) => &raw mut **boxed,
None => {
report_stderr_no_panic("flyline_get_char: FLYLINE_INSTANCE_PTR is None");
return bash_symbols::EOF;
}
}
} else {
report_stderr_no_panic("flyline_get_char: FLYLINE_INSTANCE_PTR is None");
bash_symbols::EOF
};

// SAFETY: instance stays in FLYLINE_INSTANCE_PTR until unload; unload is not
// expected while get() is active on the Bash main thread.
let _active = unsafe { ActiveFlylineGuard::activate(flyline_ptr) };
match catch_unwind_safe(|| unsafe { (*flyline_ptr).get() }) {
Ok(c) => c,
Err(_) => {
// writing to stderr can panic if master pty side has been closed.
report_stderr_no_panic(
"flyline: app panicked; recovering with EOF. Please create an issue with the steps to reproduce at https://github.com/HalFrgrd/flyline/issues.",
);
report_error_no_panic("app panicked; recovering with EOF");

std::thread::sleep(std::time::Duration::from_millis(1000));
bash_symbols::EOF
}
}
}

// C-compatible ungetter function that bash will call
#[cfg(not(test))]
extern "C" fn flyline_unget_char(c: c_int) -> c_int {
if let Some(boxed) = FLYLINE_INSTANCE_PTR
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
return match catch_unwind_safe(|| boxed.unget(c)) {
Ok(unget_char) => unget_char,
Err(_) => {
report_stderr_no_panic("flyline: unget handler panicked; ignoring.");
report_error_no_panic("flyline_unget_char panicked; returning original character");
c
}
};
match with_flyline_mut(|boxed| catch_unwind_safe(|| boxed.unget(c))) {
Some(Ok(unget_char)) => unget_char,
Some(Err(_)) => {
report_stderr_no_panic("flyline: unget handler panicked; ignoring.");
report_error_no_panic("flyline_unget_char panicked; returning original character");
c
}
None => {
report_stderr_no_panic("flyline_unget_char: FLYLINE_INSTANCE_PTR is None");
c
}
}
report_stderr_no_panic("flyline_unget_char: FLYLINE_INSTANCE_PTR is None");
c
}

#[cfg(not(test))]
extern "C" fn flyline_call_command(words: *const bash_symbols::WordList) -> c_int {
let result = catch_unwind_safe(|| {
if let Some(boxed) = FLYLINE_INSTANCE_PTR
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
return boxed.call(words);
let result = catch_unwind_safe(|| match with_flyline_mut(|boxed| boxed.call(words)) {
Some(code) => code,
None => {
report_stderr_no_panic("flyline_call_command: FLYLINE_INSTANCE_PTR is None");
0
}
report_stderr_no_panic("flyline_call_command: FLYLINE_INSTANCE_PTR is None");
0
});
match result {
Ok(code) => code,
Expand Down Expand Up @@ -253,7 +303,27 @@ impl Flyline {
log::info!("App signaled EOF");
return bash_symbols::EOF;
}
app::ExitState::WithoutCommand => vec![],
app::ExitState::TimedOut => {
// Bash's SIGALRM handler is restored; re-raise so TMOUT's
// alrm_catcher (or a SIGALRM trap) can run after TUI cleanup.
// alrm_catcher may longjmp and not return.
log::info!("Re-raising SIGALRM after TUI cleanup");
unsafe {
libc::raise(libc::SIGALRM);
}
// If we get here, the handler returned (e.g. a trap, not TMOUT).
bash_funcs::check_signals_and_traps();
vec![]
}
app::ExitState::WithoutCommand => {
// External SIGINT: let Bash process interrupt_state now that
// the TUI and Rust frames around App have unwound.
if bash_funcs::read_interrupt_state() != 0 {
log::info!("Running check_signals_and_traps for interrupt_state");
bash_funcs::check_signals_and_traps();
}
vec![]
}
};
log::info!("---------------------- App finished ------------------------");
self.content.push(b'\n');
Expand Down
55 changes: 55 additions & 0 deletions src/shell/bash/funcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,61 @@ pub fn read_terminating_signal() -> c_int {
unsafe { (&raw const super::symbols::terminating_signal).read_volatile() }
}

pub fn read_interrupt_state() -> c_int {
unsafe { (&raw const super::symbols::interrupt_state).read_volatile() }
}

/// Returns the first signal with a pending trap, or `-1` if none.
pub fn first_pending_trap() -> c_int {
let _guard = super::symbols::BASH_LOCK.lock();
unsafe { bash_symbols::first_pending_trap() }
}

/// Runs Bash's pending trap commands.
///
/// Trap bodies are arbitrary shell code and may re-enter the `flyline` builtin
/// (e.g. a SIGUSR2 trap running `flyline set-style`); [`super::symbols::BASH_LOCK`]
/// is reentrant, so taking it here keeps flyline's worker threads off Bash
/// globals without deadlocking that path.
pub fn check_signals_and_traps() {
let _guard = super::symbols::BASH_LOCK.lock();
unsafe { bash_symbols::check_signals_and_traps() }
}

/// Returns Bash's `rl_signal_event_hook` if set (bash >= 4.4).
#[cfg(not(feature = "pre_bash_4_4"))]
fn rl_signal_event_hook() -> Option<extern "C" fn() -> c_int> {
unsafe { bash_symbols::rl_signal_event_hook }
}

#[cfg(feature = "pre_bash_4_4")]
fn rl_signal_event_hook() -> Option<extern "C" fn() -> c_int> {
None
}

/// Runs any pending traps without ending the editing session, mirroring what
/// readline does between keystrokes. Returns `true` if anything ran.
pub fn run_pending_traps() -> bool {
if let Some(hook) = rl_signal_event_hook() {
log::info!("Calling rl_signal_event_hook for pending Bash signal/trap");
let _guard = super::symbols::BASH_LOCK.lock();
hook();
return true;
}

let pending = first_pending_trap();
if pending > 0 {
log::info!(
"Pending trap for signal {}, running check_signals_and_traps",
crate::app::signal_to_str(pending)
);
check_signals_and_traps();
return true;
}

false
}

#[allow(dead_code)]
pub fn set_env_var(name: &str, value: &str) -> Result<()> {
let _guard = super::symbols::BASH_LOCK.lock();
Expand Down
Loading