Handle SIGTERM/SIGINT with a sigaction self-pipe instead of a signalfd, and clear the signal mask of spawned children
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

The signalfd-based source (calloop's `signals` feature) needs SIGTERM and SIGINT blocked in every thread of the process, since the kernel hands a process-directed signal to any thread that does not block it. calloop only blocks them in the thread that creates the source, so the runtime depended on `ltk::run` being entered before the app spawned any thread — a constraint apps do not honour: crustace-notifier builds its tokio runtime and its D-Bus thread first, and in that configuration a SIGTERM lands on a tokio worker and kills the process outright, skipping the clean exit (`save_state`, `mark_clean_exit`) entirely.
The blocked mask had a second consequence: it is inherited across fork and exec, and Rust's std deliberately does not reset it in the child. Every process an ltk app spawns from a thread created after `ltk::run` therefore started with SIGTERM blocked. For the `gsettings monitor` child of the text-scale watcher this meant it never died on systemd's stop, so the unit's cgroup stayed populated until `TimeoutStopSec` (90 s) expired and systemd resorted to SIGKILL — visible as a stop job holding up every session shutdown.
`install_signal_source` now installs a `sigaction` handler for SIGTERM and SIGINT that writes the signal number to a non-blocking `O_CLOEXEC` pipe (an atomic load and a `write`, errno preserved); the read end is a calloop `Generic` source that drains the pipe and sets `exit_requested`. A handler is process-wide, so the clean exit works no matter which thread receives the signal or when it was created, and no thread blocks anything, so children inherit a clean mask. `SA_RESTART` keeps the handler from injecting spurious EINTRs into the app's own threads; the pipe wakes the event loop regardless.
`text_scale` additionally clears the child's mask in `pre_exec` before running `gsettings`, as a guard against a mask already dirty when the process was launched. The `save_state` docs drop the caveat about threads started before `run`, the ordering comment at the call site goes with the constraint it described, and the `signals` feature is dropped from calloop. `libc` becomes a direct dependency for `sigaction`, `pipe2` and `sigprocmask`.
Tests: `child_starts_with_clear_signal_mask` blocks SIGTERM in the test thread, spawns a child through the helper and checks its `SigBlk` in `/proc`, with the inverse control asserting that std really does inherit the mask (the reason the helper exists); `handler_writes_signal_to_pipe` exercises the handler directly and through a real `sigaction` + `raise`, then restores `SIG_DFL`.
This commit is contained in:
2026-08-25 09:47:08 +02:00
parent 902e23e7f2
commit a2752a5bd3
5 changed files with 192 additions and 23 deletions
+124 -8
View File
@@ -4,6 +4,8 @@
//! `xdg-session-management-v1` client glue plus the runtime hooks that
//! persist `App::save_state` and turn signals into a clean exit.
use std::os::fd::{ AsRawFd, FromRawFd, OwnedFd };
use std::sync::atomic::{ AtomicI32, Ordering };
use std::time::Duration;
use calloop::timer::{ TimeoutAction, Timer };
@@ -144,18 +146,81 @@ impl SessionRuntime
}
}
static SIGNAL_PIPE_WR: AtomicI32 = AtomicI32::new( -1 );
/// Async-signal-safe: an atomic load and a `write`, errno preserved.
extern "C" fn on_termination_signal( sig: libc::c_int )
{
unsafe
{
let errno = *libc::__errno_location();
let fd = SIGNAL_PIPE_WR.load( Ordering::Acquire );
if fd >= 0
{
let byte = sig as u8;
let _ = libc::write( fd, ( &byte as *const u8 ).cast(), 1 );
}
*libc::__errno_location() = errno;
}
}
/// Self-pipe rather than a signalfd: a `sigaction` handler is process-
/// wide, so the clean exit works no matter which thread the kernel
/// picks, without blocking the signal in every thread — a mask that
/// children would inherit across exec.
pub( crate ) fn install_signal_source<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
{
use calloop::signals::{ Signal, Signals };
let signals = Signals::new( &[ Signal::SIGTERM, Signal::SIGINT ] )
.map_err( |e| RunError::EventLoop( format!( "Signals::new: {e}" ) ) )?;
handle
.insert_source( signals, |event, _, data: &mut AppData<A>|
use calloop::generic::Generic;
use calloop::{ Interest, Mode, PostAction };
let mut fds = [ -1i32; 2 ];
if unsafe { libc::pipe2( fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK ) } != 0
{
return Err( RunError::EventLoop( format!( "signal pipe2: {}", std::io::Error::last_os_error() ) ) );
}
SIGNAL_PIPE_WR.store( fds[ 1 ], Ordering::Release );
unsafe
{
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_termination_signal as usize;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset( &mut action.sa_mask );
for sig in [ libc::SIGTERM, libc::SIGINT ]
{
eprintln!( "ltk: {:?} received, exiting cleanly", event.signal() );
data.exit_requested = true;
if libc::sigaction( sig, &action, std::ptr::null_mut() ) != 0
{
return Err( RunError::EventLoop( format!( "sigaction: {}", std::io::Error::last_os_error() ) ) );
}
}
}
let read_end = unsafe { OwnedFd::from_raw_fd( fds[ 0 ] ) };
handle
.insert_source( Generic::new( read_end, Interest::READ, Mode::Level ), |_, fd, data: &mut AppData<A>|
{
let mut sig = None;
let mut buf = [ 0u8; 16 ];
loop
{
let n = unsafe { libc::read( fd.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len() ) };
if n <= 0 { break; }
sig = Some( buf[ 0 ] as i32 );
}
if let Some( sig ) = sig
{
let name = match sig
{
libc::SIGTERM => "SIGTERM",
libc::SIGINT => "SIGINT",
_ => "signal",
};
eprintln!( "ltk: {name} received, exiting cleanly" );
data.exit_requested = true;
}
Ok( PostAction::Continue )
} )
.map_err( |e| RunError::EventLoop( format!( "signals insert_source: {e:?}" ) ) )?;
.map_err( |e| RunError::EventLoop( format!( "signal pipe insert_source: {e:?}" ) ) )?;
Ok( () )
}
@@ -224,3 +289,54 @@ impl<A: App> Dispatch<XdgToplevelSessionV1, ()> for AppData<A>
{
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn read_one( fd: i32 ) -> Option<u8>
{
let mut buf = [ 0u8; 4 ];
let n = unsafe { libc::read( fd, buf.as_mut_ptr().cast(), buf.len() ) };
( n == 1 ).then( || buf[ 0 ] )
}
#[ test ]
fn handler_writes_signal_to_pipe()
{
let mut fds = [ -1i32; 2 ];
assert_eq!( unsafe { libc::pipe2( fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK ) }, 0 );
SIGNAL_PIPE_WR.store( fds[ 1 ], Ordering::Release );
on_termination_signal( libc::SIGTERM );
assert_eq!( read_one( fds[ 0 ] ), Some( libc::SIGTERM as u8 ) );
// The real wiring: raise() delivers synchronously to this
// thread, so the handler has run before it returns.
unsafe
{
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_termination_signal as usize;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset( &mut action.sa_mask );
assert_eq!( libc::sigaction( libc::SIGTERM, &action, std::ptr::null_mut() ), 0 );
libc::raise( libc::SIGTERM );
}
assert_eq!( read_one( fds[ 0 ] ), Some( libc::SIGTERM as u8 ) );
unsafe
{
let mut dfl: libc::sigaction = std::mem::zeroed();
dfl.sa_sigaction = libc::SIG_DFL;
libc::sigemptyset( &mut dfl.sa_mask );
libc::sigaction( libc::SIGTERM, &dfl, std::ptr::null_mut() );
}
SIGNAL_PIPE_WR.store( -1, Ordering::Release );
unsafe
{
libc::close( fds[ 0 ] );
libc::close( fds[ 1 ] );
}
}
}