fix(linux): reap a crashed headless session's leftovers on next start (#15348)

The teardown cleanup added for #15183 only runs on a clean disconnect.
If the service or its --server crashes before then, the headless logind
session scope and the /tmp/.X<n> lock files it created leak the same way
#15183 leaked them, with nothing to reclaim them afterwards.

Record the session scope and display when the headless session starts,
and on the next --server start reap exactly what the previous run
recorded, then drop the marker. It only ever touches the one scope and
display the previous run recorded, never a scan, so unrelated sessions
are untouched; the reap and X cleanup reuse the teardown path.

A logind session id is only unique within a boot: the counter lives in
/run and resets, so a recorded "session-N.scope" can name a different,
live session after a reboot. Tag the marker with the boot id and only
reap the scope when it matches the current boot. A leaked cgroup cannot
outlive a reboot, so nothing legitimate is lost cross-boot; the X lock
cleanup stays pid-guarded and runs either way.

Signed-off-by: TBX3D <88289044+TBX3D@users.noreply.github.com>
This commit is contained in:
Tigah
2026-06-21 01:56:41 -07:00
committed by GitHub
parent 5cf4323d07
commit 311d4708e5

View File

@@ -51,6 +51,7 @@ fn check_desktop_manager() {
pub fn start_xdesktop() {
debug_assert!(crate::is_server());
std::thread::spawn(|| {
DesktopManager::recover_orphaned_session();
*DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new());
let interval = time::Duration::from_millis(super::SERVICE_INTERVAL);
@@ -462,9 +463,10 @@ impl DesktopManager {
let (child_xorg, child_wm) = Self::start_x11(uid, gid, username, display_num, &envs)?;
is_child_running.store(true, Ordering::SeqCst);
// capture the logind session scope (from a live child) for teardown, see
// reap_session_scope.
// capture the logind session scope (from a live child) for teardown and crash
// recovery, see reap_session_scope and recover_orphaned_session.
let scope_dir = Self::session_scope_dir(child_xorg.id());
Self::save_orphaned_marker(&scope_dir, display_num);
log::info!("Start xorg and wm done, notify and wait xtop x11");
allow_err!(tx_res.send("".to_owned()));
@@ -879,6 +881,66 @@ impl DesktopManager {
std::io::Error::last_os_error().raw_os_error() == Some(hbb_common::libc::EPERM)
}
const ORPHANED_SESSION_KEY: &'static str = "headless-orphaned-session";
fn save_orphaned_marker(scope_dir: &str, display_num: u32) {
// tag the marker with this boot's id: a logind session id is only unique within a
// boot (the counter lives in /run and resets), so recovery must not reap a recorded
// scope path after a reboot, when it may name a different live session.
let boot_id = Self::current_boot_id().unwrap_or_default();
hbb_common::config::LocalConfig::set_option(
Self::ORPHANED_SESSION_KEY.to_owned(),
format!("{};{};{}", scope_dir, display_num, boot_id),
);
}
fn current_boot_id() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
.ok()
.map(|s| s.trim().to_owned())
}
fn clear_orphaned_marker() {
hbb_common::config::LocalConfig::set_option(
Self::ORPHANED_SESSION_KEY.to_owned(),
String::new(),
);
}
fn parse_orphaned_marker(marker: &str) -> Option<(&str, u32, &str)> {
let (rest, boot_id) = marker.rsplit_once(';')?;
let (scope_dir, display) = rest.rsplit_once(';')?;
Some((scope_dir, display.trim().parse::<u32>().ok()?, boot_id))
}
// a run that dies before wait_stop_x11 (service or --server crash) leaks the headless
// session scope + X lock files, the same as a missed teardown (rustdesk/rustdesk#15183).
// reap exactly what the dead run recorded - never a scan, so unrelated sessions are safe.
fn recover_orphaned_session() {
let marker = hbb_common::config::LocalConfig::get_option(Self::ORPHANED_SESSION_KEY);
if marker.is_empty() {
return;
}
if let Some((scope_dir, display_num, boot_id)) = Self::parse_orphaned_marker(&marker) {
// only reap the recorded scope when the marker is from this same boot: a leaked
// cgroup cannot outlive a reboot, so cross-boot there is nothing legitimate to
// reap, and the recorded "session-N.scope" may by then name a different live
// session. the X lock cleanup is pid-guarded, so run it either way.
let same_boot = Self::current_boot_id().map_or(false, |b| b == boot_id);
log::info!(
"Recovering leaked headless session from a previous run: scope {}, display {} (same boot: {})",
scope_dir,
display_num,
same_boot
);
if same_boot {
Self::reap_session_scope(scope_dir);
}
Self::cleanup_x_display_files(display_num);
}
Self::clear_orphaned_marker();
}
fn try_wait_stop_x11(
child_xorg: &mut Child,
child_wm: &mut Child,
@@ -898,6 +960,7 @@ impl DesktopManager {
Self::wait_x11_children_exit(child_xorg, child_wm);
Self::reap_session_scope(scope_dir);
Self::cleanup_x_display_files(display_num);
Self::clear_orphaned_marker();
desktop_manager
.is_child_running
.store(false, Ordering::SeqCst);
@@ -1082,4 +1145,27 @@ mod tests {
assert_eq!(pids, vec![100, 101, 200, 300]);
}
#[test]
fn parses_orphaned_session_marker() {
assert_eq!(
DesktopManager::parse_orphaned_marker(
"/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope;7;abc-123"
),
Some((
"/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope",
7,
"abc-123"
))
);
// an empty scope still carries the display so its stale X lock can be cleaned
assert_eq!(DesktopManager::parse_orphaned_marker(";5;abc-123"), Some(("", 5, "abc-123")));
// an empty boot id never matches the live one, so the scope reap is skipped
assert_eq!(DesktopManager::parse_orphaned_marker("/scope;5;"), Some(("/scope", 5, "")));
assert_eq!(DesktopManager::parse_orphaned_marker(""), None);
assert_eq!(DesktopManager::parse_orphaned_marker("garbage"), None);
// the pre-boot-id two-field format no longer parses, recovery just skips it
assert_eq!(DesktopManager::parse_orphaned_marker("/scope;7"), None);
assert_eq!(DesktopManager::parse_orphaned_marker("/scope;notnum;abc"), None);
}
}