Skip to content
Select theme

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:

Run it and press Ctrl+C:

working, press Ctrl+C to stop

The 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.

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:

main on thread ThreadId(1)
running with pid 50499
handler on thread ThreadId(2)
caught SIGINT, removed app.tmp
app.tmp still exists: false

Cleanup ran this time — the pids and thread ids will differ on another run. Reading the output:

  1. 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 named ctrl-c it spawned at set_handler time; your closure runs there, where println!, remove_file, and atomics are all ordinary code.
  2. The handler does not exit the process. After the closure returns, the program is still running — that is why the block flips RUNNING instead of just printing. The main loop polls the flag every 100 ms, sees false, and finishes in an orderly way: nothing mid-write gets abandoned, and the final check confirms app.tmp is gone before main returns.
  3. Doing work in the handler is fine, but it is a different thread’s work. The remove_file in 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.

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.