// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! `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, pub toplevel_session: Option, /// `None` when persistence is off: layer / lock surfaces, an unusable /// app_id, a concurrent instance, or after `replaced`. pub store: Option, 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( 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( &mut self, globals: &GlobalList, qh: &QueueHandle> ) { let Some( store ) = &self.store else { return }; let manager: Option = 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( &mut self, window: &Window, qh: &QueueHandle> ) { 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( &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( &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( handle: &LoopHandle<'static, AppData> ) -> 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| { 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( handle: &LoopHandle<'static, AppData> ) -> Result<(), RunError> { handle .insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData| { data.session.periodic_save( &data.app ); TimeoutAction::ToDuration( SAVE_INTERVAL ) } ) .map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?; Ok( () ) } impl Dispatch for AppData { fn event( _state: &mut Self, _proxy: &XdgSessionManagerV1, _event: xdg_session_manager_v1::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { } } impl Dispatch for AppData { fn event( state: &mut Self, _proxy: &XdgSessionV1, event: xdg_session_v1::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { 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 Dispatch for AppData { fn event( _state: &mut Self, _proxy: &XdgToplevelSessionV1, _event: xdg_toplevel_session_v1::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { } } #[ cfg( test ) ] mod tests { use super::*; fn read_one( fd: i32 ) -> Option { 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 ] ); } } }