When Ctrl+C breaks your cleanup
You are building a background worker that processes a queue of jobs. It runs for hours. You hit Ctrl+C to stop it. Instead of a clean exit, the process dies instantly, leaving half-written files and a corrupted database. You reach for a signal handler to catch SIGINT and run your cleanup code. The Rust compiler immediately blocks you. Traditional signal handlers do not exist in safe Rust. The language refuses to let the operating system interrupt your code at arbitrary instruction boundaries.
The async-signal problem
Operating systems deliver signals asynchronously. The CPU pauses whatever your program is doing, jumps to a handler function, runs it, and resumes. That sounds convenient until you consider what your program was doing when the interrupt hit. Maybe it was halfway through updating a linked list. Maybe it was holding a mutex. Maybe the borrow checker was tracking a temporary reference that only exists for the next three lines of code. An asynchronous jump shatters those guarantees.
Think of the borrow checker as a meticulous librarian. Every book has a checkout card tracking exactly who holds it and when it must be returned. A traditional signal handler is like a sudden earthquake that knocks a book out of a patron's hands and drops it on the floor. The librarian's records no longer match reality. Rust would rather refuse the earthquake than risk a corrupted catalog.
Safe Rust replaces asynchronous interruption with cooperative polling. Instead of the OS yanking your thread into a callback, you ask the OS to queue the signal. Your main loop checks the queue at safe, predictable points. The borrow checker stays in control. The memory layout remains intact.
The cooperative alternative
The community standard for this pattern is the signal-hook crate. It wraps the OS primitives and exposes an iterator. You do not register a callback. You iterate over received signals.
use signal_hook::consts::signal::SIGINT;
use signal_hook::iterator::Signals;
fn main() {
// Create a signal queue that listens only for Ctrl+C
let mut signals = Signals::new(&[SIGINT]).unwrap();
// Block until a signal arrives, then yield it
for _signal in signals.forever() {
println!("Caught SIGINT. Shutting down cleanly.");
break;
}
}
Add signal-hook = "0.3" to your Cargo.toml dependencies. The Signals::new call sets up the OS-level handler in the background. That handler does nothing but drop a marker into a lock-free queue. The forever() iterator blocks your thread until a marker appears. When it does, the loop body runs in your normal execution context. The borrow checker sees a standard loop, not an asynchronous jump.
Trust the polling pattern. It turns a chaotic OS interrupt into a predictable Rust loop.
Under the hood
Here is what happens when you run that code. Signals::new installs a raw C signal handler via libc::sigaction. That raw handler is deliberately tiny. It calls an internal emitter function, which writes a value into a shared atomic counter. It returns immediately. No allocations. No mutex locks. No string formatting. The entire raw handler runs in a few machine instructions.
Your main thread sits in signals.forever(). That method calls a blocking primitive like libc::sigsuspend. The thread sleeps until the atomic counter changes. Once it changes, the iterator yields the signal number. Your for loop body executes with full Rust safety guarantees. You can call println!, drop open file handles, join worker threads, or flush buffers. The compiler knows exactly what is alive and what is dead.
This cooperative model trades instant interruption for predictable execution. The signal is not processed the exact nanosecond the OS delivers it. It is processed the next time your polling loop wakes up. For 99 percent of applications, that delay is measured in microseconds and is completely invisible to the user. The tradeoff is worth it. You get memory safety without sacrificing control.
Realistic multi-threaded shutdown
Real applications rarely just print a message and exit. They need to coordinate shutdown across multiple threads. A common pattern uses a shared atomic flag or a channel to broadcast the shutdown intent.
use signal_hook::consts::signal::{SIGINT, SIGTERM};
use signal_hook::iterator::Signals;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
/// Runs a background worker that checks the shutdown flag
fn run_worker(shutdown: Arc<AtomicBool>) {
while !shutdown.load(Ordering::SeqCst) {
// Simulate work
thread::sleep(Duration::from_millis(100));
println!("Worker processing...");
}
println!("Worker received shutdown signal. Cleaning up.");
}
fn main() {
// Shared flag visible to all threads
let shutdown = Arc::new(AtomicBool::new(false));
// Spawn a worker thread
let worker_shutdown = Arc::clone(&shutdown);
let handle = thread::spawn(move || run_worker(worker_shutdown));
// Listen for Ctrl+C or kill command
let mut signals = Signals::new(&[SIGINT, SIGTERM]).unwrap();
for _signal in signals.forever() {
// Set the flag atomically. No locks needed.
shutdown.store(true, Ordering::SeqCst);
println!("Main thread caught signal. Waiting for worker...");
break;
}
// Block until the worker finishes its cleanup
handle.join().unwrap();
println!("Graceful shutdown complete.");
}
The AtomicBool is the bridge between the signal loop and the worker. The signal loop runs in the main thread. It flips the flag and breaks out of the polling loop. The worker checks the flag on every iteration. When it sees true, it finishes its current task, runs cleanup, and exits. The main thread calls join() to wait for the worker to finish. Everything happens in a deterministic order.
The community convention here is explicit cloning. You will see Arc::clone(&shutdown) instead of shutdown.clone(). Both compile, but the explicit form signals to readers that you are cloning the wrapper, not the inner boolean. It prevents the mental model of a deep copy from creeping in.
Do not overcomplicate the shutdown broadcast. An atomic flag handles 90 percent of cases. Save channels for when you need to pass structured messages.
Pitfalls and compiler traps
The biggest trap is trying to bypass the iterator pattern and write a raw callback. If you pull in libc or nix and call signal() directly, you will eventually crash. The OS will invoke your callback while your program is holding a mutex or formatting a string. Rust's standard library functions are not async-signal-safe. Calling println! or Vec::push inside a raw signal handler triggers undefined behavior. The compiler cannot stop you if you use unsafe, but the runtime will punish you with deadlocks or memory corruption.
You will also run into trait bound issues if you try to move complex state into a signal context. The compiler will reject closures that capture non-Send or non-Sync types with E0277 (trait bound not satisfied). Signal handlers run on arbitrary threads in some configurations, and the borrow checker enforces strict threading guarantees. The iterator pattern avoids this entirely by keeping signal processing in the thread that explicitly calls forever().
Another common mistake is blocking the signal queue. If your loop body takes ten seconds to clean up, the next signal will sit in the queue until you finish. That is usually fine, but it means signals are not coalesced automatically. If you need to ignore duplicate signals during cleanup, track the state yourself.
Rust also ignores SIGPIPE by default. If you write to a closed socket, the OS sends SIGPIPE, which normally terminates the process. Rust's runtime catches it and converts it into a standard BrokenPipe error. This saves you from writing a custom handler for a very common network edge case.
Treat raw signal handlers as a last resort. The iterator pattern exists for a reason.
Choosing your signal strategy
Use signal-hook when you are writing a standard synchronous application and need reliable, safe signal polling. Use tokio::signal when your runtime is already async and you want to integrate signals into a select! macro. Use libc::signal or nix::sys::signal only when you are writing a library that must interoperate with C code that expects traditional callbacks, and you are prepared to audit every line for async-signal-safety. Reach for std::sync::atomic flags when you need to broadcast shutdown intent across threads. Reach for channels like crossbeam_channel when you need to pass structured shutdown messages instead of a simple boolean.
Keep your signal handling surface small. The less code you run near OS interrupts, the fewer ways you can break memory safety.