Repository navigation
Signal Handling
This page explains how to handle Unix signals safely in Elio coroutines using signalfd.
Traditional signal handlers have significant limitations in coroutine-based applications:
-
Async-signal-safety: Signal handlers can interrupt at any point, even in the middle of coroutine scheduling operations. Only a limited set of functions are safe to call in signal handlers.
-
Race conditions: Modifying scheduler state, coroutine handles, or shared data structures from a signal handler is inherently unsafe.
-
Limited functionality: You can't use
co_await, I/O operations, or most standard library functions in signal handlers.
Linux's signalfd(2) converts signals into file descriptor events. By blocking signals with sigprocmask and reading them via signalfd, signals become normal I/O events that can be handled in a regular coroutine context.
#include <elio/signal/signalfd.hpp>
using namespace elio::signal;elio::run() and therefore ELIO_ASYNC_MAIN install SIG_IGN for SIGPIPE
on first use of the runtime entry path. elio::serve() and elio::serve_all()
perform an independent one-time installation so that they provide the same
protection when used with a custom scheduler. This process-wide fallback lets
socket and TLS writes report a peer close as EPIPE on paths where per-call
SIGPIPE suppression is unavailable, rather than terminating the process.
These entry points change the process-wide signal disposition with sigaction;
they do not change a per-thread signal mask, and Elio neither saves nor restores
the previous disposition. An embedding application that needs another
disposition later must snapshot it before its first Elio runtime/server entry
and restore it only after all Elio runtime and server use has ended. The runtime
and server paths each have an independent once_flag. A later call on an
already-fired path will not reinstall the ignore disposition, but an
as-yet-unused path (e.g. calling serve() for the first time after run()
has exited) will still reinstall SIG_IGN. Restoring a custom disposition is
only safe after all Elio runtime and server entry-point use has ended.
This is separate from the per-thread masks described below. signal_set and
signal_fd manage masks used by signalfd; those masks do not preserve or
restore the process-wide SIGPIPE disposition.
coro::task<void> handle_shutdown() {
// Create signal set with signals to handle
signal_set sigs{SIGINT, SIGTERM};
// Create signalfd - automatically blocks the signals
signal_fd sigfd(sigs);
// Wait for signal in coroutine context
while (running) {
auto info = co_await sigfd.wait();
if (info) {
if (info->signo == SIGINT || info->signo == SIGTERM) {
ELIO_LOG_INFO("Shutdown requested via {}", info->full_name());
running = false;
}
}
}
co_return;
}// Simple one-shot signal wait
auto info = co_await wait_signal(SIGTERM);
ELIO_LOG_INFO("Received {}", info.full_name());
// wait_signal() acquires the block; release it after all users are finished
signal_set{SIGTERM}.unblock();Manages a set of signals using sigset_t.
// Create empty set
signal_set sigs;
// Add signals (chainable)
sigs.add(SIGINT).add(SIGTERM).add(SIGUSR1);
// Create with initializer list
signal_set sigs{SIGINT, SIGTERM, SIGUSR1};
// Remove a signal
sigs.remove(SIGUSR1);
// Check membership
if (sigs.contains(SIGINT)) { /* ... */ }
// Clear or fill
sigs.clear(); // Empty set
sigs.fill(); // All signals
// Block/unblock for current thread
sigset_t old_mask;
sigs.block(&old_mask); // Block these signals
sigs.unblock(); // Unblock these signals
sigs.set_mask(&old_mask); // Replace signal mask
// Use the *_error variants when callers need the direct pthread error number.
int rc = sigs.block_error(&old_mask);
if (rc != 0) {
// rc is the pthread_sigmask() error code
}Async-friendly signalfd wrapper.
The object retains the io_context selected at construction, but wait()
resolves its submission context per call: on a scheduler worker the pending
read is submitted to the CURRENT worker's io_context (the awaiting
coroutine's worker), so a descriptor constructed on one worker may be awaited
on another; off-worker the construction-time context is used. A pending wait
pins the resolving worker's backend until completion or cleanup. Standalone
use requires the caller to serialize and poll that context. Only one wait()
may be pending on a signal_fd at a time (single-waiter rule); concurrent
waits from two workers are not supported and can silently lose one waiter.
// Create with automatic blocking
signal_fd sigfd(sigs);
// Don't auto-block (caller manages signal mask)
signal_fd sigfd(sigs, io::current_io_context(), false);
// Check validity
if (sigfd.valid()) { /* ... */ }
if (sigfd) { /* ... */ } // bool conversion
// Get file descriptor
int fd = sigfd.fd();
// Async wait
auto async_info = co_await sigfd.wait();
// Sync try-read (non-blocking)
auto ready_info = sigfd.try_read();
// Update signal set
signal_set new_sigs{SIGUSR2};
sigfd.update(new_sigs);
// Explicitly release signals after every user that needs them blocked is done
signal_set released{SIGUSR1, SIGUSR2};
released.unblock();
// Close explicitly
sigfd.close();With automatic blocking enabled, update() blocks the new signal set but does
not unblock signals removed from the descriptor. This preserves caller-owned
masks and avoids exposing a removed pending signal before the descriptor update
commits. Explicitly unblock signals when the calling thread no longer needs
them blocked. If the descriptor update fails, update() restores the calling
thread's prior mask. Passing false as the second argument leaves the thread
mask unchanged. Closing, moving, or destroying a signal_fd never changes the
thread mask.
signal_fd::restore_mask() is deprecated and is now a no-op that returns
false. A whole-thread snapshot cannot be restored safely when other live
signal_fd instances or later caller operations have added blocks. Keep the
signals blocked for as long as any descriptor or worker depends on them, then
release them explicitly with signal_set::unblock() from the owning thread.
The wait_signal() convenience helpers follow the same acquire-only rule.
Information about a received signal.
auto info = co_await sigfd.wait();
if (info) {
int signo = info->signo; // Signal number
const char* name = info->name(); // "INT", "TERM", etc.
std::string full = info->full_name(); // "SIGINT", "SIGTERM"
uint32_t pid = info->pid; // Sender PID
uint32_t uid = info->uid; // Sender UID
int32_t code = info->code; // Signal code (SI_USER, SI_KERNEL, etc.)
}RAII guard for temporary signal blocking.
{
signal_block_guard guard(sigs);
// Signals are blocked here
} // Signals restored automatically// Get signal name from number
const char* name = signal_name(SIGINT); // "INT"
// Get signal number from name
int signo = signal_number("SIGINT"); // 2
int signo = signal_number("INT"); // 2 (prefix optional)Block signals before creating any threads to ensure all threads inherit the blocked mask:
int main() {
// Block signals FIRST, before anything else
signal_set sigs{SIGINT, SIGTERM, SIGUSR1};
sigs.block_all_threads();
// Now create scheduler and start threads
scheduler sched(4);
sched.start();
// ...
}coro::task<void> signal_router() {
signal_set sigs{SIGINT, SIGTERM, SIGUSR1, SIGUSR2, SIGHUP};
signal_fd sigfd(sigs);
while (running) {
auto info = co_await sigfd.wait();
if (!info) continue;
switch (info->signo) {
case SIGINT:
case SIGTERM:
initiate_shutdown();
break;
case SIGHUP:
reload_configuration();
break;
case SIGUSR1:
print_status();
break;
case SIGUSR2:
rotate_logs();
break;
}
}
co_return;
}For simple server applications, use elio::serve() with shutdown signals
blocked before the scheduler starts:
coro::task<int> async_main(int argc, char* argv[]) {
http::router r;
r.get("/", handler);
http::server srv(r);
// serve() waits for masked SIGINT/SIGTERM and calls srv.stop()
co_await elio::serve(srv, [&]() { return srv.listen(addr); });
co_return 0;
}
int main(int argc, char* argv[]) {
elio::signal::signal_set shutdown_signals(elio::default_shutdown_signals);
shutdown_signals.block_all_threads();
return elio::run(async_main, argc, argv);
}For more complex scenarios with custom shutdown logic:
std::atomic<bool> g_running{true};
coro::task<void> shutdown_handler() {
signal_set sigs{SIGINT, SIGTERM};
signal_fd sigfd(sigs);
auto info = co_await sigfd.wait();
if (!info) {
co_return;
}
ELIO_LOG_INFO("Shutdown signal received: {}", info->full_name());
g_running = false;
co_return;
}
coro::task<void> worker() {
while (g_running) {
// Do work
co_await process_request();
}
// Cleanup before exit
co_return;
}void log_signal(const signal_info& info) {
ELIO_LOG_INFO("Signal: {} ({})", info.full_name(), info.signo);
ELIO_LOG_INFO(" Sender: PID={}, UID={}", info.pid, info.uid);
ELIO_LOG_INFO(" Code: {}", info.code);
}See examples/signal_handling.cpp for a complete example showing:
- Signal blocking before thread creation
- Signal handler coroutine
- Graceful shutdown coordination
- Status requests via SIGUSR1
Build and run:
cmake --build build --target signal_handling
./build/examples/signal_handling
# In another terminal:
kill -USR1 <pid> # Print status
kill -INT <pid> # or Ctrl+C for graceful shutdown// DON'T do this in coroutine applications!
void signal_handler(int signo) {
// Can't use co_await here
// Can't safely modify scheduler state
// Limited to async-signal-safe functions
g_shutdown_flag = true; // Only atomic operations are safe
}coro::task<void> signal_handler() {
signal_fd sigfd(signal_set{SIGINT, SIGTERM});
auto info = co_await sigfd.wait(); // Full coroutine support!
if (!info) {
co_return;
}
// Can use any function here
co_await cleanup_connections();
co_await flush_caches();
ELIO_LOG_INFO("Clean shutdown complete");
co_return;
}- Core-Concepts - Coroutines and scheduler basics
- Examples - More code examples
-
man signalfd- Linux signalfd documentation -
man sigprocmask- Signal mask manipulation