How to handle Ctrl+C gracefully
Short answer: Ctrl+C is SIGINT, and the kernel’s default response to SIGINT is to terminate the process on the spot — no unwinding, no Drop, no chance to say goodbye. To run cleanup, register a handler with the ctrlc crate: ctrlc::set_handler(...) takes a closure and runs it when the signal arrives. The handler runs on a normal thread the crate spawns, so it can use ordinary Rust — but it cannot safely stop the main thread mid-work. The usual pattern is a shared AtomicBool: the handler flips it, the main loop notices and shuts down in order.
Everything below was compiled and run with rustc 1.99.0, edition 2024, on macOS, and behaves the same on Linux.
Ctrl+C is SIGINT, and the default is death
Section titled “Ctrl+C is SIGINT, and the default is death”A program that has cleanup to do — a temp file to remove, here — looks safe with a Drop guard:
use std::time::Duration;
struct TempFile;
impl Drop for TempFile { fn drop(&mut self) { println!("Drop ran: removing app.tmp"); }}
fn main() { let _guard = TempFile; std::fs::write("app.tmp", "state").expect("failed to write app.tmp"); println!("working, press Ctrl+C to stop"); loop { std::thread::sleep(Duration::from_millis(200)); }}Run it and press Ctrl+C:
working, press Ctrl+C to stopThe process is gone. The output stops there — Drop ran: removing app.tmp never prints, and app.tmp is still on disk afterwards. Ctrl+C delivers signal 2 (SIGINT), and the default disposition for SIGINT is “terminate the process”: the kernel kills it directly, the same way the timeout article’s kill() did to its child. Nothing in the process gets a vote — no panic, no unwinding, so Drop never runs. Destructors are a guarantee about scope exits and errors, not about signals.
The guard variable is also why cleanup is hard to retrofit: the state lives on the main thread, and the main thread is the thing that just died.
Catch it with the ctrlc crate
Section titled “Catch it with the ctrlc crate”The standard library has no signal handling — registering a handler means unsafe libc calls with strict rules about what a signal handler may touch. The ctrlc crate wraps all of it behind one safe call:
[dependencies]ctrlc = "3.5"The same program, now with a handler. The kill -INT line exists so the example runs without a finger on the keyboard — from another terminal, kill -INT <pid> delivers exactly the signal Ctrl+C does:
use std::path::Path;use std::process::Command;use std::sync::atomic::{AtomicBool, Ordering};use std::time::Duration;
static RUNNING: AtomicBool = AtomicBool::new(true);
fn main() { std::fs::write("app.tmp", "state").expect("failed to write app.tmp"); println!("main on thread {:?}", std::thread::current().id());
ctrlc::set_handler(|| { println!("handler on thread {:?}", std::thread::current().id()); std::fs::remove_file("app.tmp").expect("failed to remove app.tmp"); println!("caught SIGINT, removed app.tmp"); RUNNING.store(false, Ordering::SeqCst); }) .expect("error setting Ctrl+C handler");
// simulate pressing Ctrl+C in another terminal: kill -INT sends the same signal let pid = std::process::id(); println!("running with pid {pid}"); Command::new("kill") .args(["-INT", &pid.to_string()]) .spawn() .expect("failed to spawn kill");
while RUNNING.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_millis(100)); } println!("app.tmp still exists: {}", Path::new("app.tmp").exists());}main on thread ThreadId(1)running with pid 50499handler on thread ThreadId(2)caught SIGINT, removed app.tmpapp.tmp still exists: falseCleanup ran this time — the pids and thread ids will differ on another run. Reading the output:
- The handler runs on its own thread.
ThreadId(2)is not the main thread. That is not an implementation detail to shrug at: a raw signal handler executes inside whatever the main thread was doing, with only async-signal-safe operations allowed. The crate gets around that by installing a tiny OS handler whose only job is to wake a thread namedctrl-cit spawned atset_handlertime; your closure runs there, whereprintln!,remove_file, and atomics are all ordinary code. - The handler does not exit the process. After the closure returns, the program is still running — that is why the block flips
RUNNINGinstead of just printing. The main loop polls the flag every 100 ms, seesfalse, and finishes in an orderly way: nothing mid-write gets abandoned, and the final check confirmsapp.tmpis gone beforemainreturns. - Doing work in the handler is fine, but it is a different thread’s work. The
remove_filein the closure is legitimate because the file is not otherwise in use. If the main thread is in the middle of writing a file, a handler that truncates the same file is a race the flag pattern avoids: the handler decides that it is time to stop, the main thread decides how.
One handler per call: calling set_handler again replaces the previous closure. If you would rather get an error than silently overwrite, try_set_handler does that.
Or exit right from the handler
Section titled “Or exit right from the handler”The flag loop is the shape to reach for when the main thread has state worth shutting down carefully. When “stop” just means “stop”, the handler can end the process itself:
ctrlc::set_handler(|| { // ... cleanup ... std::process::exit(0);}).expect("error setting Ctrl+C handler");One caveat carried over from the default behavior: std::process::exit does not run destructors either — cleanup that lives in a Drop guard will be skipped, so put it in the handler, not in the guard the handler’s exit outruns.
On Windows, Ctrl+C is a console event rather than a signal — there is no SIGINT to catch. The crate handles both platforms with the same set_handler call: the Unix side installs the signal handler, the Windows side registers a console control handler, and your closure runs either way.
Related
Section titled “Related”- How to run a command with a timeout — the other side of signals:
kill()sends SIGKILL, which a child cannot catch, and a killed child still needs reaping - How to pipe data between processes — the other blocking call with no escape hatch
- Processes, environment and signals — the rest of the chapter