Add XWayland support (#16)

* save dev state

* fix xwayland-satellite supervisor lifecycle, readiness and cleanup

* remove wayland scanner protocol generated files

* default xwayland enabled with path

* remove jargons

* address x11 review: park unconfirmed cleanup, never signal a reaped group, fix storm test and notify dir mode

* fix resize tests: never build Size values from negative dimensions

* add min-width/min-height window rules that replace client minimum sizes

* rename effective_size_constraints to effective_minimum_size and minor cleanups
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-06 19:40:22 +01:00
1 parent ed23949a95
commit a6f83689f8
42 files changed
+7162 -1333

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
use tempfile::NamedTempFile;
// The cookie authenticates the server; it does not isolate X11 clients from each other.
/// Deletion has one owner: the `NamedTempFile` removes the cookie file when
/// this value drops, so nothing here unlinks the path a second time.
#[derive(Debug)]
pub(crate) struct AuthorityFile {
file: NamedTempFile,
}
fn random_cookie() -> io::Result<[u8; 16]> {
let mut cookie = [0_u8; 16];
let mut filled = 0;
while filled < cookie.len() {
let count = unsafe { libc::getrandom(cookie[filled..].as_mut_ptr().cast(), cookie.len() - filled, 0) };
if count < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
if count == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "getrandom returned zero"));
}
filled += count as usize;
}
Ok(cookie)
}
fn hostname() -> io::Result<Vec<u8>> {
let mut buffer = [0_u8; 256];
if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 {
return Err(io::Error::last_os_error());
}
let end = buffer
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "hostname is not terminated"))?;
Ok(buffer[..end].to_vec())
}
fn field(output: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
let length = u16::try_from(bytes.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Xauthority field is too long"))?;
output.write_all(&length.to_be_bytes())?;
output.write_all(bytes)
}
impl AuthorityFile {
pub fn create(runtime_directory: &Path, display: u32) -> io::Result<Self> {
let metadata = fs::symlink_metadata(runtime_directory)?;
let uid = unsafe { libc::geteuid() };
if !metadata.is_dir()
|| metadata.file_type().is_symlink()
|| metadata.uid() != uid
|| metadata.mode() & 0o077 != 0
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"XDG_RUNTIME_DIR must be a private directory owned by this user",
));
}
let mut file = tempfile::Builder::new()
.prefix("ferese-xauth-")
.tempfile_in(runtime_directory)?;
file.as_file().set_permissions(fs::Permissions::from_mode(0o600))?;
let cookie = random_cookie()?;
let host = hostname()?;
let number = display.to_string();
let output = file.as_file_mut();
output.write_all(&256_u16.to_be_bytes())?; // FamilyLocal
field(output, &host)?;
field(output, number.as_bytes())?;
field(output, b"MIT-MAGIC-COOKIE-1")?;
field(output, &cookie)?;
output.flush()?;
Ok(Self { file })
}
pub fn path(&self) -> &Path {
self.file.path()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn private_runtime_dir(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("ferese-xauth-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
root
}
fn read_record(path: &Path) -> (u16, String, String, String, Vec<u8>) {
let bytes = fs::read(path).unwrap();
let mut offset = 0;
let family = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]);
offset += 2;
let take = &|offset: &mut usize| -> Vec<u8> {
let length = u16::from_be_bytes([bytes[*offset], bytes[*offset + 1]]) as usize;
*offset += 2;
let value = bytes[*offset..*offset + length].to_vec();
*offset += length;
value
};
let host = String::from_utf8(take(&mut offset)).unwrap();
let display = String::from_utf8(take(&mut offset)).unwrap();
let name = String::from_utf8(take(&mut offset)).unwrap();
let cookie = take(&mut offset);
assert_eq!(offset, bytes.len(), "trailing bytes in authority file");
(family, host, display, name, cookie)
}
#[test]
fn writes_a_family_local_mit_cookie_record() {
let runtime = private_runtime_dir("record");
let file = AuthorityFile::create(&runtime, 7).unwrap();
let (family, host, display, name, cookie) = read_record(file.path());
assert_eq!(family, 256, "FamilyLocal");
assert!(!host.is_empty(), "hostname must be recorded");
assert_eq!(display, "7");
assert_eq!(name, "MIT-MAGIC-COOKIE-1");
assert_eq!(cookie.len(), 16);
}
#[test]
fn the_cookie_is_fresh_for_each_display() {
let runtime = private_runtime_dir("fresh");
let first = AuthorityFile::create(&runtime, 1).unwrap();
let second = AuthorityFile::create(&runtime, 2).unwrap();
assert_ne!(first.path(), second.path());
assert_ne!(read_record(first.path()).4, read_record(second.path()).4);
}
#[test]
fn the_file_is_private_and_removed_on_drop() {
let runtime = private_runtime_dir("private");
let path = {
let file = AuthorityFile::create(&runtime, 3).unwrap();
let path = file.path().to_owned();
let mode = fs::metadata(&path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "authority file must not be group/world readable");
path
};
assert!(!path.exists(), "authority file must be removed on drop");
}
#[test]
fn a_group_readable_runtime_directory_is_rejected() {
let runtime = std::env::temp_dir().join(format!("ferese-xauth-loose-{}", std::process::id()));
let _ = fs::remove_dir_all(&runtime);
fs::create_dir_all(&runtime).unwrap();
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o755)).unwrap();
let error = AuthorityFile::create(&runtime, 1).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn a_missing_runtime_directory_is_rejected() {
let error = AuthorityFile::create(Path::new("/nonexistent/ferese-runtime"), 1).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn the_record_is_decodable_by_libxau_via_xauth() {
let runtime = private_runtime_dir("libxau");
let file = AuthorityFile::create(&runtime, 91).unwrap();
let output = std::process::Command::new("xauth")
.args(["-f", file.path().to_str().unwrap(), "list"])
.output()
.expect("xauth must be available to verify the authority file");
assert!(
output.status.success(),
"xauth rejected the authority file: {}",
String::from_utf8_lossy(&output.stderr)
);
let listing = String::from_utf8_lossy(&output.stdout);
let entry = listing
.lines()
.find(|line| line.contains("MIT-MAGIC-COOKIE-1"))
.unwrap_or_else(|| panic!("xauth did not list the cookie record: {listing:?}"));
assert!(entry.contains(":91"), "entry must reference the display: {entry:?}");
let cookie_hex = entry.split_whitespace().last().expect("cookie field");
assert_eq!(cookie_hex.len(), 32, "16-byte cookie as hex: {cookie_hex}");
assert!(cookie_hex.chars().any(|c| c != '0'), "cookie must not be zero");
}
#[test]
fn another_user_cannot_read_the_authority_file() {
let runtime = private_runtime_dir("cross-user");
let file = AuthorityFile::create(&runtime, 92).unwrap();
let mode = fs::metadata(file.path()).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(fs::metadata(&runtime).unwrap().mode() & 0o777, 0o700);
}
}
+571
View File
@@ -0,0 +1,571 @@
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Stdio};
use crate::session_environment::SessionEnvironment;
use super::sockets::Reservation;
#[cfg(test)]
pub(crate) mod test_hook {
use std::cell::{Cell, RefCell};
type InChild = Box<dyn FnMut() -> std::io::Result<()> + Send + Sync>;
thread_local! {
static IN_CHILD: RefCell<Option<InChild>> = const { RefCell::new(None) };
/// Group signals this thread's owners actually sent.
///
/// Kept per thread so parallel tests cannot inflate it: an owner whose
/// leader was reaped must send none, and a test that measures before
/// and after a shutdown is measuring that owner alone.
static GROUP_SIGNALS: Cell<usize> = const { Cell::new(0) };
}
pub(crate) fn record_group_signal() {
GROUP_SIGNALS.with(|count| count.set(count.get() + 1));
}
pub(crate) fn group_signals() -> usize {
GROUP_SIGNALS.with(Cell::get)
}
/// Arrange an action that must happen in *this* child only, after fork.
///
/// Close-on-exec must be cleared here: the parent is shared with parallel
/// spawns, so a parent-side change would race them.
pub(crate) fn set_in_child(hook: impl FnMut() -> std::io::Result<()> + Send + Sync + 'static) {
IN_CHILD.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
pub(crate) fn take_in_child() -> Option<InChild> {
IN_CHILD.with(|slot| slot.borrow_mut().take())
}
}
// Use the public Wayland socket: Satellite passes its environment to Xwayland.
pub(crate) fn spawn_satellite(
executable: &Path,
environment: &SessionEnvironment,
reservation: &Reservation,
authority: &Path,
notify_socket: Option<&Path>,
) -> io::Result<Child> {
if reservation.listeners().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cannot launch Satellite without reserved listening sockets",
));
}
satellite_command(executable, environment, reservation, authority, notify_socket).spawn()
}
fn satellite_command(
executable: &Path,
environment: &SessionEnvironment,
reservation: &Reservation,
authority: &Path,
notify_socket: Option<&Path>,
) -> std::process::Command {
let inherited: Vec<_> = reservation.listeners().iter().map(AsRawFd::as_raw_fd).collect();
if inherited.is_empty() {
panic!("cannot build a Satellite command without reserved listening sockets");
}
let mut command = crate::process::command(executable);
environment.apply_public(&mut command);
command
.arg(reservation.display_name())
.arg("-auth")
.arg(authority)
.args(["-nolisten", "tcp"])
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.process_group(0);
for name in [
"NOTIFY_SOCKET",
"WATCHDOG_PID",
"WATCHDOG_USEC",
"LISTEN_PID",
"LISTEN_FDS",
"LISTEN_FDNAMES",
] {
command.env_remove(name);
}
if let Some(path) = notify_socket {
command.env("NOTIFY_SOCKET", path);
}
for fd in &inherited {
command.arg("-listenfd").arg(fd.to_string());
}
// PR_SET_PDEATHSIG follows the spawning thread; spawn from the compositor thread.
let expected_parent = unsafe { libc::getpid() };
// SAFETY: this child-only hook performs only raw, non-allocating
// Linux/POSIX operations on prevalidated descriptors. All formatting and
// allocation occurred above, before fork.
unsafe {
command.pre_exec(move || {
for fd in &inherited {
let flags = libc::fcntl(*fd, libc::F_GETFD);
if flags == -1 || libc::fcntl(*fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 {
return Err(io::Error::last_os_error());
}
}
if libc::prctl(
libc::PR_SET_PDEATHSIG,
libc::SIGTERM as libc::c_ulong,
0 as libc::c_ulong,
0 as libc::c_ulong,
0 as libc::c_ulong,
) == -1
{
return Err(io::Error::last_os_error());
}
if libc::getppid() != expected_parent {
return Err(io::Error::from_raw_os_error(libc::ESRCH));
}
Ok(())
});
}
// One-shot, thread-local, and consumed by exactly this spawn: the hook
// changes descriptors in the forked child, never in the parent.
#[cfg(test)]
if let Some(hook) = test_hook::take_in_child() {
// SAFETY: same contract as the hook above: a closure that runs in the
// forked child before exec, using only prevalidated descriptors and
// no allocation.
unsafe {
command.pre_exec(hook);
}
}
command
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Observation {
Running,
Exited,
Failed(String),
}
pub(crate) struct OwnedProcessGroup {
pid: u32,
child: Option<Child>,
reaped: bool,
cleanup_error: Option<String>,
}
impl OwnedProcessGroup {
pub fn adopt(child: Child) -> Self {
let pid = child.id();
Self {
pid,
child: Some(child),
reaped: false,
cleanup_error: None,
}
}
#[cfg(test)]
fn from_command(mut command: std::process::Command) -> std::io::Result<Self> {
command.process_group(0);
Ok(Self::adopt(command.spawn()?))
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn observe(&mut self) -> Observation {
if self.reaped {
return Observation::Exited;
}
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
// SAFETY: `info` is a live, correctly aligned local; P_PID with our own
// pid, and WNOWAIT|WNOHANG leave the status claimable by `wait`.
let result = unsafe {
libc::waitid(
libc::P_PID,
self.pid,
std::ptr::from_mut(&mut info),
libc::WEXITED | libc::WNOWAIT | libc::WNOHANG,
)
};
if result == -1 {
// ECHILD means someone else already reaped the leader, so the
// process-group identity can no longer be trusted.
return Observation::Failed(format!(
"cannot observe X11 service {}: {}",
self.pid,
io::Error::last_os_error()
));
}
// si_pid == 0 with WNOHANG means no state change is pending.
// SAFETY: `info` was filled in by the waitid call above.
if unsafe { info.si_pid() } == 0 {
Observation::Running
} else {
Observation::Exited
}
}
/// Ask the whole group to stop, but only while this owner still holds the
/// unreaped leader that names it.
///
/// Once the leader is reaped the group number identifies nothing this
/// owner controls: it may already belong to an unrelated process group.
/// Cleanup in that state resumes by *waiting* for the group, never by
/// signalling it, so a stale number can never kill a stranger.
pub fn signal_group(&self, signal: i32) {
if self.reaped {
// The group number is stale: it may already name an unrelated
// process group, so resume by waiting and never by signalling.
return;
}
#[cfg(test)]
test_hook::record_group_signal();
signal_group(self.pid, signal);
}
pub fn group_is_empty(&self) -> bool {
#[cfg(test)]
if super::test_hooks::force_group_not_empty() {
return false;
}
// SAFETY: `kill` with signal 0 performs error checking only. A negative
// pid addresses the process group led by `self.pid`.
let outcome = unsafe { libc::kill(-(self.pid as i32), 0) };
if outcome == 0 {
return false;
}
// ESRCH is the only "nothing left" answer; anything else (notably
// EPERM) means the group still exists and must not be assumed gone.
std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
}
pub fn is_reaped(&self) -> bool {
self.reaped
}
pub fn terminate(&mut self, grace: std::time::Duration) -> Result<Option<std::process::ExitStatus>, String> {
self.signal_group(libc::SIGTERM);
let deadline = std::time::Instant::now() + grace;
loop {
match self.observe() {
Observation::Exited => break,
Observation::Running => {
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
Observation::Failed(error) => return Err(error),
}
}
self.signal_group(libc::SIGKILL);
// A zombie still counts as a group member, so reap before asking
// whether a descendant is left.
let status = self.reap()?;
let deadline = std::time::Instant::now() + grace;
while !self.group_is_empty() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
if !self.group_is_empty() {
// A survivor may still hold the X11 listeners, so report it.
return Err("the X11 service group survived SIGKILL".to_owned());
}
Ok(status)
}
pub fn reap(&mut self) -> Result<Option<std::process::ExitStatus>, String> {
if self.reaped {
return Ok(None);
}
let Some(child) = self.child.as_mut() else {
self.reaped = true;
return Ok(None);
};
match child.wait() {
Ok(status) => {
self.reaped = true;
Ok(Some(status))
}
Err(error) => Err(format!("cannot reap X11 service {}: {error}", self.pid)),
}
}
pub fn cleanup_error(&self) -> Option<&str> {
self.cleanup_error.as_deref()
}
pub fn note_cleanup_error(&mut self, error: String) {
self.cleanup_error = Some(error);
}
}
impl Drop for OwnedProcessGroup {
fn drop(&mut self) {
if self.reaped {
return;
}
// Last resort: the manager must already have reached a completed
// cleanup transition, so a process is never silently forgotten.
self.signal_group(libc::SIGKILL);
let _ = self.reap();
}
}
/// Requires an unreaped group leader owned by the caller.
pub(crate) fn signal_group(pid: u32, signal: i32) {
// SAFETY: negative pid targets the process group led by pid.
unsafe {
libc::kill(-(pid as i32), signal);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SessionEnvironment;
use std::ffi::OsString;
use std::time::Duration;
fn environment() -> SessionEnvironment {
SessionEnvironment {
wayland_display: OsString::from("wayland-7"),
x11: None,
}
}
#[test]
fn refusing_to_launch_without_owned_listeners() {
let mut reservation = Reservation::allocate().unwrap();
reservation.close_listeners();
let error = spawn_satellite(
Path::new("/nonexistent/xwayland-satellite"),
&environment(),
&reservation,
Path::new("/nonexistent/auth"),
None,
)
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(error.to_string().contains("without reserved listening sockets"));
}
#[test]
fn a_missing_executable_is_reported_not_panicked() {
let reservation = Reservation::allocate().unwrap();
let error = spawn_satellite(
Path::new("/nonexistent/xwayland-satellite"),
&environment(),
&reservation,
Path::new("/nonexistent/auth"),
None,
)
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn a_stopped_group_reports_itself_empty() {
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg("exit 0");
let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh");
assert!(!process.group_is_empty());
process.signal_group(libc::SIGTERM);
let status = process.terminate(Duration::from_secs(5)).expect("terminate");
assert!(status.is_some());
assert!(process.group_is_empty(), "a reaped group has no members left");
assert_eq!(process.cleanup_error(), None);
}
#[test]
fn a_descendant_that_ignores_sigterm_is_still_killed() {
// A leader that exits immediately while a descendant keeps running is
// the crash shape that leaks X11 listeners into the next generation.
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg("sleep 300 & exit 0");
let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh");
// The leader is gone, yet the group still has a member.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while process.observe() == Observation::Running && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(process.observe(), Observation::Exited);
assert!(
!process.group_is_empty(),
"the surviving descendant keeps the group non-empty"
);
process.terminate(Duration::from_secs(5)).expect("terminate");
assert!(
process.group_is_empty(),
"SIGKILL must reclaim a descendant that ignored SIGTERM"
);
}
#[test]
fn reaping_reports_a_failure_instead_of_panicking() {
let mut process = OwnedProcessGroup {
pid: 0,
child: None,
reaped: false,
cleanup_error: None,
};
// An owner with no child has nothing left to reap.
assert_eq!(process.reap().expect("reap"), None);
assert!(process.reaped);
// Reaping twice is harmless, so cleanup paths stay simple.
assert_eq!(process.reap().expect("reap again"), None);
}
#[test]
fn a_reaped_owner_resumes_shutdown_without_signalling_a_stale_group() {
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg("exit 0");
let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh");
process.reap().expect("reap the leader");
assert!(process.is_reaped(), "the leader is reaped");
// A shutdown that resumes here still owns the cleanup record, but the
// group number may already name another process group: it must be
// waited for, never signalled.
let before = test_hook::group_signals();
let status = process
.terminate(Duration::from_secs(5))
.expect("terminating a reaped owner must not fail");
assert_eq!(status, None, "there is no leader left to report a status for");
assert_eq!(
test_hook::group_signals(),
before,
"a reaped owner must resume by waiting, not by signalling its stale group number"
);
assert!(process.group_is_empty(), "the group really was reclaimed");
}
#[test]
fn the_command_line_matches_the_verified_cli_contract() {
let reservation = Reservation::allocate().unwrap();
let notify = std::env::temp_dir().join("ferese-notify-test");
let command = satellite_command(
Path::new("xwayland-satellite"),
&environment(),
&reservation,
Path::new("/run/user/1000/ferese-xauth-abc"),
Some(notify.as_path()),
);
let args: Vec<_> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
args.first().map(String::as_str),
Some(reservation.display_name().as_str())
);
let mut listenfds = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"-listenfd" => {
listenfds.push(args[index + 1].clone());
index += 2;
}
"-auth" => {
assert_eq!(args[index + 1], "/run/user/1000/ferese-xauth-abc");
index += 2;
}
"-nolisten" => {
assert_eq!(args[index + 1], "tcp");
index += 2;
}
_ => index += 1,
}
}
assert!(args.windows(2).any(|pair| pair == ["-nolisten", "tcp"]));
assert!(!args.iter().any(|arg| arg == "-ac"));
assert!(!args.iter().any(|arg| arg == "-displayfd"));
assert_eq!(listenfds.len(), reservation.listeners().len());
}
#[test]
fn inherited_socket_activation_state_is_cleared() {
let reservation = Reservation::allocate().unwrap();
let command = satellite_command(
Path::new("xwayland-satellite"),
&environment(),
&reservation,
Path::new("/auth"),
None,
);
for (key, value) in command.get_envs() {
let key = key.to_string_lossy();
if matches!(
key.as_ref(),
"LISTEN_PID" | "LISTEN_FDS" | "LISTEN_FDNAMES" | "WATCHDOG_PID" | "WATCHDOG_USEC" | "NOTIFY_SOCKET"
) {
assert_eq!(value, None, "{key} must not be inherited from the launching service");
}
if key.as_ref() == "WAYLAND_SOCKET" {
assert_eq!(
value, None,
"Satellite must connect by display name, not an inherited socket"
);
}
}
}
#[test]
fn a_private_notify_socket_replaces_the_host_one() {
let reservation = Reservation::allocate().unwrap();
let private = std::env::temp_dir().join("ferese-notify-private");
let command = satellite_command(
Path::new("xwayland-satellite"),
&environment(),
&reservation,
Path::new("/auth"),
Some(private.as_path()),
);
let notify = command
.get_envs()
.find(|(key, _)| key.to_string_lossy() == "NOTIFY_SOCKET")
.and_then(|(_, value)| value)
.expect("private NOTIFY_SOCKET must be set");
assert_eq!(notify, private);
}
}
File diff suppressed because it is too large. Load diff
+620
View File
@@ -0,0 +1,620 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
const FAILURE_WINDOW: Duration = Duration::from_secs(30);
const MAX_FAILURES: usize = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AfterStop {
/// Schedule a bounded retry.
Backoff,
/// Open the circuit: fail fast until an explicit retry.
Failed,
/// The session is shutting down.
Stopped,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Phase {
Idle,
Starting(u64),
Spawned(u64),
Running(u64),
Stopping {
generation: u64,
after_stop: AfterStop,
},
Backoff {
generation: u64,
until: Instant,
},
/// A stop whose cleanup could not be confirmed.
CleanupFailed {
generation: u64,
},
Failed,
Stopped,
}
impl Phase {
pub fn generation(self) -> Option<u64> {
match self {
Phase::Starting(generation)
| Phase::Spawned(generation)
| Phase::Running(generation)
| Phase::Stopping { generation, .. }
| Phase::Backoff { generation, .. }
| Phase::CleanupFailed { generation } => Some(generation),
Phase::Idle | Phase::Failed | Phase::Stopped => None,
}
}
pub fn is_running(self) -> bool {
matches!(self, Phase::Running(_))
}
pub fn can_start(self) -> bool {
matches!(self, Phase::Idle)
}
/// True while a generation's process and cleanup sources are still owned.
#[cfg(test)]
pub fn is_stopping(self) -> bool {
matches!(self, Phase::Stopping { .. })
}
}
// Process resources belong to the manager, not this state model.
pub(crate) struct Lifecycle {
pub phase: Phase,
next_generation: u64,
failures: VecDeque<Instant>,
}
impl Default for Lifecycle {
fn default() -> Self {
Self {
phase: Phase::Idle,
next_generation: 0,
failures: VecDeque::new(),
}
}
}
impl Lifecycle {
pub fn request_start(&mut self) -> Option<u64> {
if !self.phase.can_start() {
return None;
}
let Some(generation) = self.next_generation.checked_add(1) else {
self.phase = Phase::Failed;
return None;
};
self.next_generation = generation;
self.phase = Phase::Starting(generation);
Some(generation)
}
pub fn spawned(&mut self, generation: u64) -> bool {
if self.phase != Phase::Starting(generation) {
return false;
}
self.phase = Phase::Spawned(generation);
true
}
pub fn ready(&mut self, generation: u64) -> bool {
if self.phase != Phase::Spawned(generation) {
return false;
}
self.phase = Phase::Running(generation);
true
}
pub fn begin_stop_after_failure(&mut self, generation: u64, now: Instant, retryable: bool) -> Option<AfterStop> {
let current = matches!(
self.phase,
Phase::Starting(current)
| Phase::Spawned(current)
| Phase::Running(current)
if current == generation
);
if !current {
return None;
}
while self
.failures
.front()
.is_some_and(|at| now.saturating_duration_since(*at) > FAILURE_WINDOW)
{
self.failures.pop_front();
}
self.failures.push_back(now);
let after_stop = if !retryable || self.failures.len() >= MAX_FAILURES {
AfterStop::Failed
} else {
AfterStop::Backoff
};
self.phase = Phase::Stopping { generation, after_stop };
Some(after_stop)
}
pub fn backoff_delay(&self) -> Duration {
if self.failures.len() <= 1 {
Duration::from_millis(250)
} else {
Duration::from_secs(1)
}
}
pub fn finish_stop(&mut self, generation: u64, now: Instant) -> Option<AfterStop> {
let Phase::Stopping {
generation: current,
after_stop,
} = self.phase
else {
return None;
};
if current != generation {
return None;
}
self.phase = match after_stop {
AfterStop::Backoff => Phase::Backoff {
generation,
until: now + self.backoff_delay(),
},
AfterStop::Failed => Phase::Failed,
AfterStop::Stopped => Phase::Stopped,
};
Some(after_stop)
}
pub fn park_cleanup_failure(&mut self, generation: u64) -> bool {
let Phase::Stopping {
generation: current, ..
} = self.phase
else {
return false;
};
if current != generation {
return false;
}
self.phase = Phase::CleanupFailed { generation };
true
}
pub fn confirm_cleanup(&mut self, generation: u64) -> bool {
if self.phase != (Phase::CleanupFailed { generation }) {
return false;
}
self.phase = Phase::Failed;
true
}
pub fn finish_backoff(&mut self, generation: u64, now: Instant) -> bool {
match self.phase {
Phase::Backoff {
generation: current,
until,
} if current == generation && now >= until => {
self.phase = Phase::Idle;
true
}
_ => false,
}
}
pub fn can_explicit_retry(&self) -> bool {
self.phase == Phase::Failed
}
pub fn commit_explicit_retry(&mut self) -> bool {
if !self.can_explicit_retry() {
return false;
}
self.failures.clear();
self.phase = Phase::Idle;
true
}
pub fn begin_shutdown_stop(&mut self) -> Option<u64> {
if self.phase == Phase::Stopped {
return None;
}
let generation = self.phase.generation().unwrap_or(0);
self.phase = Phase::Stopping {
generation,
after_stop: AfterStop::Stopped,
};
Some(generation)
}
pub fn recent_failures(&self) -> usize {
self.failures.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn start(lifecycle: &mut Lifecycle) -> u64 {
lifecycle.request_start().expect("Idle must yield a generation ticket")
}
/// Fail a generation the way the manager does, then complete the stop.
///
/// Cleanup completing is what publishes the next phase, so a test that
/// needs the next generation must say so explicitly.
fn fail_and_stop(lifecycle: &mut Lifecycle, generation: u64, now: Instant, retryable: bool) -> AfterStop {
let after_stop = lifecycle
.begin_stop_after_failure(generation, now, retryable)
.expect("the current generation must accept a failure");
assert_eq!(
lifecycle.finish_stop(generation, now),
Some(after_stop),
"cleanup completion publishes the chosen outcome"
);
after_stop
}
fn start_after_backoff(lifecycle: &mut Lifecycle) -> u64 {
let now = Instant::now();
let Phase::Backoff { generation, until } = lifecycle.phase else {
panic!("expected a backoff, found {:?}", lifecycle.phase);
};
assert!(lifecycle.finish_backoff(generation, until.max(now)));
start(lifecycle)
}
#[test]
fn starts_from_idle_and_claims_one_generation_at_a_time() {
let mut lifecycle = Lifecycle::default();
assert_eq!(lifecycle.phase, Phase::Idle);
let generation = start(&mut lifecycle);
assert_eq!(generation, 1);
assert_eq!(lifecycle.phase, Phase::Starting(1));
assert_eq!(lifecycle.request_start(), None);
assert_eq!(lifecycle.phase, Phase::Starting(1));
}
#[test]
fn generations_are_monotonic() {
let mut lifecycle = Lifecycle::default();
let first = start(&mut lifecycle);
lifecycle.ready(first);
fail_and_stop(&mut lifecycle, first, Instant::now(), true);
let second = start_after_backoff(&mut lifecycle);
assert!(second > first);
}
#[test]
fn spawn_then_readiness_reaches_running() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
assert!(lifecycle.spawned(generation));
assert_eq!(lifecycle.phase, Phase::Spawned(generation));
assert!(lifecycle.ready(generation));
assert_eq!(lifecycle.phase, Phase::Running(generation));
assert!(lifecycle.phase.is_running());
}
#[test]
fn stale_generations_cannot_advance_the_phase() {
let mut lifecycle = Lifecycle::default();
let stale = start(&mut lifecycle);
fail_and_stop(&mut lifecycle, stale, Instant::now(), true);
let current = start_after_backoff(&mut lifecycle);
assert!(!lifecycle.spawned(stale));
assert!(!lifecycle.ready(stale));
assert_eq!(lifecycle.begin_stop_after_failure(stale, Instant::now(), true), None);
assert_eq!(lifecycle.phase, Phase::Starting(current));
}
#[test]
fn readiness_cannot_be_claimed_before_exec() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
assert!(!lifecycle.ready(generation));
assert_eq!(lifecycle.phase, Phase::Starting(generation));
}
#[test]
fn a_non_retryable_failure_opens_the_circuit_immediately() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
assert_eq!(
fail_and_stop(&mut lifecycle, generation, Instant::now(), false),
AfterStop::Failed
);
assert_eq!(lifecycle.phase, Phase::Failed);
assert!(!lifecycle.phase.can_start());
}
#[test]
fn backoff_is_bounded_and_then_opens_the_circuit() {
let mut lifecycle = Lifecycle::default();
let now = Instant::now();
let first = start(&mut lifecycle);
assert_eq!(fail_and_stop(&mut lifecycle, first, now, true), AfterStop::Backoff);
let Phase::Backoff { until, .. } = lifecycle.phase else {
panic!("first failure must back off");
};
assert_eq!(until, now + Duration::from_millis(250));
let second = start_after_backoff(&mut lifecycle);
let now = Instant::now();
assert_eq!(fail_and_stop(&mut lifecycle, second, now, true), AfterStop::Backoff);
let Phase::Backoff { until, .. } = lifecycle.phase else {
panic!("second failure must back off");
};
assert_eq!(until, now + Duration::from_secs(1));
let third = start_after_backoff(&mut lifecycle);
assert_eq!(
fail_and_stop(&mut lifecycle, third, Instant::now(), true),
AfterStop::Failed,
"reaching the failure limit must open the circuit instead of backing off"
);
assert_eq!(lifecycle.phase, Phase::Failed);
assert_eq!(lifecycle.recent_failures(), MAX_FAILURES);
}
#[test]
fn backoff_releases_to_idle_only_after_the_window() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
let now = Instant::now();
fail_and_stop(&mut lifecycle, generation, now, true);
let Phase::Backoff { until, .. } = lifecycle.phase else {
panic!("must be backing off");
};
assert!(!lifecycle.finish_backoff(generation, until - Duration::from_millis(1)));
assert!(lifecycle.finish_backoff(generation, until));
assert_eq!(lifecycle.phase, Phase::Idle);
}
#[test]
fn a_successful_spawn_does_not_reset_the_failure_window() {
let mut lifecycle = Lifecycle::default();
let first = start(&mut lifecycle);
fail_and_stop(&mut lifecycle, first, Instant::now(), true);
let second = start_after_backoff(&mut lifecycle);
lifecycle.spawned(second);
lifecycle.ready(second);
assert_eq!(lifecycle.recent_failures(), 1);
}
#[test]
fn failures_outside_the_window_are_discarded() {
let mut lifecycle = Lifecycle::default();
let start_time = Instant::now();
let generation = start(&mut lifecycle);
fail_and_stop(&mut lifecycle, generation, Instant::now(), true);
assert_eq!(lifecycle.recent_failures(), 1);
let second = start_after_backoff(&mut lifecycle);
let later = start_time + FAILURE_WINDOW + Duration::from_secs(1);
fail_and_stop(&mut lifecycle, second, later, true);
assert_eq!(lifecycle.recent_failures(), 1);
}
#[test]
fn explicit_retry_clears_the_circuit_but_a_connection_cannot() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
fail_and_stop(&mut lifecycle, generation, Instant::now(), false);
assert_eq!(lifecycle.phase, Phase::Failed);
assert_eq!(lifecycle.request_start(), None);
assert!(lifecycle.can_explicit_retry());
assert!(lifecycle.commit_explicit_retry());
assert_eq!(lifecycle.phase, Phase::Idle);
assert_eq!(lifecycle.recent_failures(), 0);
assert!(lifecycle.request_start().is_some());
}
#[test]
fn an_unconfirmed_cleanup_is_not_retryable_until_it_is_confirmed() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
let now = Instant::now();
lifecycle
.begin_stop_after_failure(generation, now, true)
.expect("the current generation accepts a failure");
assert!(lifecycle.park_cleanup_failure(generation), "an in-flight stop parks");
assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation });
// The deadline proved nothing, so the service must refuse to act on it.
assert!(!lifecycle.phase.can_start());
assert_eq!(lifecycle.request_start(), None);
assert!(
!lifecycle.can_explicit_retry(),
"an unconfirmed cleanup is not retryable"
);
assert!(!lifecycle.commit_explicit_retry());
assert_eq!(
lifecycle.finish_stop(generation, Instant::now()),
None,
"a parked cleanup publishes nothing, not even the stop it replaced"
);
assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation });
// Only evidence of a reclaimed group reopens the circuit.
assert!(lifecycle.confirm_cleanup(generation));
assert_eq!(lifecycle.phase, Phase::Failed);
assert!(lifecycle.can_explicit_retry());
assert!(lifecycle.commit_explicit_retry());
assert_eq!(lifecycle.phase, Phase::Idle);
}
#[test]
fn a_parked_cleanup_of_another_generation_is_never_confirmed() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
lifecycle
.begin_stop_after_failure(generation, Instant::now(), false)
.expect("the current generation accepts a failure");
assert!(lifecycle.park_cleanup_failure(generation));
assert!(!lifecycle.confirm_cleanup(generation.wrapping_add(1)));
assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation });
assert!(!lifecycle.can_explicit_retry());
}
#[test]
fn only_an_in_flight_stop_can_park_a_cleanup() {
let mut lifecycle = Lifecycle::default();
assert!(
!lifecycle.park_cleanup_failure(1),
"an idle service has no cleanup to park"
);
let generation = start(&mut lifecycle);
assert!(
!lifecycle.park_cleanup_failure(generation),
"a start in flight is not a stop, so it cannot park"
);
assert_eq!(lifecycle.phase, Phase::Starting(generation));
assert!(
!lifecycle.confirm_cleanup(generation),
"a confirmation outside a park must change nothing"
);
assert_eq!(lifecycle.phase, Phase::Starting(generation));
}
#[test]
fn explicit_retry_is_refused_outside_the_failed_phase() {
let mut lifecycle = Lifecycle::default();
assert!(!lifecycle.can_explicit_retry());
assert!(!lifecycle.commit_explicit_retry());
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
assert!(!lifecycle.can_explicit_retry());
assert!(!lifecycle.commit_explicit_retry());
}
#[test]
fn a_failed_generation_is_not_startable_until_cleanup_completes() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
let now = Instant::now();
let after_stop = lifecycle
.begin_stop_after_failure(generation, now, true)
.expect("the current generation accepts a failure");
// The process is still alive and still holds the listeners, so the
// lifecycle must not be startable and must not look like a failure
// that a queued connection could act on.
assert!(lifecycle.phase.is_stopping());
assert!(!lifecycle.phase.can_start());
assert_eq!(lifecycle.request_start(), None);
assert!(!lifecycle.can_explicit_retry());
// Only cleanup finishing publishes the retryable outcome.
assert_eq!(
lifecycle.finish_stop(generation, now),
Some(after_stop),
"cleanup completion publishes the outcome"
);
assert!(matches!(lifecycle.phase, Phase::Backoff { .. }));
assert!(!lifecycle.phase.can_start(), "backoff still blocks a start");
}
#[test]
fn an_abandoned_cleanup_cannot_publish_a_newer_generation() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
lifecycle
.begin_stop_after_failure(generation, Instant::now(), false)
.expect("the current generation accepts a failure");
assert_eq!(lifecycle.finish_stop(generation.wrapping_add(1), Instant::now()), None);
// A stale completion leaves the real transition in flight.
assert!(lifecycle.phase.is_stopping());
assert!(lifecycle.finish_stop(generation, Instant::now()).is_some());
assert_eq!(lifecycle.phase, Phase::Failed);
}
#[test]
fn stopping_invalidates_scheduled_starts() {
let mut lifecycle = Lifecycle::default();
let _generation = start(&mut lifecycle);
let generation = lifecycle.begin_shutdown_stop().expect("a stop is required");
assert!(lifecycle.phase.is_stopping());
assert_eq!(
lifecycle.finish_stop(generation, Instant::now()),
Some(AfterStop::Stopped)
);
assert_eq!(lifecycle.phase, Phase::Stopped);
assert_eq!(lifecycle.request_start(), None);
assert!(!lifecycle.spawned(generation));
assert!(!lifecycle.ready(generation));
// Shutdown is idempotent once stopped.
assert_eq!(lifecycle.begin_shutdown_stop(), None);
assert_eq!(lifecycle.phase, Phase::Stopped);
}
#[test]
fn phase_reports_its_generation() {
assert_eq!(Phase::Starting(4).generation(), Some(4));
assert_eq!(
Phase::Backoff {
generation: 4,
until: Instant::now()
}
.generation(),
Some(4)
);
assert_eq!(Phase::Idle.generation(), None);
assert_eq!(Phase::Failed.generation(), None);
assert_eq!(
Phase::CleanupFailed { generation: 4 }.generation(),
Some(4),
"a parked cleanup names the generation it still owns"
);
}
}
File diff suppressed because it is too large. Load diff
+875
View File
@@ -0,0 +1,875 @@
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixDatagram;
use std::path::{Path, PathBuf};
/// Bytes reserved for ancillary control data, well above `CMSG_LEN(ucred)`.
const CONTROL_CAPACITY: usize = 128;
fn cmsg_len_ucred() -> usize {
cmsg_data_offset() + std::mem::size_of::<libc::ucred>()
}
/// Byte offset at which `CMSG_DATA` starts: `cmsghdr` rounded up to
/// `size_t` alignment.
fn cmsg_data_offset() -> usize {
let header = std::mem::size_of::<libc::cmsghdr>();
let align = std::mem::size_of::<libc::size_t>();
header.div_ceil(align) * align
}
/// Room for the control data of one notification.
///
/// `cmsghdr` is the first field so the buffer carries that type's alignment.
/// A `#[repr(C)]` union with a `ucred` is not enough: the kernel requires
/// `msg_control` to be aligned for `cmsghdr`, which is wider.
#[repr(C)]
struct ControlBuffer {
header: libc::cmsghdr,
payload: [u8; CONTROL_CAPACITY],
}
// Requires Satellite's systemd notification feature; exec success is not readiness.
pub(crate) struct ReadinessSocket {
socket: UnixDatagram,
path: PathBuf,
}
impl ReadinessSocket {
pub fn create(runtime_directory: &Path, generation: u64) -> io::Result<Self> {
let path = runtime_directory.join(format!("ferese-x11-notify-{generation}.sock"));
let socket = UnixDatagram::bind(&path)?;
socket.set_nonblocking(true)?;
let enable: libc::c_int = 1;
let length = libc::socklen_t::try_from(std::mem::size_of::<libc::c_int>())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "optlen overflow"))?;
// SAFETY: setsockopt on a live owned socket, with `enable` a live local
// int of exactly `length` bytes.
if unsafe {
libc::setsockopt(
socket.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PASSCRED,
std::ptr::from_ref(&enable).cast(),
length,
)
} == -1
{
return Err(io::Error::last_os_error());
}
Ok(Self { socket, path })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn recv_ready(&self, satellite_pid: u32) -> io::Result<bool> {
let mut buffer = [0_u8; 512];
let mut control = ControlBuffer {
header: unsafe { std::mem::zeroed() },
payload: [0_u8; CONTROL_CAPACITY],
};
let mut iov = libc::iovec {
iov_base: buffer.as_mut_ptr().cast(),
iov_len: buffer.len(),
};
let mut message: libc::msghdr = unsafe { std::mem::zeroed() };
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = std::ptr::from_mut(&mut control).cast();
message.msg_controllen = std::mem::size_of::<ControlBuffer>();
// SAFETY: every pointer refers to a live local buffer for the duration
// of the call, and `message` is fully initialized before use.
let received = unsafe {
libc::recvmsg(
self.socket.as_raw_fd(),
std::ptr::from_mut(&mut message),
libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
)
};
if received < 0 {
return Err(io::Error::last_os_error());
}
// Walk *every* ancillary message, not just the first: a sender is free
// to put credentials second, and descriptors attached to any message
// must not be leaked into this long-lived process. This happens before
// the truncation check below, because a truncated datagram can still
// have installed descriptors: the flag does not release them.
let mut from_satellite = false;
// SAFETY: the control buffer was sized, aligned, and described by this
// message, and CMSG_NXTHDR walks only what the kernel filled in.
unsafe {
let mut header = libc::CMSG_FIRSTHDR(&message);
while !header.is_null() {
let (cmsg, data) = (*header, libc::CMSG_DATA(header));
if cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_CREDENTIALS {
// Only an exactly sized ucred is a credential. A shorter or
// longer one is malformed, so it does not count.
if cmsg.cmsg_len == cmsg_len_ucred() {
// SAFETY: cmsg_len was checked to describe exactly one
// ucred, and CMSG_DATA points into our own buffer.
let credential = &*data.cast::<libc::ucred>();
// Ignore the Xwayland child and any other process.
if credential.pid as u32 == satellite_pid {
from_satellite = true;
}
}
} else if cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_RIGHTS {
// Accepting a datagram must never adopt descriptors from an
// untrusted sender. Close them instead of dropping them on
// the floor, where they would stay open in this process.
let payload = cmsg.cmsg_len.saturating_sub(cmsg_data_offset());
let count = payload / std::mem::size_of::<libc::c_int>();
for index in 0..count {
// SAFETY: element `index` lies inside the ancillary data
// the kernel reported, and is read exactly once.
let raw = *data.cast::<libc::c_int>().add(index);
// SAFETY: `raw` is an open descriptor delivered by the
// kernel, so closing it releases the sender's copy.
libc::close(raw);
}
}
header = libc::CMSG_NXTHDR(&message, header);
}
}
// Decided only after the control walk: a completely filled payload
// buffer cannot be distinguished from truncation, and the kernel's
// truncation flags never satisfy the readiness contract.
if received as usize == buffer.len() || message.msg_flags & (libc::MSG_CTRUNC | libc::MSG_TRUNC) != 0 {
return Ok(false);
}
if !from_satellite {
return Ok(false);
}
Ok(matches!(
exact_line(&buffer[..received as usize], b"READY=1"),
Some(true)
))
}
pub fn try_clone_owned(&self) -> io::Result<UnixDatagram> {
self.socket.try_clone()
}
}
impl AsRawFd for ReadinessSocket {
fn as_raw_fd(&self) -> std::os::fd::RawFd {
self.socket.as_raw_fd()
}
}
fn exact_line(payload: &[u8], wanted: &[u8]) -> Option<bool> {
if payload.is_empty() || payload.contains(&0) {
return None;
}
let text = std::str::from_utf8(payload).ok()?;
let mut found = false;
for line in text.split('\n') {
if line.as_bytes() == wanted {
found = true;
} else if !line.is_empty() && !is_key_value(line) {
return None;
}
}
Some(found)
}
fn is_key_value(line: &str) -> bool {
line.split_once('=')
.is_some_and(|(key, _)| !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'))
}
impl Drop for ReadinessSocket {
fn drop(&mut self) {
if let Err(error) = std::fs::remove_file(&self.path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %self.path.display(), %error, "could not remove readiness socket");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Bounded wait, mirroring the production drain budget.
const READINESS_DRAIN_BUDGET_TESTS: usize = 200;
/// Open a datagram socket already connected to `path`.
///
/// Connecting first means the senders can use plain `sendmsg`, whose
/// glibc export carries no destination address.
///
/// # Safety
/// Async-signal-safe, for use between `fork` and `_exit`.
unsafe fn connect_datagram(path: &Path) -> libc::c_int {
unsafe {
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_DGRAM, 0);
if fd < 0 {
return -1;
}
let mut address: libc::sockaddr_un = std::mem::zeroed();
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
let bytes = path.as_os_str().as_encoded_bytes();
if bytes.len() >= address.sun_path.len() {
libc::close(fd);
return -1;
}
for (slot, byte) in address.sun_path.iter_mut().zip(bytes) {
*slot = *byte as libc::c_char;
}
if libc::connect(
fd,
std::ptr::from_ref(&address).cast(),
// The fixed `sockaddr_un` length is what the kernel expects for
// AF_UNIX; `sun_path` is already zero-filled past the name.
libc::socklen_t::try_from(std::mem::size_of::<libc::sockaddr_un>()).unwrap(),
) == -1
{
libc::close(fd);
return -1;
}
fd
}
}
fn private_runtime_dir(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ferese-readiness-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap();
root
}
fn send_from_child(path: &Path, payload: &[u8]) -> i32 {
// SAFETY: the child path performs only socket/bind-free sendto and
// _exit, which are async-signal-safe.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let Ok(client) = UnixDatagram::unbound() else {
libc::_exit(70);
};
let sent = client.send_to(payload, path);
libc::_exit(if sent.is_ok() { 0 } else { 71 });
}
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
status
}
}
/// Send `payload` with `MSG_TRUNC`, which makes the kernel deliver a short
/// datagram and set `MSG_TRUNC` on the receiver.
fn send_truncated_from_child(path: &Path, payload: &[u8]) {
// SAFETY: the child performs only sendmsg and _exit, both
// async-signal-safe, on freshly created descriptors.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let mut iov = libc::iovec {
iov_base: payload.as_ptr().cast_mut().cast(),
iov_len: payload.len(),
};
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
// The kernel truncates the payload and reports MSG_TRUNC.
message.msg_flags = libc::MSG_TRUNC;
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
libc::_exit(if sent >= 0 { 0 } else { 71 });
}
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must send a truncated datagram"
);
}
}
/// Send `READY=1` from a child that also passes a descriptor.
///
/// The child stays alive so the receiver can name its PID, and the extra
/// descriptor must be released by the receiver rather than leaked.
fn send_with_descriptor_from_child(path: &Path, sentinel: &Path) -> i32 {
// SAFETY: the child performs only socket/open/sendmsg/_exit, all
// async-signal-safe.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
// A descriptor for a known file, so the receiver can prove it
// closed exactly this one rather than guessing at a count.
let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else {
libc::_exit(73);
};
let spare = libc::open(name.as_ptr(), libc::O_RDONLY);
if spare < 0 {
libc::_exit(72);
}
let mut iov = libc::iovec {
iov_base: b"READY=1".as_ptr().cast_mut().cast(),
iov_len: 7,
};
// One SCM_RIGHTS message, in an aligned buffer.
let mut control = ControlBuffer {
header: std::mem::zeroed(),
payload: [0_u8; CONTROL_CAPACITY],
};
// `payload` starts where `CMSG_DATA` starts, so the descriptor
// belongs at the beginning of it. Writing it further along would
// silently send whatever zeros sit in between.
let descriptor = spare;
std::ptr::copy_nonoverlapping(
std::ptr::from_ref(&descriptor).cast::<u8>(),
control.payload.as_mut_ptr(),
std::mem::size_of::<libc::c_int>(),
);
let total = cmsg_data_offset() + std::mem::size_of::<libc::c_int>();
control.header.cmsg_len = total as _;
control.header.cmsg_level = libc::SOL_SOCKET;
control.header.cmsg_type = libc::SCM_RIGHTS;
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = std::ptr::from_mut(&mut control).cast();
message.msg_controllen = total;
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
if sent < 0 {
libc::_exit(71);
}
// Stay alive so the receiver can accept the credential while
// this process still exists.
libc::sleep(1);
libc::_exit(0);
}
pid
}
}
/// A truncation flag must be exercised for real, not simulated by a shorter payload.
#[test]
fn a_truncated_notification_is_rejected_even_with_valid_credentials() {
let runtime = private_runtime_dir("truncated");
let socket = ReadinessSocket::create(&runtime, 21).unwrap();
let path = socket.path().to_owned();
send_truncated_from_child(&path, b"READY=1");
// Any PID is fine here: the point is the flag, not the credential.
assert!(
!recv_within(&socket, u32::MAX),
"MSG_TRUNC must never satisfy readiness"
);
}
/// Descriptors attached to a notification must be released, not adopted.
#[test]
fn attached_descriptors_are_closed_rather_than_leaked() {
let runtime = private_runtime_dir("fds");
let socket = ReadinessSocket::create(&runtime, 22).unwrap();
let path = socket.path().to_owned();
// Counted by identity, not by process-wide total, so a parallel test
// cannot make this flaky.
let sentinel = sentinel_file("fds");
let pid = send_with_descriptor_from_child(&path, &sentinel);
assert!(
recv_within(&socket, pid as u32),
"a READY=1 from the named PID is accepted even with an attached fd"
);
assert!(
!holds_open(&sentinel),
"the descriptor delivered with the notification must be closed, \
not left open in the compositor"
);
let _ = std::fs::remove_file(&sentinel);
// SAFETY: reaping a direct child that has finished sleeping.
let mut status = 0;
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
}
#[test]
fn credentials_after_another_control_message_are_still_read() {
let runtime = private_runtime_dir("cmsg-order");
let socket = ReadinessSocket::create(&runtime, 23).unwrap();
let path = socket.path().to_owned();
let sentinel = sentinel_file("cmsg-order");
let pid = send_with_descriptor_from_child(&path, &sentinel);
assert!(
recv_within(&socket, pid as u32),
"a credential that is not the first control message must still count"
);
// SAFETY: reaping a direct child that has finished sleeping.
let mut status = 0;
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
}
/// Send `READY=1` with more descriptors than the receiver's control buffer can
/// hold, so the kernel truncates the ancillary data while installing what fits.
fn send_descriptors_beyond_capacity_from_child(path: &Path, sentinel: &Path, count: usize) -> i32 {
// SAFETY: the child performs only socket/open/sendmsg/sleep/_exit, all
// async-signal-safe except sleep, which is a bounded wait before exit.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else {
libc::_exit(73);
};
let sentinel_fd = libc::open(name.as_ptr(), libc::O_RDONLY);
if sentinel_fd < 0 {
libc::_exit(72);
}
let filler = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
if filler < 0 {
libc::_exit(72);
}
let mut iov = libc::iovec {
iov_base: b"READY=1".as_ptr().cast_mut().cast(),
iov_len: 7,
};
// A control buffer large enough to *send* the flood; the
// receiver's much smaller buffer is what truncates it.
let mut control = [0_u8; 1024];
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = control.as_mut_ptr().cast();
message.msg_controllen = control.len() as _;
let header = libc::CMSG_FIRSTHDR(&message);
if header.is_null() {
libc::_exit(74);
}
(*header).cmsg_level = libc::SOL_SOCKET;
(*header).cmsg_type = libc::SCM_RIGHTS;
let bytes = (count * std::mem::size_of::<libc::c_int>()) as u32;
(*header).cmsg_len = libc::CMSG_LEN(bytes) as _;
// Exactly the aligned size of one control message: trailing
// zero bytes would be read as a second, malformed message and
// the kernel would reject the send with EINVAL.
message.msg_controllen = libc::CMSG_SPACE(bytes) as _;
let data = libc::CMSG_DATA(header);
for index in 0..count {
// The sentinel rides along first, so it is one of the
// descriptors the receiver's short buffer actually installs.
let descriptor = if index == 0 { sentinel_fd } else { filler };
*data.cast::<libc::c_int>().add(index) = descriptor;
}
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
if sent < 0 {
libc::_exit(71);
}
libc::sleep(1);
libc::_exit(0);
}
pid
}
}
/// Send `count` malformed datagrams, each carrying a descriptor.
fn send_malformed_storm_from_child(path: &Path, sentinel: &Path, count: usize) -> i32 {
// SAFETY: the child performs only socket/open/sendmsg/_exit, all
// async-signal-safe.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else {
libc::_exit(73);
};
let sentinel_fd = libc::open(name.as_ptr(), libc::O_RDONLY);
if sentinel_fd < 0 {
libc::_exit(72);
}
for _ in 0..count {
let mut iov = libc::iovec {
iov_base: b"READY=2".as_ptr().cast_mut().cast(),
iov_len: 7,
};
let mut control = ControlBuffer {
header: std::mem::zeroed(),
payload: [0_u8; CONTROL_CAPACITY],
};
let descriptor = sentinel_fd;
std::ptr::copy_nonoverlapping(
std::ptr::from_ref(&descriptor).cast::<u8>(),
control.payload.as_mut_ptr(),
std::mem::size_of::<libc::c_int>(),
);
control.header.cmsg_len = (cmsg_data_offset() + std::mem::size_of::<libc::c_int>()) as _;
control.header.cmsg_level = libc::SOL_SOCKET;
control.header.cmsg_type = libc::SCM_RIGHTS;
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = std::ptr::from_mut(&mut control).cast();
message.msg_controllen = cmsg_data_offset() + std::mem::size_of::<libc::c_int>();
if libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL) < 0 {
libc::_exit(71);
}
}
libc::_exit(0);
}
pid
}
}
/// Open descriptors in this process; only *deltas* between two calls are
/// meaningful, because parallel tests add and remove descriptors too.
fn open_fd_count() -> usize {
std::fs::read_dir("/proc/self/fd")
.map(|entries| entries.count())
.unwrap_or(0)
}
/// A truncated ancillary buffer must be rejected and its delivered descriptors closed.
#[test]
fn truncated_control_messages_still_close_the_descriptors_they_delivered() {
let runtime = private_runtime_dir("ctrunc");
let socket = ReadinessSocket::create(&runtime, 24).unwrap();
let path = socket.path().to_owned();
let sentinel = sentinel_file("ctrunc");
let before = open_fd_count();
// Far more descriptors than the receiver's 128-byte payload can hold.
let pid = send_descriptors_beyond_capacity_from_child(&path, &sentinel, 64);
// SAFETY: reaping a direct child that has finished sleeping.
let mut status = 0;
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must deliver the truncated notification: {status:#x}"
);
let verdict = recv_within(&socket, pid as u32);
assert!(!verdict, "MSG_CTRUNC must never satisfy readiness");
assert!(
!holds_open(&sentinel),
"descriptors installed before MSG_CTRUNC was noticed must be closed"
);
let after = open_fd_count();
assert!(
after <= before + 4,
"truncation leaked descriptors: {before} open before, {after} after"
);
let _ = std::fs::remove_file(&sentinel);
}
/// A flood of malformed datagrams must leak nothing and leave the socket usable.
#[test]
fn a_storm_of_malformed_datagrams_leaks_nothing_and_leaves_the_socket_usable() {
let runtime = private_runtime_dir("storm");
let socket = ReadinessSocket::create(&runtime, 25).unwrap();
let path = socket.path().to_owned();
let sentinel = sentinel_file("storm");
let before = open_fd_count();
let pid = send_malformed_storm_from_child(&path, &sentinel, 128);
// Drain while the sender may still be blocked in `sendmsg`: the socket
// queue holds far fewer datagrams than the storm, so waiting for the
// child to exit before reading would deadlock the pair. One deadline
// covers both the send and the receive.
let started = std::time::Instant::now();
let deadline = started + std::time::Duration::from_secs(10);
let mut judged = 0;
let mut status = 0;
while judged < 128 {
loop {
match socket.recv_ready(u32::MAX) {
Ok(_) => judged += 1,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
Err(error) => panic!("unexpected readiness read error: {error}"),
}
}
if judged >= 128 || std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
if judged < 128 {
// The sender never finished, so it is still blocked. Kill it and
// reap it here rather than leave it behind for another test.
// SAFETY: signalling and reaping a direct child of this process.
unsafe {
libc::kill(pid, libc::SIGKILL);
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
}
panic!("the storm was not delivered within the shared deadline: {judged} of 128 judged");
}
// Every datagram was accepted, so the sender has reached its exit.
// SAFETY: reaping a direct child that has finished sending.
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must deliver the storm: {status:#x}"
);
assert!(judged >= 128, "the storm must be delivered and judged: {judged}");
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"judging the storm must not stall the consumer: {:?}",
started.elapsed()
);
assert!(
!holds_open(&sentinel),
"every descriptor carried by the storm must be closed"
);
let after = open_fd_count();
assert!(
after <= before + 4,
"the storm leaked descriptors: {before} open before, {after} after"
);
let _ = std::fs::remove_file(&sentinel);
let ready_sentinel = sentinel_file("storm-ready");
let ready_pid = send_with_descriptor_from_child(&path, &ready_sentinel);
assert!(
recv_within(&socket, ready_pid as u32),
"a READY=1 after the storm is still judged by credential, and the \
storm sender's exit must not have wedged the socket"
);
assert!(
!holds_open(&ready_sentinel),
"cleanup must keep working after the storm"
);
let _ = std::fs::remove_file(&ready_sentinel);
// SAFETY: reaping a direct child that has finished sleeping.
let mut ready_status = 0;
unsafe { libc::waitpid(ready_pid, std::ptr::from_mut(&mut ready_status), 0) };
}
/// Drain the socket until one datagram is judged, bounded like production.
fn recv_within(socket: &ReadinessSocket, pid: u32) -> bool {
for _ in 0..READINESS_DRAIN_BUDGET_TESTS {
match socket.recv_ready(pid) {
Ok(verdict) => return verdict,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("no readiness datagram arrived");
}
/// A unique empty file used to identify a delivered descriptor.
fn sentinel_file(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("ferese-readiness-sentinel-{name}-{}", std::process::id()));
let _ = std::fs::remove_file(&path);
std::fs::write(&path, b"").unwrap();
path
}
/// Whether this process currently holds any descriptor on `path`.
fn holds_open(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
return false;
};
entries
.filter_map(Result::ok)
.any(|entry| std::fs::read_link(entry.path()).is_ok_and(|target| target == path))
}
#[test]
fn creates_a_private_socket_that_is_removed_on_drop() {
let runtime = private_runtime_dir("create");
let path = {
let socket = ReadinessSocket::create(&runtime, 7).unwrap();
let path = socket.path().to_owned();
assert!(path.exists());
assert!(path.starts_with(&runtime));
assert!(path.to_string_lossy().contains('7'), "path must encode the generation");
path
};
assert!(!path.exists(), "readiness socket must be removed on drop");
}
#[test]
fn each_generation_gets_a_distinct_socket() {
let runtime = private_runtime_dir("generations");
let first = ReadinessSocket::create(&runtime, 1).unwrap();
let second = ReadinessSocket::create(&runtime, 2).unwrap();
assert_ne!(first.path(), second.path());
}
#[test]
fn line_matching_requires_an_exact_ready_line() {
assert_eq!(exact_line(b"READY=1", b"READY=1"), Some(true));
assert_eq!(exact_line(b"READY=1\n", b"READY=1"), Some(true));
assert_eq!(exact_line(b"MAINPID=42\nREADY=1", b"READY=1"), Some(true));
assert_eq!(exact_line(b"READY=0", b"READY=1"), Some(false));
assert_eq!(exact_line(b"READY=1 ", b"READY=1"), Some(false));
assert_eq!(exact_line(b"XREADY=1", b"READY=1"), Some(false));
assert_eq!(exact_line(b"READY=11", b"READY=1"), Some(false));
assert_eq!(exact_line(b"READY=1=2", b"READY=1"), Some(false));
assert_eq!(exact_line(b"", b"READY=1"), None);
assert_eq!(exact_line(b"READY=1\x00", b"READY=1"), None);
assert_eq!(exact_line(b"\xff\xfe\x00\x01", b"READY=1"), None);
}
#[test]
fn a_ready_datagram_from_another_process_is_rejected() {
let runtime = private_runtime_dir("foreign");
let socket = ReadinessSocket::create(&runtime, 3).unwrap();
let path = socket.path().to_owned();
let status = send_from_child(&path, b"READY=1");
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must send"
);
let mut rejected = false;
for _ in 0..1000 {
match socket.recv_ready(u32::MAX) {
Ok(false) => {
rejected = true;
break;
}
Ok(true) => panic!("a foreign READY=1 must never satisfy readiness"),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(rejected, "a READY=1 from another process must be rejected");
}
#[test]
fn a_ready_datagram_from_the_expected_pid_is_accepted() {
let runtime = private_runtime_dir("expected");
let socket = ReadinessSocket::create(&runtime, 4).unwrap();
let path = socket.path().to_owned();
let pid = {
// SAFETY: mirror of send_from_child, returning the child PID.
unsafe {
let pid = libc::fork();
assert!(pid >= 0);
if pid == 0 {
for _ in 0..500 {
if let Ok(client) = UnixDatagram::unbound()
&& client.send_to(b"READY=1", &path).is_ok()
{
libc::_exit(0);
}
libc::usleep(2_000);
}
libc::_exit(72);
}
pid
}
};
let mut accepted = false;
for _ in 0..1000 {
match socket.recv_ready(pid as u32) {
Ok(true) => {
accepted = true;
break;
}
Ok(false) => {}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
// SAFETY: reap the child we just spawned.
unsafe {
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
}
assert!(accepted, "an exact READY=1 from the expected PID must be accepted");
}
#[test]
fn a_non_ready_datagram_from_the_expected_pid_is_rejected() {
let runtime = private_runtime_dir("not-ready");
let socket = ReadinessSocket::create(&runtime, 5).unwrap();
let path = socket.path().to_owned();
let pid = unsafe {
let pid = libc::fork();
assert!(pid >= 0);
if pid == 0 {
for _ in 0..500 {
if let Ok(client) = UnixDatagram::unbound()
&& client.send_to(b"RELOADING=1\nSTATUS=starting", &path).is_ok()
{
libc::_exit(0);
}
libc::usleep(2_000);
}
libc::_exit(73);
}
pid
};
let mut rejected = false;
for _ in 0..1000 {
match socket.recv_ready(pid as u32) {
Ok(false) => {
rejected = true;
break;
}
Ok(true) => panic!("RELOADING=1 must not satisfy readiness"),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
unsafe {
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
}
assert!(rejected, "RELOADING=1 must not satisfy readiness");
}
}
+371
View File
@@ -0,0 +1,371 @@
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::ops::Range;
use std::os::linux::net::SocketAddrExt;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::os::unix::net::{SocketAddr, UnixListener};
use std::path::{Path, PathBuf};
use tracing::warn;
// Identity checks prevent accidental unlinking, not races with hostile same-UID code.
#[derive(Debug)]
struct CreatedPath {
path: PathBuf,
device: u64,
inode: u64,
}
impl CreatedPath {
fn record(path: PathBuf, metadata: &fs::Metadata) -> Self {
Self {
path,
device: metadata.dev(),
inode: metadata.ino(),
}
}
fn remove(&mut self) {
match fs::symlink_metadata(&self.path) {
Ok(metadata) if metadata.dev() == self.device && metadata.ino() == self.inode => {
if let Err(error) = fs::remove_file(&self.path) {
warn!(
path = %self.path.display(),
%error,
"could not remove X11 path this process created"
);
}
}
Ok(_) => warn!(
path = %self.path.display(),
"refusing to remove an X11 path that no longer matches the one this process created"
),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => warn!(
path = %self.path.display(),
%error,
"could not inspect X11 path during cleanup"
),
}
}
}
impl Drop for CreatedPath {
fn drop(&mut self) {
self.remove();
}
}
fn validate_shared_directory(path: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
let uid = unsafe { libc::geteuid() };
if !metadata.is_dir() || metadata.file_type().is_symlink() || (metadata.uid() != 0 && metadata.uid() != uid) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("untrusted X11 directory: {}", path.display()),
));
}
if metadata.mode() & 0o022 != 0 && metadata.mode() & 0o1000 == 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("shared X11 directory is not sticky: {}", path.display()),
));
}
Ok(())
}
#[derive(Debug)]
pub(crate) struct Reservation {
display: u32,
listeners: Vec<UnixListener>,
socket_path: Option<CreatedPath>,
lock_path: Option<CreatedPath>,
socket_directory: PathBuf,
}
impl Reservation {
// Leave pre-existing locks and socket paths untouched.
pub fn allocate() -> io::Result<Self> {
#[cfg(test)]
{
// Owned inputs, so an ordinary test never competes for a real slot in
// `/tmp/.X11-unix` nor depends on that directory existing.
let (sockets, locks) = crate::xwayland::test_hooks::allocator_root_for_reservation().unwrap_or_else(|| {
let root = crate::xwayland::test_hooks::default_allocator_root();
(root.clone(), root)
});
Self::allocate_in(&sockets, &locks, 0..64)
}
#[cfg(not(test))]
Self::allocate_in(Path::new("/tmp/.X11-unix"), Path::new("/tmp"), 0..64)
}
fn allocate_in(socket_directory: &Path, lock_directory: &Path, candidates: Range<u32>) -> io::Result<Self> {
validate_shared_directory(socket_directory)?;
validate_shared_directory(lock_directory)?;
for display in candidates {
let path = lock_directory.join(format!(".X{display}-lock"));
let mut file = match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&path)
{
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
};
let lock_path = CreatedPath::record(path, &file.metadata()?);
if let Err(error) = writeln!(file, "{:>10}", std::process::id()).and_then(|()| file.flush()) {
drop(file);
drop(lock_path);
return Err(error);
}
drop(file);
let mut reservation = Self {
display,
listeners: Vec::new(),
socket_path: None,
lock_path: Some(lock_path),
socket_directory: socket_directory.to_owned(),
};
match reservation.bind_same_display() {
Ok(()) => return Ok(reservation),
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
drop(reservation);
}
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::AddrInUse,
"no unused X11 display in the configured internal candidate range",
))
}
pub fn display_number(&self) -> u32 {
self.display
}
pub fn display_name(&self) -> String {
format!(":{}", self.display)
}
pub fn listeners(&self) -> &[UnixListener] {
&self.listeners
}
// Remove source clones and stop the server before closing the reservation.
pub fn close_listeners(&mut self) {
self.listeners.clear();
self.socket_path = None;
}
pub fn bind_same_display(&mut self) -> io::Result<()> {
#[cfg(test)]
if crate::xwayland::test_hooks::fire(&crate::xwayland::test_hooks::FAIL_REBIND_AFTER) {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
"injected X11 display rebind failure",
));
}
if !self.listeners.is_empty() || self.socket_path.is_some() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"X11 listeners are already bound",
));
}
validate_shared_directory(&self.socket_directory)?;
let path = self.socket_directory.join(format!("X{}", self.display));
let filesystem = UnixListener::bind(&path)?;
let owned_path = CreatedPath::record(path.clone(), &fs::symlink_metadata(&path)?);
let abstract_address = SocketAddr::from_abstract_name(path.as_os_str().as_bytes())?;
let abstract_socket = match UnixListener::bind_addr(&abstract_address) {
Ok(listener) => listener,
Err(error) => {
// Drop the recorded path first so cleanup happens in order, once.
drop(owned_path);
drop(filesystem);
return Err(error);
}
};
self.listeners = vec![filesystem, abstract_socket];
self.socket_path = Some(owned_path);
Ok(())
}
fn release_display(&mut self) {
self.close_listeners();
self.lock_path = None;
}
}
impl Drop for Reservation {
fn drop(&mut self) {
self.release_display();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn shared_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ferese-xwayland-alloc-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
for directory in ["sockets", "locks"] {
fs::create_dir(root.join(directory)).unwrap();
fs::set_permissions(root.join(directory), fs::Permissions::from_mode(0o1777)).unwrap();
}
root
}
#[test]
fn allocates_a_display_with_two_listener_transports() {
let root = shared_root("two-transports");
let reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
assert!(reservation.listeners().len() >= 2);
let socket = root.join("sockets").join(format!("X{}", reservation.display_number()));
assert!(socket.exists(), "conventional socket path must exist");
assert!(
root.join("locks")
.join(format!(".X{}-lock", reservation.display_number()))
.exists()
);
}
#[test]
fn skips_an_occupied_display_without_reclaiming_it() {
let root = shared_root("occupied");
let sockets = root.join("sockets");
let locks = root.join("locks");
let first = Reservation::allocate_in(&sockets, &locks, 0..64).unwrap();
let taken = first.display_number();
let second = Reservation::allocate_in(&sockets, &locks, 0..64).unwrap();
assert_ne!(second.display_number(), taken);
assert!(second.display_number() > taken);
assert!(locks.join(format!(".X{taken}-lock")).exists());
assert!(sockets.join(format!("X{taken}")).exists());
}
#[test]
fn an_existing_lock_file_is_left_untouched() {
let root = shared_root("stale-lock");
let locks = root.join("locks");
let stale = locks.join(".X3-lock");
fs::write(&stale, b"9999\n").unwrap();
let reservation = Reservation::allocate_in(&root.join("sockets"), &locks, 0..64).unwrap();
assert_ne!(reservation.display_number(), 3, "occupied number must be skipped");
assert_eq!(fs::read(&stale).unwrap(), b"9999\n", "stale lock must not be reclaimed");
}
#[test]
fn cleanup_removes_paths_this_process_created() {
let root = shared_root("cleanup");
let reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
let display = reservation.display_number();
let socket = root.join("sockets").join(format!("X{display}"));
let lock = root.join("locks").join(format!(".X{display}-lock"));
drop(reservation);
assert!(!socket.exists(), "owned socket path must be removed");
assert!(!lock.exists(), "owned lock path must be removed");
}
#[test]
fn close_listeners_keeps_the_display_lock_owned() {
let root = shared_root("close-listeners");
let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
let display = reservation.display_number();
reservation.close_listeners();
assert!(reservation.listeners().is_empty());
assert!(root.join("locks").join(format!(".X{display}-lock")).exists());
assert!(!root.join("sockets").join(format!("X{display}")).exists());
}
#[test]
fn retry_rebinds_the_same_display_number() {
let root = shared_root("retry");
let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
let display = reservation.display_number();
reservation.close_listeners();
reservation.bind_same_display().unwrap();
assert_eq!(reservation.display_number(), display);
assert!(!reservation.listeners().is_empty());
assert!(root.join("sockets").join(format!("X{display}")).exists());
}
#[test]
fn binding_twice_is_rejected() {
let root = shared_root("double-bind");
let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
reservation.close_listeners();
reservation.bind_same_display().unwrap();
let error = reservation.bind_same_display().unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn an_untrusted_socket_directory_is_rejected() {
let root = std::env::temp_dir().join(format!("ferese-xwayland-untrusted-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::set_permissions(&root, fs::Permissions::from_mode(0o777)).unwrap();
let locks = root.join("locks");
fs::create_dir(&locks).unwrap();
fs::set_permissions(&locks, fs::Permissions::from_mode(0o1777)).unwrap();
let error = Reservation::allocate_in(&root, &locks, 0..64).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("not sticky"));
}
#[test]
fn a_missing_socket_directory_is_an_actionable_error() {
let root = shared_root("missing-dir");
let error = Reservation::allocate_in(&root.join("absent"), &root.join("locks"), 0..64).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn exhausting_the_candidate_range_reports_no_free_display() {
let root = shared_root("exhausted");
let locks = root.join("locks");
for display in 10..13 {
fs::write(locks.join(format!(".X{display}-lock")), b"1\n").unwrap();
}
let error = Reservation::allocate_in(&root.join("sockets"), &locks, 10..13).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AddrInUse);
assert!(error.to_string().contains("no unused X11 display"));
}
}