arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! Termination signal handling for the production lifecycle (AP2.1-10).
//!
//! [`termination_signal`] is the future `run_with_health` awaits to detect
//! a production shutdown request. It resolves on the first of:
//!
//! - **Unix**: `SIGTERM` (the standard Kubernetes/container graceful-stop
//!   signal) or `SIGINT` (interactive Ctrl-C).
//! - **Windows**: `CTRL_C_EVENT` or `CTRL_BREAK_EVENT` (interactive Ctrl-C /
//!   Ctrl-Break). Windows has no `SIGTERM` equivalent in the console
//!   subsystem; a production deployment sends `CTRL_BREAK_EVENT` via the
//!   job-object / service control, or the orchestrator terminates the
//!   process. This platform difference is documented here and in the
//!   security review — it is not a bug.
//!
//! The future never resolves to an error that aborts the lifecycle: a
//! signal-read failure (e.g. the handler could not be installed) is logged
//! and the future stays pending, so a broken signal stream does not cause
//! an uncontrolled exit. The OS still delivers the default action for a
//! second `SIGINT`/Ctrl-C (terminate) if the process is wedged.
//!
//! # Why tokio `signal` (already admitted)
//!
//! `tokio` is admitted behind the `macros` feature (Phase 1, vetted) with
//! the `signal` sub-feature enabled on the certified runtime line. This
//! module uses `tokio::signal::ctrl_c` (cross-platform) and, on Unix,
//! `tokio::signal::unix::signal(SignalKind::terminate())` for `SIGTERM`. No
//! new dependency (AGENTS.md §3: do not reinvent wheels; §8: dependencies
//! enter only through policy). The platform-specific paths are `cfg(unix)`
//! / `cfg(windows)` so the module compiles on both first-class platforms
//! (PROGRAM.md: "Windows and Linux first-class").

//! Gated by the `macros` feature, which brings the certified `tokio`
//! runtime with the `signal` sub-feature.

use std::future::Future;

/// A future that resolves when a production termination signal is received.
///
/// On Unix it listens for `SIGTERM` and `SIGINT` (whichever fires first). On
/// Windows it listens for `CTRL_C_EVENT` and `CTRL_BREAK_EVENT`. The future
/// is `Send + 'static` so it can be passed to `serve_with_health` /
/// `run_with_health` as the shutdown signal.
///
/// This is the production default. A test or an expert user that wants a
/// caller-controlled shutdown (e.g. a `oneshot` channel) passes their own
/// future to `serve_with_health` instead — the lifecycle orchestration is
/// signal-agnostic.
pub fn termination_signal() -> std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
    Box::pin(async {
        #[cfg(unix)]
        {
            unix_termination().await;
        }
        #[cfg(windows)]
        {
            windows_termination().await;
        }
        #[cfg(not(any(unix, windows)))]
        {
            // No signal handling on this platform; block forever so the
            // future stays pending (the process relies on the OS default
            // action or an external kill). This branch is unreachable on
            // the supported platforms but keeps the module total.
            std::future::pending::<()>().await;
        }
    })
}

#[cfg(unix)]
async fn unix_termination() {
    use tokio::signal::unix::{SignalKind, signal};
    // `SIGTERM` is the standard Kubernetes/container graceful-stop signal;
    // `SIGINT` is interactive Ctrl-C. Await whichever fires first. A failure
    // to install a handler is logged and the future falls back to the other
    // handler (or, if neither can be installed, blocks forever — the OS
    // default action still applies on a second signal). The handler is never
    // `expect`ed: a broken signal stream does not panic the lifecycle
    // (AGENTS.md §17).
    let sigterm = signal(SignalKind::terminate());
    let sigint = signal(SignalKind::interrupt());
    match (sigterm, sigint) {
        (Ok(mut term), Ok(mut int)) => {
            tokio::select! {
                _ = term.recv() => {}
                _ = int.recv() => {}
            }
        }
        (Ok(mut term), Err(error)) => {
            eprintln!("warning: cannot install SIGINT handler: {error}");
            term.recv().await;
        }
        (Err(error), Ok(mut int)) => {
            eprintln!("warning: cannot install SIGTERM handler: {error}");
            int.recv().await;
        }
        (Err(term_err), Err(int_err)) => {
            eprintln!(
                "warning: cannot install SIGTERM ({term_err}) or SIGINT ({int_err}) handler; \
                 blocking until external termination"
            );
            std::future::pending::<()>().await;
        }
    }
}

#[cfg(windows)]
async fn windows_termination() {
    use tokio::signal::windows::{ctrl_break, ctrl_c};
    let mut ctrl_c = match ctrl_c() {
        Ok(stream) => stream,
        Err(error) => {
            eprintln!("warning: cannot install Ctrl-C handler: {error}");
            std::future::pending::<()>().await;
            return;
        }
    };
    let mut ctrl_break = match ctrl_break() {
        Ok(stream) => stream,
        Err(error) => {
            eprintln!("warning: cannot install Ctrl-Break handler: {error}");
            // Fall back to Ctrl-C only.
            ctrl_c.recv().await;
            return;
        }
    };
    tokio::select! {
        _ = ctrl_c.recv() => {}
        _ = ctrl_break.recv() => {}
    }
}

#[cfg(test)]
mod tests {
    use super::termination_signal;
    use std::time::Duration;

    /// The termination signal future is constructible and Send + 'static.
    /// It cannot be reasonably driven in a unit test (it awaits an OS
    // signal), so this asserts only the type-level contract. The real
    /// signal-driven drain is exercised by the `serve_with_health` /
    /// drain-under-load integration tests with a caller-provided signal.
    #[test]
    fn termination_signal_is_send_static() {
        fn assert_send_static<T: Future<Output = ()> + Send + 'static>() {}
        assert_send_static::<std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'static>>>();
        let _signal = termination_signal();
    }

    /// A pending termination signal does not resolve within a short budget
    /// (proves the future is actually awaiting a signal, not immediately
    /// completing).
    #[tokio::test]
    async fn termination_signal_waits_for_signal() {
        let signal = termination_signal();
        let result = tokio::time::timeout(Duration::from_millis(50), signal).await;
        assert!(result.is_err(), "signal future should wait, not resolve");
    }
}