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:
2026-03-01 18:50:29 +01:00
parent 8ef587c163
commit 065365662c
5 changed files with 46 additions and 0 deletions
Generated
+1
View File
@@ -223,6 +223,7 @@ dependencies = [
"ansi-to-tui",
"clap",
"image",
"libc",
"notify",
"pulldown-cmark",
"ratatui 0.30.0",
+1
View File
@@ -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
View File
@@ -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 {
+7
View File
@@ -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,
}
}
}
+8
View File
@@ -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 {