feat(01-02): implement signal handling and logging stub

- SignalFlags struct wraps Arc<AtomicBool> for SIGHUP/SIGTERM
- register_signals() registers SIGHUP and SIGTERM only (not SIGINT)
- SIGINT deliberately excluded — handled via crossterm key events for double-press Ctrl+C
- should_terminate() polls flag with Ordering::Relaxed for event loop use
- init_logging() is a Phase 1 no-op stub with LIFE-03 documentation
This commit is contained in:
2026-02-28 21:12:45 +01:00
parent 65313eac31
commit 966b47edbc
2 changed files with 87 additions and 0 deletions
+1
View File
@@ -1,4 +1,5 @@
mod config; mod config;
mod signals;
mod terminal; mod terminal;
fn main() { fn main() {
+86
View File
@@ -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 <pid>` or `systemctl stop`.
pub terminate: Arc<AtomicBool>,
}
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<AtomicBool>`
/// 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<SignalFlags> {
let terminate = Arc::new(AtomicBool::new(false));
// SIGHUP: SSH disconnect / terminal hangup
signal_flag::register(SIGHUP, Arc::clone(&terminate))?;
// SIGTERM: systemd stop, `kill <pid>`, 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.
}