Skip to content

Signal Handling

github-actions[bot] edited this page Jul 18, 2026 · 10 revisions

Signal Handling

This page explains how to handle Unix signals safely in Elio coroutines using signalfd.

The Problem with Traditional Signals

Traditional signal handlers have significant limitations in coroutine-based applications:

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

  2. Race conditions: Modifying scheduler state, coroutine handles, or shared data structures from a signal handler is inherently unsafe.

  3. Limited functionality: You can't use co_await, I/O operations, or most standard library functions in signal handlers.

The Solution: signalfd

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;

Quick Start

Basic Signal Handling

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;
}

Waiting for a Single Signal

// 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();

API Reference

signal_set

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
}

signal_fd

Async-friendly signalfd wrapper.

The object retains the io_context selected at construction. For scheduler use, construct and await it on the same worker; pending waits pin that worker's backend until completion or cleanup. Do not construct it against a standalone context and later await it in a scheduler coroutine. Standalone use requires the caller to serialize and poll that context.

// 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 their process-level disposition is no longer needed. 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.

signal_info

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.)
}

signal_block_guard

RAII guard for temporary signal blocking.

{
    signal_block_guard guard(sigs);
    // Signals are blocked here
} // Signals restored automatically

Utility Functions

// 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)

Best Practices

1. Block Signals Early

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();
    
    // ...
}

2. Handle Multiple Signal Types

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;
}

3. Graceful Shutdown Pattern

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;
}

4. Signal Information Logging

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);
}

Complete Example

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

Comparison with Traditional Approach

Traditional (Unsafe)

// 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
}

With signalfd (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;
}

See Also

  • Core-Concepts - Coroutines and scheduler basics
  • Examples - More code examples
  • man signalfd - Linux signalfd documentation
  • man sigprocmask - Signal mask manipulation

Clone this wiki locally