After my last post on memory in C, the next topic I wanted to dive into was signal handling.
Working at a higher level in Node.js, the Process
object provides an interface for cross-platform signal handling. This deceptively simple API hides a lot of complexity:
process.on('SIGINT', () => {
console.log('Received Ctrl-C!');
process.exit(0);
});
In Rust, simple signal handling is also possible with the CtrlC crate:
fn main() -> Result<(), std::io::Error> {
ctrlc::set_handler(|| {
println!("Received Ctrl-C!");
std::process::exit(0);
})?;
}
Under the hood, CtrlC handles raw signal handling in C and uses a clever pipe-based design to safely communicate between the signal handler and the main thread. This is a crucial detail because signal handlers run in a special kernel context where most operations are unsafe
- they can interrupt any operation, including memory management.
This post will explore CtrlC’s source to build understanding of how signal handling works at a lower level. Note that we’ll focus on traditional synchronous Rust. If you’re using Tokio, it has its own signal handling mechanism that integrates with its event loop.
Note
CtrlC does support Windows via windows-rs
. However, I’ll focus on Unix signal handling via nix
in this post.
Understanding Signal Handlers
At the hardware level, interrupts allow a device to notify the CPU that they need attention. The CPU suspends its current execution and runs a special routine called an interrupt handler or interrupt service routine (ISR) in kernel space.
Note
Kernel space is a protected area of memory where the core of the operating system resides. It has direct access to hardware resources. User space is where applications run with limited privileges. They must request resources from the kernel.
Signals are a higher-level mechanism that Unix-like systems use for inter-process communication (IPC). When a process receives a signal, it can run a user space signal handler to respond to that event. Common signals include:
SIGINT
(2): Interrupt from Ctrl+CSIGTERM
(15): Termination requestSIGHUP
(1): Hangup from terminal disconnectSIGKILL
(9): Kill signal (cannot be handled)
The number in parentheses is the signal number. In practice, you can use the signal name or number interchangeably when using a command like kill.
CtrlC
The CtrlC crate is designed around pipes to safely communicate between the signal handler and the main thread. This design is necessary because many operations are not async-signal-safe. According to the man page:
To avoid problems with unsafe functions, there are two possible choices: (a) ensure that (1) the signal handler calls only async-signal-safe functions, and (2) the signal handler itself is reentrant with respect to global variables in the main program; or (b) block signal delivery in the main program when calling functions that are unsafe or operating on global data that is also accessed by the signal handler. Generally, the second choice is difficult in programs of any complexity, so the first choice is taken.
The approach taken by CtrlC aligns with the first choice.
os_handler
A pipe is a unidirectional data channel with two file descriptors for reading and writing.
While pipes are commonly used for IPC, CtrlC uses one internally to safely bridge between the signal handler context and the main thread.
The os_handler
function is the signal handler that writes a single byte to the pipe.
use std::os::fd::BorrowedFd;
use std::os::unix::io::RawFd;
// Initialize with dummy values
static mut PIPE: (RawFd, RawFd) = (-1, -1);
extern "C" fn os_handler(_: nix::libc::c_int) {
unsafe {
// Borrow the write end of the pipe (the read end is 0)
let fd = BorrowedFd::borrow_raw(PIPE.1);
// Write a single byte to the pipe
let _ = nix::unistd::write(fd, &[0u8]);
}
}
init_os_handler
To initialize os_handler
, the init_os_handler
function is called.
It first creates a pipe with the O_CLOEXEC
flag to ensure the file descriptors are closed when a new process is spawned. For example, when using fork to create a child process.
Then, it sets the write end of the pipe (PIPE.1
) to non-blocking mode using fcntl
with the O_NONBLOCK
flag. Finally, it registers os_handler
for SIGINT
using signal
from the nix crate.
fn pipe2(flags: nix::fcntl::OFlag) -> nix::Result<(RawFd, RawFd)> {
let pipe = nix::unistd::pipe2(flags)?;
Ok((pipe.0.into_raw_fd(), pipe.1.into_raw_fd()))
}
pub unsafe fn init_os_handler(overwrite: bool) -> Result<(), nix::Error> {
// The mutable PIPE defined further up
PIPE = pipe2(nix::fcntl::OFlag::O_CLOEXEC)?;
// Make write end non-blocking
nix::fcntl::fcntl(
PIPE.1,
nix::fcntl::FcntlArg::F_SETFL(
nix::fcntl::OFlag::O_NONBLOCK,
),
)?;
// Register signal handler
let handler = nix::sys::signal::SigHandler::Handler(os_handler);
let new_action = nix::sys::signal::SigAction::new(
handler,
nix::sys::signal::SaFlags::SA_RESTART,
nix::sys::signal::SigSet::empty(),
);
}
block_ctrl_c
The block_ctrl_c
function reads from the pipe in a loop waiting for a signal:
pub unsafe fn block_ctrl_c() -> Result<(), std::io::Error> {
let mut buf = [0u8];
loop {
match nix::unistd::read(PIPE.0, &mut buf[..]) {
Ok(1) => break, // successfully read the signal byte
Err(nix::Error::Sys(nix::errno::Errno::EINTR)) => continue, // read was interrupted, try again
Err(e) => return Err(e.into()), // some other error
}
}
Ok(())
}
set_handler
The set_handler
function is the public API we called at the beginning. It spawns a dedicated signal handling thread that runs the block_ctrl_c
function in a loop. When the signal is received, the provided closure (user_handler
) is called.
pub fn set_handler<F>(mut user_handler: F) -> Result<(), std::io::Error>
where F: FnMut() -> () + 'static + Send {
thread::Builder::new()
.name("ctrl-c".into())
.spawn(move || loop {
unsafe {
block_ctrl_c()?;
}
user_handler();
})?;
Ok(())
}
The loop is so we can handle multiple signals, although in our example, the closure calls exit(0)
to terminate the process.
EINTR Handling
On StackOverflow, there are over 2,000 questions tagged with EINTR
. When googling “EINTR”, all of the results are to various other StackExchange sites.
The EINTR
handling in block_ctrl_c
is a common pattern in Unix programming. When a syscall like read is interrupted by a signal, the kernel returns EINTR
(interrupted system call).
In C, handling EINTR
is notoriously tricky:
// BAD: might return early if interrupted
ssize_t n = read(fd, buf, size);
// GOOD: retry until successful
ssize_t n;
do {
n = read(fd, buf, size);
} while (n == -1 && errno == EINTR);
The -1
return value from read
indicates an error, and errno
is set to EINTR
. The correct way to handle this is to retry the read
syscall until it completes successfully. That is essentially what the loop in block_ctrl_c
is doing.
Rust Review
The CtrlC code is small yet mighty. There’s a lot of advanced Rust features and systems programming concepts packed into a few hundred lines of code.
File Descriptors
use std::os::fd::BorrowedFd;
use std::os::unix::io::RawFd;
static mut PIPE: (RawFd, RawFd) = (-1, -1);
PIPE
is a global static variable. 'static
is a special lifetime that lasts for the duration of the program. mut
makes it mutable, but that also it unsafe
, since multiple threads could access it concurrently. This is why all operations on PIPE
occur within unsafe
blocks.
RawFd
is a type alias for a platform-specific integer (i32
) representing a file descriptor. Our pipe has two file descriptors - one for reading and one for writing. They are both initialized to -1
to indicate that they’re not yet set.
The BorrowedFd
struct is a wrapper around RawFd
that implements Drop
to close the file descriptor when it goes out of scope. The borrow_raw
method takes a RawFd
and returns a BorrowedFd
, enabling RAII.
Note
RAII is a pattern where resource management is tied to object lifetime. When an object is created, it acquires resources. When it’s destroyed, it releases them.
FFI and Signal Handlers
extern "C" fn os_handler(_: nix::libc::c_int) {
unsafe {
// ...
}
}
The extern
keyword in Rust is used for foreign function interfaces (FFI) in two distinct ways: blocks and functions.
External blocks declare foreign functions Rust can call:
extern "C" {
// Just a signature
fn foreign_function(x: i32) -> i32;
}
External functions expose Rust functions in foreign code:
extern "C" fn rust_function(x: i32) -> i32 {
// Rust implementation
x + 1
}
"C"
is the application binary interface (ABI) and what you’ll see most often.
The unsafe
block is necessary because we are accessing global state (PIPE
) and calling write.
The c_int
type from nix is a platform-specific integer type that matches the C int
type. This is necessary because the signal
function expects a C int
as the signal number.
Unix System Calls via nix
PIPE = pipe2(nix::fcntl::OFlag::O_CLOEXEC)?;
nix::fcntl::fcntl(
PIPE.1,
nix::fcntl::FcntlArg::F_SETFL(
nix::fcntl::OFlag::O_NONBLOCK,
),
)?;
let handler = nix::sys::signal::SigHandler::Handler(os_handler);
let new_action = nix::sys::signal::SigAction::new(
handler,
nix::sys::signal::SaFlags::SA_RESTART,
nix::sys::signal::SigSet::empty(),
);
The nix crate provides Rust wrappers around Unix system calls. fcntl
is a wrapper around the fcntl syscall, which can perform various operations on file descriptors. The F_SETFL
operation sets the file descriptor flags.
The O_NONBLOCK
flag sets the file descriptor to non-blocking mode, which is necessary for the signal handler to write to the pipe without blocking. The O_CLOEXEC
flag ensures the file descriptors are closed when a new process is spawned.
To register the signal handler, we create a SigHandler
with os_handler
and a SigAction
with the handler, flags, and an empty SigSet
. The SA_RESTART
flag tells the kernel to automatically restart interrupted system calls.
Error Handling
match nix::unistd::read(PIPE.0, &mut buf[..]) {
Ok(1) => break,
Err(nix::Error::Sys(nix::errno::Errno::EINTR)) => continue,
Err(e) => return Err(e.into()),
}
The read
function returns a Result
enum with the number of bytes read on success or an Err
with a nix::Error
.
The branches are known as arms in a match
expression. The Ok
variant is pattern matched to check if exactly 1 byte was read and breaks the loop if true
.
The Err
variant is further pattern matched to check if the error is EINTR
. If it is, the loop continues to retry the read. If it’s another error, it is converted to a io::Error
and returned.
Threads
pub fn set_handler<F>(mut user_handler: F) -> Result<(), std::io::Error>
where F: FnMut() -> () + 'static + Send {
thread::Builder::new()
.name("ctrl-c".into())
.spawn(move || loop {
// ...
})?;
The F
type parameter is a generic type representing a closure that implements the FnMut
trait. This allows the user to pass a closure that takes no arguments and returns ()
.
Send
is a marker trait that indicates the closure can be safely transferred between threads. This is necessary because the closure is moved to the thread.
The thread::Builder
struct is used to configure the thread before spawning it. In this case, we set the thread name to “ctrl-c”.
The spawn
method creates a new thread that runs the provided closure in a loop. The move
keyword moves the closure into the thread, transferring ownership.
Conclusion
Signal handling is a fundamental aspect of systems programming. While the CtrlC crate makes it easy to handle signals in Rust applications, understanding its internals reveals important lessons about safe signal handling, FFI, and systems programming patterns.
Read The Dark Arts of Unsafe Rust for a deep dive into unsafe
Rust. Also, Shopify Engineering wrote about porting a Ruby compiler to Rust and their experience of having to use unsafe
in many places.
If you’re interested in exploring more Rust projects like this, you can follow my work on GitHub and Hugging Face. Thanks for reading!