Layer 0 Spec — signal Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::signal module exposes the Unix signal management primitives. In accordance with ADR-020, Air’s approach is:
-
signalfd by default for all deferrable signals. The mechanism turns signals into events readable on an FD, which integrates naturally with io_uring and with the unified event loop.
-
Restricted sigaction in a
synchronous_handlersubmodule for the four synchronous non-deferrable signals (SIGSEGV,SIGBUS,SIGFPE,SIGILL).
This discipline structurally eliminates an entire class of bugs (non-async-signal-safe signal handlers), while preserving the ability to handle the cases where sigaction is unavoidable.
Cross-cutting characteristics of the family.
-
No generic handler exposed. The API does not allow installing a sigaction handler on an arbitrary signal. The path is barred by construction.
-
All created FDs are CLOEXEC by default. Consistent with the universal discipline of layer 0.
-
Thread-safe management. Signal mask operations are thread-safe (each thread has its own mask). Handler operations are per process.
-
Natural io_uring integration. A signalfd FD can be read via io_uring, which allows waiting for signals within the same mechanism as the other asynchronous events.
Cross-cutting types used.
Signal: typed representation of a signal.SignalMask: set of signals as a bitmask.SignalFd: signalfd FD.SignalFdInfo: structure returned by reading a signalfd.FatalSignal: restricted enum of the 4 synchronous signals acceptable for sigaction.SignalAction: type for handlers (used only in the synchronous_handler submodule).
Subsection 1: signalfd and signal masks
signalfd_create
Signature.
#![allow(unused)]
fn main() {
pub fn signalfd_create(
mask: &SignalMask,
flags: SignalFdFlags,
) -> Result<SignalFd, Errno>;
pub struct SignalFd { /* opaque, owns internal OwnedFd */ }
impl SignalFd {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
pub fn read(&self) -> Result<SignalFdInfo, Errno>;
pub fn update_mask(&mut self, mask: &SignalMask) -> Result<(), Errno>;
}
bitflags! {
pub struct SignalFdFlags: i32 {
const NONBLOCK = 0x800;
const CLOEXEC = 0x80000;
}
}
#[repr(C)]
pub struct SignalFdInfo {
pub signal: Signal,
pub errno: i32,
pub code: i32,
pub pid: Pid,
pub uid: u32,
pub fd: i32,
pub timer_id: u32,
pub band: u32,
pub overrun: u32,
pub trap_no: u32,
pub status: i32,
pub int: i32,
pub ptr: u64,
pub utime: u64,
pub stime: u64,
pub addr: u64,
}
}
Underlying syscall. signalfd4 (x86_64 #289, ARM64 #74). Man page signalfd(2). Available since Linux 2.6.27.
Preconditions.
mask indicates the signals that this signalfd must catch. Signals not listed in the mask continue to be delivered according to their default disposition.
SignalFdFlags::CLOEXEC is always enabled by default by the wrapper.
Behavior.
Creates an FD that turns the signals in the mask into readable events. When one of the listed signals is delivered to the thread, instead of invoking a handler, it becomes “readable” on the signalfd FD.
Important: the mask alone is not enough.
Creating a signalfd for a signal S is not enough to prevent the normal delivery of S (handler or default behavior). You also need to block S in the thread’s signal mask via block_signals. The correct pattern:
#![allow(unused)]
fn main() {
let mask = SignalMask::from_signals(&[Signal::SIGTERM, Signal::SIGINT]);
block_signals(&mask)?;
let sfd = signalfd_create(&mask, SignalFdFlags::empty())?;
// now SIGTERM and SIGINT will arrive only on sfd
}
Air provides a helper that combines the two steps:
#![allow(unused)]
fn main() {
pub fn signalfd_create_blocking(
mask: &SignalMask,
flags: SignalFdFlags,
) -> Result<SignalFd, Errno>;
}
Errors.
EINVAL: invalid mask or invalid flags.EMFILE,ENFILE: FD limits reached.ENODEV,ENOMEM: insufficient kernel resources.
Performance.
Creation: ~5-10 µs. Read via read(): ~1-2 µs if a signal is immediately available.
block_signals and unblock_signals
Signature.
#![allow(unused)]
fn main() {
pub fn block_signals(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn unblock_signals(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn set_signal_mask(mask: &SignalMask) -> Result<SignalMask, Errno>;
pub fn current_signal_mask() -> Result<SignalMask, Errno>;
}
Underlying syscall. rt_sigprocmask (x86_64 #14, ARM64 #135).
Behavior.
block_signals adds the signals from the mask to the thread’s current mask. Returns the old mask (before modification).
unblock_signals removes the signals from the mask.
set_signal_mask replaces the mask entirely.
current_signal_mask reads the current mask without modification.
Special cases.
SIGKILL and SIGSTOP cannot be blocked. An attempt to include them in the mask is silently ignored by the kernel.
The mask is inherited across fork. execve resets the mask.
Performance. ~100-200 ns. Very fast.
wait_for_signal helper
Signature.
#![allow(unused)]
fn main() {
pub fn wait_for_signal(
mask: &SignalMask,
timeout: Option<Duration>,
) -> Result<SignalFdInfo, Errno>;
}
Behavior.
Helper that creates a temporary signalfd, waits for a signal from the mask to arrive, and returns the info. Convenient for simple patterns.
Subsection 2: Sending signals
kill and tgkill
#![allow(unused)]
fn main() {
pub fn kill(pid: Pid, signal: Option<Signal>) -> Result<(), Errno>;
pub fn tgkill(tgid: Pid, tid: Tid, signal: Option<Signal>) -> Result<(), Errno>;
}
Underlying syscalls. kill (x86_64 #62, ARM64 #129), tgkill (x86_64 #234, ARM64 #131).
Preconditions.
Option<Signal> enables the “signal 0” pattern (existence test without actually sending) via None. Consistent with convention 1 of ADR-021.
Behavior.
kill(pid, sig) sends a signal to the process identified by pid.
tgkill(tgid, tid, sig) sends a signal to a specific thread of a process.
Air recommendation.
For child processes, prefer pidfd_send_signal (process family) over kill: no race on the recycled PID.
Errors.
EINVAL: invalid signal.EPERM: insufficient permissions.ESRCH: nonexistent process.
Performance. ~1-2 µs.
rt_sigqueueinfo
#![allow(unused)]
fn main() {
pub fn rt_sigqueueinfo(
pid: Pid,
signal: Signal,
info: SignalQueueInfo,
) -> Result<(), Errno>;
pub struct SignalQueueInfo {
pub code: i32,
pub value: SignalValue,
}
pub enum SignalValue {
Integer(i32),
Pointer(u64),
}
}
Sending a signal with an attached “value”. Rare use case.
Subsection 3: Restricted sigaction
In accordance with ADR-020, the air-sys-syscall::signal::synchronous_handler submodule exposes sigaction only for the 4 fatal synchronous signals.
FatalSignal type
#![allow(unused)]
fn main() {
pub mod synchronous_handler {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FatalSignal {
Segv,
Bus,
Fpe,
Ill,
}
impl FatalSignal {
pub const fn as_signal(self) -> Signal;
}
}
}
The enum is restricted to these 4 signals. There is no way to pass another signal to this API.
install_fatal_handler
#![allow(unused)]
fn main() {
pub mod synchronous_handler {
pub unsafe fn install_fatal_handler(
signal: FatalSignal,
handler: FatalHandler,
) -> Result<PreviousHandler, Errno>;
pub unsafe fn restore_handler(
signal: FatalSignal,
previous: PreviousHandler,
) -> Result<(), Errno>;
pub type FatalHandler = unsafe extern "C" fn(
signum: c_int,
info: *mut SignalInfo,
context: *mut c_void,
);
pub struct PreviousHandler {
// Opaque
}
#[repr(C)]
pub struct SignalInfo {
pub signal: c_int,
pub errno: c_int,
pub code: c_int,
pub addr: u64,
// ...
}
}
}
Preconditions (Safety).
The API is unsafe. The # Safety documentation requires the handler to be async-signal-safe.
Legitimate use cases.
- Crash reporter: captures the process state at the moment of a crash, writes a mini-dump, then terminates.
- Stack guards: stack overflow detection via a signal handler on an alternate stack.
Tricky tests.
The tests run in isolated subprocesses via a subprocess harness.
signal family summary
Exposed functions:
| Category | Main functions |
|---|---|
| signalfd | signalfd_create, signalfd_create_blocking, SignalFd::read, SignalFd::update_mask |
| Masks | block_signals, unblock_signals, set_signal_mask, current_signal_mask |
| Helpers | wait_for_signal |
| Sending | kill, tgkill, rt_sigqueueinfo |
| Handlers (restricted) | install_fatal_handler, restore_handler |
Total: ~12 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
signal(BSD-style, deprecated).tkill(deprecated, replaced by tgkill).pause,alarm: legacy, replaced by signalfd + timerfd.sigsuspend,rt_sigsuspend: obsolete pattern, replaced by signalfd.sigaltstack: to be added later if stack guards are needed.
Types added to air-sys-types
SignalFdInfoSignalFdFlagsSignalQueueInfo,SignalValueFatalSignal(synchronous_handler submodule)SignalInfo(synchronous_handler submodule)PreviousHandler(synchronous_handler submodule)
That is ~6 additional types.
Substantive decisions that emerged in the signal family
1. signalfd_create_blocking as the canonical helper.
The “create signalfd + block signals” pattern is so universal that a combined helper simplifies usage and avoids the trap of forgetting a step.
2. kill via Option<Signal> for signal 0.
Application of convention 1 of ADR-021.
3. install_fatal_handler returns an opaque PreviousHandler.
Hides the kernel details of the sigaction struct.
4. No wrapper for sigaltstack.
Specialized case, to be added later without breaking the API.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::signal module (layer 0).