mod containment;
mod lifecycle;
use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, Stdio};
use std::time::{Duration, Instant};
use rustix::io::Errno;
use rustix::process::{Pid, getpgid, test_kill_process};
use self::lifecycle::{
CleanupOutcome, Lifecycle, OwnedFiles, StartupGuard, leader_exited_unreaped,
readiness_with_timeout, remaining_timeout, server_startup_failure, socket_path_fits_tmux,
startup_failure, startup_timeout,
};
#[cfg(feature = "control-mode")]
use crate::ControlClientLimits;
use crate::Server;
use crate::limits::{DispatchLimits, OutputLimits};
const SOCKET_NAME: &str = "s";
const CONFIG_NAME: &str = "c";
const LOCK_NAME: &str = "s.lock";
const OWNER_NAME: &str = "owner";
const FIXTURE_ROOT: &str = "/tmp/libtmux-rs-test";
const TERM: &str = "xterm-256color";
const FIXTURE_CONFIG: &str = "\
set -g default-shell /bin/sh\n\
set -g default-command /bin/sh\n";
const CONTAINMENT_TIMEOUT: Duration = Duration::from_secs(5);
fn timeout_scale() -> f64 {
static SCALE: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
*SCALE.get_or_init(|| {
parse_timeout_scale(std::env::var("LIBTMUX_TEST_TIMEOUT_SCALE").ok().as_deref())
})
}
fn parse_timeout_scale(value: Option<&str>) -> f64 {
value
.and_then(|value| value.trim().parse::<f64>().ok())
.filter(|scale| scale.is_finite())
.map_or(1.0, |scale| scale.max(1.0))
}
#[must_use]
pub fn scaled(base: Duration) -> Duration {
Duration::try_from_secs_f64(base.as_secs_f64() * timeout_scale()).unwrap_or(Duration::MAX)
}
fn platform_fallback_grace_ceiling() -> Option<Duration> {
PLATFORM_FALLBACK_GRACE_CEILING.map(scaled)
}
pub(crate) const CLEANUP_POLL_INTERVAL: Duration = Duration::from_millis(1);
#[cfg(any(
target_os = "cygwin",
target_os = "horizon",
target_os = "openbsd",
target_os = "redox",
target_os = "wasi"
))]
const FALLBACK_GRACE_CEILING: Duration = Duration::from_secs(5);
#[cfg(any(
target_os = "cygwin",
target_os = "horizon",
target_os = "openbsd",
target_os = "redox",
target_os = "wasi"
))]
const PLATFORM_FALLBACK_GRACE_CEILING: Option<Duration> = Some(FALLBACK_GRACE_CEILING);
#[cfg(not(any(
target_os = "cygwin",
target_os = "horizon",
target_os = "openbsd",
target_os = "redox",
target_os = "wasi"
)))]
const PLATFORM_FALLBACK_GRACE_CEILING: Option<Duration> = None;
type LeaderObserver = fn(Pid) -> LeaderObservation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LeaderObservation {
Running,
ExitedUnreaped,
ExternallyReaped,
#[allow(
dead_code,
reason = "constructed on targets without a non-reaping waitid observer"
)]
Unavailable,
Failed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum TestServerErrorKind {
FilesystemSetupFailed,
SocketPathTooLong,
ExecutableNotFound,
DaemonSpawnFailed,
DaemonExited,
ReadinessProbeFailed,
DaemonPidMismatch,
StartupTimedOut,
ShutdownFailed,
CleanupFailed,
}
pub struct TestServerError {
kind: TestServerErrorKind,
stage: Option<String>,
}
impl fmt::Debug for TestServerError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestServerError")
.field("kind", &self.kind)
.field("stage", &self.stage)
.finish()
}
}
impl TestServerError {
const fn new(kind: TestServerErrorKind) -> Self {
Self { kind, stage: None }
}
fn at(kind: TestServerErrorKind, stage: impl Into<String>) -> Self {
Self {
kind,
stage: Some(stage.into()),
}
}
#[must_use]
pub fn stage(&self) -> Option<&str> {
self.stage.as_deref()
}
#[must_use]
pub const fn kind(&self) -> TestServerErrorKind {
self.kind
}
}
impl fmt::Display for TestServerError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self.kind {
TestServerErrorKind::FilesystemSetupFailed => "test-server filesystem setup failed",
TestServerErrorKind::SocketPathTooLong => "test-server socket path is too long",
TestServerErrorKind::ExecutableNotFound => "test-server executable was not found",
TestServerErrorKind::DaemonSpawnFailed => "test-server daemon spawn failed",
TestServerErrorKind::DaemonExited => "test-server daemon exited during startup",
TestServerErrorKind::ReadinessProbeFailed => "test-server readiness probe failed",
TestServerErrorKind::DaemonPidMismatch => "test-server daemon PID did not match",
TestServerErrorKind::StartupTimedOut => "test-server startup timed out",
TestServerErrorKind::ShutdownFailed => "test-server shutdown failed",
TestServerErrorKind::CleanupFailed => "test-server cleanup failed",
})
}
}
impl std::error::Error for TestServerError {}
#[must_use = "use start to create the isolated test server"]
pub struct TestServerBuilder {
executable: OsString,
lifecycle_timeout: Duration,
output_limits: OutputLimits,
dispatch_limits: DispatchLimits,
#[cfg(feature = "control-mode")]
control_client_limits: ControlClientLimits,
}
fn default_executable() -> OsString {
std::env::var_os("LIBTMUX_TEST_TMUX").unwrap_or_else(|| OsString::from("tmux"))
}
impl fmt::Debug for TestServerBuilder {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestServerBuilder")
.field("lifecycle_timeout", &self.lifecycle_timeout)
.finish_non_exhaustive()
}
}
impl TestServerBuilder {
fn new() -> Self {
Self {
executable: default_executable(),
lifecycle_timeout: scaled(Duration::from_secs(5)),
output_limits: OutputLimits::default(),
dispatch_limits: DispatchLimits::default(),
#[cfg(feature = "control-mode")]
control_client_limits: ControlClientLimits::default(),
}
}
#[must_use = "use the returned builder to retain the limits"]
pub const fn output_limits(mut self, limits: OutputLimits) -> Self {
self.output_limits = limits;
self
}
#[must_use = "use the returned builder to retain the limits"]
pub const fn dispatch_limits(mut self, limits: DispatchLimits) -> Self {
self.dispatch_limits = limits;
self
}
#[cfg(feature = "control-mode")]
#[must_use = "use the returned builder to retain the limits"]
pub const fn control_client_limits(mut self, limits: ControlClientLimits) -> Self {
self.control_client_limits = limits;
self
}
#[must_use = "use the returned builder to retain the executable"]
pub fn tmux_executable(mut self, executable: impl Into<OsString>) -> Self {
self.executable = executable.into();
self
}
#[must_use = "use the returned builder to retain the lifecycle timeout"]
pub fn lifecycle_timeout(mut self, timeout: Duration) -> Self {
self.lifecycle_timeout = timeout;
self
}
pub async fn start(self) -> Result<TestServer, TestServerError> {
self.start_with_leader_observer(leader_exited_unreaped, platform_fallback_grace_ceiling())
.await
}
#[allow(
clippy::too_many_lines,
reason = "server construction and rollback ownership form one startup sequence"
)]
async fn start_with_leader_observer(
self,
leader_observer: LeaderObserver,
fallback_grace_ceiling: Option<Duration>,
) -> Result<TestServer, TestServerError> {
let files = OwnedFiles::create()?;
if !socket_path_fits_tmux(&files.socket_path) {
return Err(TestServerError::new(TestServerErrorKind::SocketPathTooLong));
}
let builder = Server::builder()
.socket_path(&files.socket_path)
.config_file(&files.config_path)
.tmux_executable(self.executable.clone())
.output_limits(self.output_limits)
.dispatch_limits(self.dispatch_limits);
#[cfg(feature = "control-mode")]
let builder = builder.control_client_limits(self.control_client_limits);
let server = builder
.prevent_server_start()
.build()
.map_err(|_| TestServerError::new(TestServerErrorKind::FilesystemSetupFailed))?;
let mut command = ProcessCommand::new(&self.executable);
command
.arg("-D")
.arg("-S")
.arg(&files.socket_path)
.arg("-f")
.arg(&files.config_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env_remove("TMUX")
.env_remove("TMUX_PANE")
.env("TERM", TERM)
.process_group(0);
files.containment.configure(&mut command);
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
let kind = if error.kind() == std::io::ErrorKind::NotFound {
TestServerErrorKind::ExecutableNotFound
} else {
TestServerErrorKind::DaemonSpawnFailed
};
return server_startup_failure(&server, kind).await;
}
};
let mut startup = StartupGuard::new(Lifecycle::new_with_leader_observer(
child,
files,
leader_observer,
fallback_grace_ceiling,
));
let Some(lifecycle) = startup.lifecycle() else {
return server_startup_failure(&server, TestServerErrorKind::CleanupFailed).await;
};
if getpgid(Some(lifecycle.pid)).ok() != Some(lifecycle.pid) {
return startup_failure(&server, startup, TestServerErrorKind::DaemonSpawnFailed).await;
}
let Ok(daemon_pid) = u32::try_from(lifecycle.pid.as_raw_pid()) else {
return startup_failure(&server, startup, TestServerErrorKind::DaemonSpawnFailed).await;
};
let started = Instant::now();
loop {
let Some(lifecycle) = startup.lifecycle_mut() else {
return server_startup_failure(&server, TestServerErrorKind::CleanupFailed).await;
};
match lifecycle.observe_leader() {
LeaderObservation::ExitedUnreaped => {
return startup_failure(&server, startup, TestServerErrorKind::DaemonExited)
.await;
}
LeaderObservation::ExternallyReaped | LeaderObservation::Failed => {
return startup_failure(&server, startup, TestServerErrorKind::ShutdownFailed)
.await;
}
LeaderObservation::Running | LeaderObservation::Unavailable => {}
}
let Some(remaining) = remaining_timeout(started, self.lifecycle_timeout) else {
return startup_timeout(&server, startup).await;
};
match readiness_with_timeout(&server, remaining).await {
Err(()) => return startup_timeout(&server, startup).await,
Ok(Ok(Some(found))) if found == daemon_pid => {
let Some(lifecycle) = startup.disarm() else {
return server_startup_failure(&server, TestServerErrorKind::CleanupFailed)
.await;
};
return Ok(TestServer {
server,
socket_path: lifecycle.files.socket_path.clone(),
daemon_pid,
lifecycle_timeout: self.lifecycle_timeout,
lifecycle: Some(lifecycle),
});
}
Ok(Ok(Some(_))) => {
return startup_failure(
&server,
startup,
TestServerErrorKind::DaemonPidMismatch,
)
.await;
}
Ok(Ok(None)) => {}
Ok(Err(())) => {
return startup_failure(
&server,
startup,
TestServerErrorKind::ReadinessProbeFailed,
)
.await;
}
}
if remaining_timeout(started, self.lifecycle_timeout).is_none() {
return startup_timeout(&server, startup).await;
}
tokio::task::yield_now().await;
}
}
}
#[must_use = "keep the guard alive or call shutdown to clean up its daemon"]
pub struct TestServer {
server: Server,
socket_path: PathBuf,
daemon_pid: u32,
lifecycle_timeout: Duration,
lifecycle: Option<Lifecycle>,
}
impl fmt::Debug for TestServer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestServer")
.field("daemon_pid", &self.daemon_pid)
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum DaemonState {
Running,
Gone(std::process::ExitStatus),
Unreadable,
}
impl DaemonState {
#[must_use]
pub const fn is_running(self) -> bool {
matches!(self, Self::Running)
}
}
impl fmt::Display for DaemonState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Running => formatter.write_str("running"),
Self::Gone(status) => write!(formatter, "gone ({status})"),
Self::Unreadable => formatter.write_str("in an unreadable state"),
}
}
}
impl TestServer {
pub async fn new() -> Result<Self, TestServerError> {
Self::builder().start().await
}
pub fn builder() -> TestServerBuilder {
TestServerBuilder::new()
}
#[must_use]
pub fn server(&self) -> &Server {
&self.server
}
#[must_use]
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
pub async fn session(
&self,
options: impl Into<crate::NewSessionOptions>,
) -> Result<crate::Session, crate::Error> {
self.server().new_session(options).await
}
#[must_use]
pub const fn daemon_pid(&self) -> u32 {
self.daemon_pid
}
pub fn daemon_state(&mut self) -> DaemonState {
self.lifecycle
.as_mut()
.map_or(DaemonState::Unreadable, Lifecycle::daemon_state)
}
pub async fn shutdown(mut self) -> Result<(), TestServerError> {
let executor_failed = self.server.shutdown().await.is_err();
let Some(mut lifecycle) = self.lifecycle.take() else {
return Err(TestServerError::at(
TestServerErrorKind::CleanupFailed,
"lifecycle already taken",
));
};
let timeout = self.lifecycle_timeout;
let waiter = tokio::task::spawn_blocking(move || {
let outcome = lifecycle.cleanup(timeout);
(outcome, lifecycle.failure())
});
let Ok((cleanup, detail)) = waiter.await else {
return Err(TestServerError::at(
TestServerErrorKind::ShutdownFailed,
"cleanup task",
));
};
if executor_failed {
return Err(TestServerError::at(
TestServerErrorKind::ShutdownFailed,
"executor",
));
}
match cleanup {
CleanupOutcome::Complete => Ok(()),
CleanupOutcome::LifecycleFailed | CleanupOutcome::LifecycleAndFilesystemFailed => {
Err(TestServerError::at(
TestServerErrorKind::ShutdownFailed,
detail.unwrap_or_else(|| "daemon did not exit".to_owned()),
))
}
CleanupOutcome::FilesystemFailed => Err(TestServerError::at(
TestServerErrorKind::CleanupFailed,
"files remain",
)),
}
}
}
impl Drop for TestServer {
fn drop(&mut self) {
if let Some(mut lifecycle) = self.lifecycle.take() {
let _ = lifecycle.force_cleanup();
}
}
}
#[cfg(test)]
mod tests;
pub async fn retry_until(
within: Duration,
mut condition: impl AsyncFnMut() -> bool,
) -> Result<(), RetryTimeout> {
let within = scaled(within);
let deadline = Instant::now().checked_add(within);
loop {
if condition().await {
return Ok(());
}
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(RetryTimeout { waited: within });
}
tokio::time::sleep(RETRY_POLL_INTERVAL).await;
}
}
const RETRY_POLL_INTERVAL: Duration = Duration::from_millis(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryTimeout {
waited: Duration,
}
impl RetryTimeout {
#[must_use]
pub const fn waited(self) -> Duration {
self.waited
}
}
impl fmt::Display for RetryTimeout {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "condition did not hold within {:?}", self.waited)
}
}
impl std::error::Error for RetryTimeout {}
static NAME_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[must_use]
pub fn unique_name(prefix: &str) -> String {
let count = NAME_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("{prefix}-{}-{count}", std::process::id())
}
pub fn reap_abandoned_servers(older_than: Duration) -> Result<Vec<PathBuf>, TestServerError> {
let entries = match fs::read_dir(FIXTURE_ROOT) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => {
return Err(TestServerError::new(
TestServerErrorKind::FilesystemSetupFailed,
));
}
};
let mut reaped = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !is_abandoned_fixture(&path, older_than) {
continue;
}
let socket = path.join(SOCKET_NAME);
let _ = ProcessCommand::new("tmux")
.arg("-S")
.arg(&socket)
.arg("kill-server")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if fs::remove_dir_all(&path).is_ok() {
reaped.push(path);
}
}
Ok(reaped)
}
fn is_abandoned_fixture(path: &Path, older_than: Duration) -> bool {
if owner_is_running(path) {
return false;
}
let Ok(metadata) = fs::metadata(path) else {
return false;
};
if !metadata.is_dir() || metadata.permissions().mode() & 0o777 != 0o700 {
return false;
}
if metadata.uid() != rustix::process::getuid().as_raw() {
return false;
}
if !path.join(SOCKET_NAME).exists() {
return false;
}
metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age >= older_than)
}
fn owner_is_running(path: &Path) -> bool {
let Ok(recorded) = fs::read_to_string(path.join(OWNER_NAME)) else {
return true;
};
let Ok(owner) = recorded.trim().parse::<i32>() else {
return true;
};
let Some(owner) = Pid::from_raw(owner) else {
return true;
};
!matches!(test_kill_process(owner), Err(Errno::SRCH))
}