feat: add idle timeout and parent process death detection
Prevents orphaned bbs-md processes when SSH sessions disconnect uncleanly. Adds two mechanisms: - Parent death check: polls getppid() each loop iteration and exits when the parent (sshd) dies, detecting session orphaning immediately. - Idle timeout: configurable idle_timeout_minutes in bbs.toml (default 0 = disabled) exits after no keyboard/mouse input for the duration.
This commit is contained in:
Generated
+1
@@ -223,6 +223,7 @@ dependencies = [
|
||||
"ansi-to-tui",
|
||||
"clap",
|
||||
"image",
|
||||
"libc",
|
||||
"notify",
|
||||
"pulldown-cmark",
|
||||
"ratatui 0.30.0",
|
||||
|
||||
@@ -18,3 +18,4 @@ walkdir = "2.5"
|
||||
ureq = "2.12"
|
||||
ratatui-image = { version = "10.0", default-features = false }
|
||||
image = "0.25"
|
||||
libc = "0.2"
|
||||
|
||||
+29
@@ -161,6 +161,10 @@ pub enum ShutdownReason {
|
||||
UserQuit,
|
||||
/// A SIGHUP or SIGTERM was received.
|
||||
Signal,
|
||||
/// No user input for the configured idle timeout duration.
|
||||
IdleTimeout,
|
||||
/// Parent process (sshd) died — SSH session is gone.
|
||||
ParentDied,
|
||||
}
|
||||
|
||||
// ── HistoryEntry ──────────────────────────────────────────────────────────────
|
||||
@@ -277,6 +281,13 @@ pub struct App {
|
||||
image_records: Vec<crate::renderer::ImageRecord>,
|
||||
/// Cache of loaded images keyed by source string.
|
||||
image_cache: HashMap<String, ImageCacheEntry>,
|
||||
|
||||
// ── Session lifecycle ──────────────────────────────────────────────────
|
||||
/// Timestamp of the last keyboard or mouse input from the user.
|
||||
last_input_at: Instant,
|
||||
/// PID of the parent process at startup (typically sshd).
|
||||
/// If getppid() changes, the parent died and the session is orphaned.
|
||||
initial_ppid: u32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -352,6 +363,8 @@ impl App {
|
||||
picker,
|
||||
image_records,
|
||||
image_cache: HashMap::new(),
|
||||
last_input_at: Instant::now(),
|
||||
initial_ppid: unsafe { libc::getppid() } as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,9 +412,11 @@ impl App {
|
||||
if event::poll(Duration::from_millis(250))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
self.last_input_at = Instant::now();
|
||||
self.handle_key(key);
|
||||
}
|
||||
Event::Mouse(mouse) => {
|
||||
self.last_input_at = Instant::now();
|
||||
self.handle_mouse(mouse);
|
||||
}
|
||||
Event::Resize(w, _h) => {
|
||||
@@ -411,6 +426,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.1 Check idle timeout (0 = disabled)
|
||||
if self.config.idle_timeout_minutes > 0 {
|
||||
let timeout = Duration::from_secs(self.config.idle_timeout_minutes * 60);
|
||||
if self.last_input_at.elapsed() >= timeout {
|
||||
return Ok(ShutdownReason::IdleTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
// 3.2 Check if parent process (sshd) died — session is orphaned
|
||||
let current_ppid = unsafe { libc::getppid() } as u32;
|
||||
if current_ppid != self.initial_ppid {
|
||||
return Ok(ShutdownReason::ParentDied);
|
||||
}
|
||||
|
||||
// 3a. Check for filesystem events (non-blocking)
|
||||
if let Some(ref mut watcher) = self.file_watcher {
|
||||
loop {
|
||||
|
||||
@@ -30,6 +30,12 @@ pub struct Config {
|
||||
/// terminals where protocol detection fails or produces garbled output.
|
||||
#[serde(default)]
|
||||
pub force_halfblocks: bool,
|
||||
|
||||
/// Idle session timeout in minutes. When no keyboard or mouse input is
|
||||
/// received for this duration, the session exits automatically.
|
||||
/// Set to 0 to disable (default).
|
||||
#[serde(default)]
|
||||
pub idle_timeout_minutes: u64,
|
||||
}
|
||||
|
||||
fn default_vault_path() -> PathBuf {
|
||||
@@ -58,6 +64,7 @@ impl Default for Config {
|
||||
max_remote_document_size: default_max_remote_document_size(),
|
||||
max_remote_image_size: default_max_remote_image_size(),
|
||||
force_halfblocks: false,
|
||||
idle_timeout_minutes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,14 @@ fn main() {
|
||||
// SIGHUP or SIGTERM — SSH disconnect or graceful OS shutdown.
|
||||
// Exit silently: there may be nobody on the other end to see a message.
|
||||
}
|
||||
Ok(app::ShutdownReason::IdleTimeout) => {
|
||||
// Idle timeout — user may still be watching, show a brief message.
|
||||
println!("\r\nSession timed out due to inactivity.\r");
|
||||
}
|
||||
Ok(app::ShutdownReason::ParentDied) => {
|
||||
// Parent process (sshd) died — SSH session is gone.
|
||||
// Exit silently: there is nobody on the other end.
|
||||
}
|
||||
Err(e) => {
|
||||
// I/O error from the event loop
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
|
||||
Reference in New Issue
Block a user