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`.
343 lines
10 KiB
Rust
343 lines
10 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! `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 };
|
|
use calloop::LoopHandle;
|
|
use smithay_client_toolkit::reexports::client::globals::GlobalList;
|
|
use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, QueueHandle };
|
|
use smithay_client_toolkit::shell::xdg::window::Window;
|
|
|
|
use crate::app::App;
|
|
use crate::protocol::xdg_session_management_v1::
|
|
{
|
|
xdg_session_manager_v1::{ self, XdgSessionManagerV1 },
|
|
xdg_session_v1::{ self, XdgSessionV1 },
|
|
xdg_toplevel_session_v1::{ self, XdgToplevelSessionV1 },
|
|
};
|
|
use crate::session_state::{ RestoreReason, Startup, StateStore };
|
|
|
|
use super::error::RunError;
|
|
use super::AppData;
|
|
|
|
pub( crate ) const TOPLEVEL_NAME: &str = "main";
|
|
pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 );
|
|
pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE";
|
|
|
|
pub( crate ) struct SessionRuntime
|
|
{
|
|
pub session: Option<XdgSessionV1>,
|
|
pub toplevel_session: Option<XdgToplevelSessionV1>,
|
|
/// `None` when persistence is off: layer / lock surfaces, an unusable
|
|
/// app_id, a concurrent instance, or after `replaced`.
|
|
pub store: Option<StateStore>,
|
|
pub reason: RestoreReason,
|
|
pub replaced: bool,
|
|
}
|
|
|
|
impl SessionRuntime
|
|
{
|
|
pub fn disabled() -> Self
|
|
{
|
|
Self {
|
|
session: None,
|
|
toplevel_session: None,
|
|
store: None,
|
|
reason: RestoreReason::Launch,
|
|
replaced: false,
|
|
}
|
|
}
|
|
|
|
/// Phase 1, before any window exists: read the restore hint from the
|
|
/// environment, decide the reason, hand saved bytes to the app and mark
|
|
/// the run as live.
|
|
pub fn bootstrap<A: App>( app: &mut A ) -> Self
|
|
{
|
|
let env_restore = std::env::var_os( RESTORE_ENV ).is_some_and( |v| v == "1" );
|
|
if std::env::var_os( RESTORE_ENV ).is_some()
|
|
{
|
|
// SAFETY: removing an env var is sound only when no other thread
|
|
// is reading the environment concurrently. We are still in the
|
|
// init phase before `set_channel_sender`, so the app has had
|
|
// no opportunity to spawn worker threads yet.
|
|
unsafe { std::env::remove_var( RESTORE_ENV ); }
|
|
}
|
|
|
|
let mut rt = Self::disabled();
|
|
let Some( mut store ) = StateStore::open( app.app_id() ) else { return rt };
|
|
match store.decide( env_restore )
|
|
{
|
|
Startup::Concurrent =>
|
|
{
|
|
eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() );
|
|
return rt;
|
|
}
|
|
Startup::Reason( reason ) => rt.reason = reason,
|
|
}
|
|
if rt.reason != RestoreReason::Launch
|
|
{
|
|
if let Some( bytes ) = store.load_state()
|
|
{
|
|
app.restore_state( bytes );
|
|
}
|
|
}
|
|
store.mark_running();
|
|
rt.store = Some( store );
|
|
rt
|
|
}
|
|
|
|
/// Phase 2: bind the manager and open the session. Silent when the
|
|
/// compositor lacks the global. The manager proxy is not kept: it has
|
|
/// no state of its own and the session outlives it server-side.
|
|
pub fn bind<A: App>( &mut self, globals: &GlobalList, qh: &QueueHandle<AppData<A>> )
|
|
{
|
|
let Some( store ) = &self.store else { return };
|
|
let manager: Option<XdgSessionManagerV1> = globals.bind( qh, 1..=1, () ).ok();
|
|
let Some( manager ) = manager else { return };
|
|
let reason = match self.reason
|
|
{
|
|
RestoreReason::Launch => xdg_session_manager_v1::Reason::Launch,
|
|
RestoreReason::Recover => xdg_session_manager_v1::Reason::Recover,
|
|
RestoreReason::SessionRestore => xdg_session_manager_v1::Reason::SessionRestore,
|
|
};
|
|
self.session = Some( manager.get_session( reason, store.session_id(), qh, () ) );
|
|
}
|
|
|
|
/// Phase 3: register the main toplevel. Must run before the window's
|
|
/// first commit or the compositor raises `already_mapped`.
|
|
pub fn attach_toplevel<A: App>( &mut self, window: &Window, qh: &QueueHandle<AppData<A>> )
|
|
{
|
|
let Some( session ) = &self.session else { return };
|
|
self.toplevel_session =
|
|
Some( session.restore_toplevel( window.xdg_toplevel(), TOPLEVEL_NAME.to_string(), qh, () ) );
|
|
}
|
|
|
|
pub fn periodic_save<A: App>( &mut self, app: &A )
|
|
{
|
|
if self.replaced { return; }
|
|
if let Some( store ) = &mut self.store
|
|
{
|
|
store.save_state_if_changed( app.save_state() );
|
|
}
|
|
}
|
|
|
|
pub fn on_exit<A: App>( &mut self, app: &A )
|
|
{
|
|
if self.replaced { return; }
|
|
if let Some( store ) = &mut self.store
|
|
{
|
|
store.mark_clean_exit( app.save_state() );
|
|
}
|
|
}
|
|
|
|
pub fn on_replaced( &mut self )
|
|
{
|
|
eprintln!( "ltk: session taken over by another instance; this one stops persisting state" );
|
|
if let Some( t ) = self.toplevel_session.take() { t.destroy(); }
|
|
if let Some( s ) = self.session.take() { s.destroy(); }
|
|
self.replaced = true;
|
|
self.store = None;
|
|
}
|
|
}
|
|
|
|
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::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 ]
|
|
{
|
|
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!( "signal pipe insert_source: {e:?}" ) ) )?;
|
|
Ok( () )
|
|
}
|
|
|
|
pub( crate ) fn install_save_timer<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
|
|
{
|
|
handle
|
|
.insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData<A>|
|
|
{
|
|
data.session.periodic_save( &data.app );
|
|
TimeoutAction::ToDuration( SAVE_INTERVAL )
|
|
} )
|
|
.map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?;
|
|
Ok( () )
|
|
}
|
|
|
|
impl<A: App> Dispatch<XdgSessionManagerV1, ()> for AppData<A>
|
|
{
|
|
fn event(
|
|
_state: &mut Self,
|
|
_proxy: &XdgSessionManagerV1,
|
|
_event: xdg_session_manager_v1::Event,
|
|
_data: &(),
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
)
|
|
{
|
|
}
|
|
}
|
|
|
|
impl<A: App> Dispatch<XdgSessionV1, ()> for AppData<A>
|
|
{
|
|
fn event(
|
|
state: &mut Self,
|
|
_proxy: &XdgSessionV1,
|
|
event: xdg_session_v1::Event,
|
|
_data: &(),
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
)
|
|
{
|
|
match event
|
|
{
|
|
xdg_session_v1::Event::Created { session_id } =>
|
|
{
|
|
if let Some( store ) = &mut state.session.store
|
|
{
|
|
store.set_session_id( session_id );
|
|
}
|
|
}
|
|
xdg_session_v1::Event::Restored => {}
|
|
xdg_session_v1::Event::Replaced => state.session.on_replaced(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: App> Dispatch<XdgToplevelSessionV1, ()> for AppData<A>
|
|
{
|
|
fn event(
|
|
_state: &mut Self,
|
|
_proxy: &XdgToplevelSessionV1,
|
|
_event: xdg_toplevel_session_v1::Event,
|
|
_data: &(),
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
)
|
|
{
|
|
}
|
|
}
|
|
|
|
#[ 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 ] );
|
|
}
|
|
}
|
|
}
|