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",
|
"ansi-to-tui",
|
||||||
"clap",
|
"clap",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
"notify",
|
"notify",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"ratatui 0.30.0",
|
"ratatui 0.30.0",
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ walkdir = "2.5"
|
|||||||
ureq = "2.12"
|
ureq = "2.12"
|
||||||
ratatui-image = { version = "10.0", default-features = false }
|
ratatui-image = { version = "10.0", default-features = false }
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
|
libc = "0.2"
|
||||||
|
|||||||
+29
@@ -161,6 +161,10 @@ pub enum ShutdownReason {
|
|||||||
UserQuit,
|
UserQuit,
|
||||||
/// A SIGHUP or SIGTERM was received.
|
/// A SIGHUP or SIGTERM was received.
|
||||||
Signal,
|
Signal,
|
||||||
|
/// No user input for the configured idle timeout duration.
|
||||||
|
IdleTimeout,
|
||||||
|
/// Parent process (sshd) died — SSH session is gone.
|
||||||
|
ParentDied,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HistoryEntry ──────────────────────────────────────────────────────────────
|
// ── HistoryEntry ──────────────────────────────────────────────────────────────
|
||||||
@@ -277,6 +281,13 @@ pub struct App {
|
|||||||
image_records: Vec<crate::renderer::ImageRecord>,
|
image_records: Vec<crate::renderer::ImageRecord>,
|
||||||
/// Cache of loaded images keyed by source string.
|
/// Cache of loaded images keyed by source string.
|
||||||
image_cache: HashMap<String, ImageCacheEntry>,
|
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 {
|
impl App {
|
||||||
@@ -352,6 +363,8 @@ impl App {
|
|||||||
picker,
|
picker,
|
||||||
image_records,
|
image_records,
|
||||||
image_cache: HashMap::new(),
|
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))? {
|
if event::poll(Duration::from_millis(250))? {
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
Event::Key(key) => {
|
Event::Key(key) => {
|
||||||
|
self.last_input_at = Instant::now();
|
||||||
self.handle_key(key);
|
self.handle_key(key);
|
||||||
}
|
}
|
||||||
Event::Mouse(mouse) => {
|
Event::Mouse(mouse) => {
|
||||||
|
self.last_input_at = Instant::now();
|
||||||
self.handle_mouse(mouse);
|
self.handle_mouse(mouse);
|
||||||
}
|
}
|
||||||
Event::Resize(w, _h) => {
|
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)
|
// 3a. Check for filesystem events (non-blocking)
|
||||||
if let Some(ref mut watcher) = self.file_watcher {
|
if let Some(ref mut watcher) = self.file_watcher {
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ pub struct Config {
|
|||||||
/// terminals where protocol detection fails or produces garbled output.
|
/// terminals where protocol detection fails or produces garbled output.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub force_halfblocks: bool,
|
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 {
|
fn default_vault_path() -> PathBuf {
|
||||||
@@ -58,6 +64,7 @@ impl Default for Config {
|
|||||||
max_remote_document_size: default_max_remote_document_size(),
|
max_remote_document_size: default_max_remote_document_size(),
|
||||||
max_remote_image_size: default_max_remote_image_size(),
|
max_remote_image_size: default_max_remote_image_size(),
|
||||||
force_halfblocks: false,
|
force_halfblocks: false,
|
||||||
|
idle_timeout_minutes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ fn main() {
|
|||||||
// SIGHUP or SIGTERM — SSH disconnect or graceful OS shutdown.
|
// SIGHUP or SIGTERM — SSH disconnect or graceful OS shutdown.
|
||||||
// Exit silently: there may be nobody on the other end to see a message.
|
// 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) => {
|
Err(e) => {
|
||||||
// I/O error from the event loop
|
// I/O error from the event loop
|
||||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
|
|||||||
Reference in New Issue
Block a user