diff --git a/src/main.rs b/src/main.rs index 8d34b7e..a786d70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod config; +mod signals; mod terminal; fn main() { diff --git a/src/signals.rs b/src/signals.rs new file mode 100644 index 0000000..cdf1cd1 --- /dev/null +++ b/src/signals.rs @@ -0,0 +1,86 @@ +//! UNIX signal handling for bbs-md. +//! +//! # Design +//! +//! SIGHUP and SIGTERM are registered via `signal-hook` to set a shared `AtomicBool` flag. +//! The event loop polls this flag at the top of each iteration and exits cleanly when set. +//! +//! SIGINT (Ctrl+C) is deliberately NOT registered here. The double-press Ctrl+C state +//! machine lives in the event loop and handles SIGINT via crossterm key events +//! (`KeyCode::Char('c') + KeyModifiers::CONTROL`). Registering SIGINT via signal-hook +//! AND crossterm would cause double-handling. +//! +//! # Stdout/Stderr Rule (LIFE-03) +//! +//! Phase 1: no file logging is needed yet. The rule is: +//! do not write to stdout/stderr after terminal init except through ratatui's draw cycle +//! or after calling `restore_terminal()`. +//! +//! When LIFE-03 becomes relevant in Phase 2+, replace `init_logging()` with file-based +//! logging (e.g., `tracing` or `log` + `simplelog` writing to an append-mode file). + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use signal_hook::consts::signal::{SIGHUP, SIGTERM}; +use signal_hook::flag as signal_flag; + +/// Shared flag handles for the registered UNIX signals. +/// +/// Create with `register_signals()` and poll `should_terminate()` in the event loop. +pub struct SignalFlags { + /// Set to `true` by the OS when SIGHUP or SIGTERM is received. + /// + /// SIGHUP fires when the controlling terminal closes (SSH disconnect). + /// SIGTERM fires on `kill ` or `systemctl stop`. + pub terminate: Arc, +} + +impl SignalFlags { + /// Returns `true` when a SIGHUP or SIGTERM has been received. + /// + /// Uses `Ordering::Relaxed` — the flag only ever transitions from `false` to `true`, + /// and a one-iteration delay before detecting the signal is acceptable. + pub fn should_terminate(&self) -> bool { + self.terminate.load(Ordering::Relaxed) + } +} + +/// Register SIGHUP and SIGTERM to set a shared `AtomicBool` flag. +/// +/// The returned `SignalFlags` must be kept alive for the duration of the process. +/// Dropping it does not unregister the handlers, but the backing `Arc` +/// will be dropped, which is safe (signal-hook holds its own `Arc` clone internally). +/// +/// # Errors +/// +/// Returns an error if the OS refuses to register a signal handler (extremely rare). +pub fn register_signals() -> std::io::Result { + let terminate = Arc::new(AtomicBool::new(false)); + + // SIGHUP: SSH disconnect / terminal hangup + signal_flag::register(SIGHUP, Arc::clone(&terminate))?; + + // SIGTERM: systemd stop, `kill `, graceful OS shutdown + signal_flag::register(SIGTERM, Arc::clone(&terminate))?; + + // SIGINT (Ctrl+C) is NOT registered here — handled via crossterm key events + // in the event loop to enable the double-press confirmation state machine. + + Ok(SignalFlags { terminate }) +} + +/// Initialize logging. +/// +/// Phase 1 stub: this is a no-op. The rule for LIFE-03 is enforced by convention: +/// after `init_terminal()` is called, nothing in the application writes to stdout +/// or stderr directly. All output goes through the ratatui draw cycle. +/// +/// The one exception is the panic hook in `terminal.rs`, which calls +/// `restore_terminal()` first (reverting to normal terminal mode) before +/// using `eprintln!()`. +/// +/// When Phase 2+ introduces persistent logging, replace this with a file-based +/// logger (e.g., `tracing_subscriber::fmt().with_writer(File::create(...))`). +pub fn init_logging() { + // No-op for Phase 1. +}