use std::sync::Once;
fn tmux_output(tmux_bin: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
let argv: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
tmux_output_owned(tmux_bin, &argv)
}
fn tmux_output_owned(tmux_bin: &str, args: &[String]) -> std::io::Result<std::process::Output> {
let _path_env = super::lock_path_env();
super::tmux_spawn(tmux_bin, args)
}
fn socket_prefix(socket: Option<&str>) -> Vec<String> {
match socket {
Some(name) => vec!["-L".to_string(), name.to_string()],
None => Vec::new(),
}
}
const STALE_SESSION_AGE_SECS: i64 = 1_800;
static SWEEP_ONCE: Once = Once::new();
pub(crate) fn reserved_session_name(tag: &str) -> String {
format!(
"{}{tag}-{}",
trusty_common::session_naming::RESERVED_TEST_PREFIX,
std::process::id()
)
}
pub(crate) fn reserved_project_slug(tag: &str) -> String {
let leaf = trusty_common::session_naming::RESERVED_TEST_PREFIX
.strip_prefix(trusty_common::session_naming::PREFIX)
.unwrap_or(trusty_common::session_naming::RESERVED_TEST_PREFIX)
.trim_end_matches('-');
format!("{leaf}-{tag}")
}
fn stale_reserved_names(listing: &str, now_epoch: i64, max_age_secs: i64) -> Vec<String> {
listing
.lines()
.filter_map(|line| line.trim_end().split_once(' '))
.filter_map(|(created, name)| Some((created.trim().parse::<i64>().ok()?, name)))
.filter(|(created, name)| {
now_epoch - created > max_age_secs
&& trusty_common::session_naming::is_reserved_test_session_name(name)
})
.map(|(_, name)| name.to_string())
.collect()
}
fn sweep_stale_reserved_sessions(tmux_bin: &str) {
let Ok(out) = tmux_output(
tmux_bin,
&["list-sessions", "-F", "#{session_created} #{session_name}"],
) else {
return;
};
if !out.status.success() {
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
for name in stale_reserved_names(
&String::from_utf8_lossy(&out.stdout),
now,
STALE_SESSION_AGE_SECS,
) {
eprintln!("test-support: killing leaked test tmux session '{name}' (#6116)");
let _ = tmux_output(
tmux_bin,
&[
"kill-session",
"-t",
&trusty_common::tmux::exact_session_target(&name),
],
);
}
}
pub(crate) struct ScratchTmuxSession {
tmux_bin: String,
socket: Option<String>,
name: String,
}
impl ScratchTmuxSession {
#[allow(dead_code)]
pub(crate) fn spawn(tmux_bin: &str, name: &str, pane_command: &str) -> Self {
Self::spawn_in(tmux_bin, name, None, pane_command)
}
#[allow(dead_code)]
pub(crate) fn spawn_in(
tmux_bin: &str,
name: &str,
cwd: Option<&std::path::Path>,
pane_command: &str,
) -> Self {
Self::spawn_in_on_socket(tmux_bin, None, name, cwd, pane_command)
}
pub(crate) fn spawn_on_socket(
tmux_bin: &str,
socket: Option<&str>,
name: &str,
pane_command: &str,
) -> Self {
Self::spawn_in_on_socket(tmux_bin, socket, name, None, pane_command)
}
pub(crate) fn spawn_in_on_socket(
tmux_bin: &str,
socket: Option<&str>,
name: &str,
cwd: Option<&std::path::Path>,
pane_command: &str,
) -> Self {
if socket.is_none() {
SWEEP_ONCE.call_once(|| sweep_stale_reserved_sessions(tmux_bin));
}
let mut args: Vec<String> = socket_prefix(socket);
args.extend(
["new-session", "-d", "-s", name]
.iter()
.map(|s| (*s).to_string()),
);
if let Some(dir) = cwd {
args.push("-c".to_string());
args.push(dir.to_string_lossy().into_owned());
}
args.push(pane_command.to_string());
let out = tmux_output_owned(tmux_bin, &args)
.unwrap_or_else(|e| panic!("spawn `{tmux_bin} new-session -s {name}`: {e}"));
assert!(
out.status.success(),
"`{tmux_bin} new-session -d -s {name}` failed with {}; the session was NOT \
created, so no guard may claim it. tmux said: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
Self {
tmux_bin: tmux_bin.to_string(),
socket: socket.map(str::to_string),
name: name.to_string(),
}
}
pub(crate) fn name(&self) -> &str {
&self.name
}
#[allow(dead_code)]
pub(crate) fn exists(tmux_bin: &str, name: &str) -> bool {
Self::exists_on_socket(tmux_bin, None, name)
}
pub(crate) fn exists_on_socket(tmux_bin: &str, socket: Option<&str>, name: &str) -> bool {
let mut args = socket_prefix(socket);
args.push("has-session".to_string());
args.push("-t".to_string());
args.push(trusty_common::tmux::exact_session_target(name));
tmux_output_owned(tmux_bin, &args).is_ok_and(|out| out.status.success())
}
pub(crate) fn tmux_available(tmux_bin: &str) -> bool {
tmux_output(tmux_bin, &["-V"]).is_ok_and(|out| out.status.success())
}
}
impl Drop for ScratchTmuxSession {
fn drop(&mut self) {
let mut args = socket_prefix(self.socket.as_deref());
args.push("kill-session".to_string());
args.push("-t".to_string());
args.push(trusty_common::tmux::exact_session_target(&self.name));
let _ = tmux_output_owned(&self.tmux_bin, &args);
}
}
fn live_session_names(tmux_bin: &str, socket: Option<&str>) -> std::collections::BTreeSet<String> {
let mut args = socket_prefix(socket);
args.push("list-sessions".to_string());
args.push("-F".to_string());
args.push("#{session_name}".to_string());
let Ok(out) = tmux_output_owned(tmux_bin, &args) else {
return std::collections::BTreeSet::new();
};
if !out.status.success() {
return std::collections::BTreeSet::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim_end().to_string())
.filter(|l| !l.is_empty())
.collect()
}
fn sessions_with_pane_under(
tmux_bin: &str,
socket: Option<&str>,
root: &std::path::Path,
) -> std::collections::BTreeSet<String> {
let mut args = socket_prefix(socket);
args.push("list-panes".to_string());
args.push("-a".to_string());
args.push("-F".to_string());
args.push("#{session_name}\t#{pane_current_path}".to_string());
let Ok(out) = tmux_output_owned(tmux_bin, &args) else {
return std::collections::BTreeSet::new();
};
if !out.status.success() {
return std::collections::BTreeSet::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| line.trim_end().split_once('\t'))
.filter(|(_, pane_path)| std::path::Path::new(pane_path).starts_with(root))
.map(|(name, _)| name.to_string())
.collect()
}
pub(crate) struct FixtureTmuxSessions {
tmux_bin: String,
socket: Option<String>,
root: std::path::PathBuf,
preexisting: std::collections::BTreeSet<String>,
}
impl FixtureTmuxSessions {
#[allow(dead_code)]
pub(crate) fn watch(tmux_bin: &str, root: &std::path::Path) -> Self {
Self::watch_on_socket(tmux_bin, None, root)
}
pub(crate) fn watch_on_socket(
tmux_bin: &str,
socket: Option<&str>,
root: &std::path::Path,
) -> Self {
Self {
tmux_bin: tmux_bin.to_string(),
socket: socket.map(str::to_string),
root: root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
preexisting: live_session_names(tmux_bin, socket),
}
}
pub(crate) fn spawned(&self) -> Vec<String> {
sessions_with_pane_under(&self.tmux_bin, self.socket.as_deref(), &self.root)
.into_iter()
.filter(|name| !self.preexisting.contains(name))
.collect()
}
}
impl Drop for FixtureTmuxSessions {
fn drop(&mut self) {
for name in self.spawned() {
eprintln!("test-support: killing fixture tmux session '{name}' (#6542)");
let mut args = socket_prefix(self.socket.as_deref());
args.push("kill-session".to_string());
args.push("-t".to_string());
args.push(trusty_common::tmux::exact_session_target(&name));
let _ = tmux_output_owned(&self.tmux_bin, &args);
}
}
}
pub(crate) struct PrivateTmuxServer {
tmux_bin: String,
socket: String,
shim_dir: tempfile::TempDir,
}
#[allow(dead_code)] impl PrivateTmuxServer {
pub(crate) fn new(tmux_bin: &str, tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
Self {
tmux_bin: tmux_bin.to_string(),
socket: format!(
"{}sock-{tag}-{}-{nanos}",
trusty_common::session_naming::RESERVED_TEST_PREFIX,
std::process::id()
),
shim_dir: tempfile::tempdir().expect("shim dir"),
}
}
pub(crate) fn name(&self) -> &str {
&self.socket
}
pub(crate) fn shim_bin(&self) -> String {
use std::os::unix::fs::PermissionsExt;
let path = self.shim_dir.path().join("tmux-private");
if !path.exists() {
let script = format!(
"#!/bin/sh\nexec '{}' -L '{}' \"$@\"\n",
self.tmux_bin, self.socket
);
std::fs::write(&path, script).expect("write tmux shim");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("chmod tmux shim");
}
path.to_string_lossy().into_owned()
}
pub(crate) fn query(&self, args: &[&str]) -> Option<String> {
let mut argv = socket_prefix(Some(&self.socket));
argv.extend(args.iter().map(|a| (*a).to_string()));
let out = tmux_output_owned(&self.tmux_bin, &argv).ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}
impl Drop for PrivateTmuxServer {
fn drop(&mut self) {
let _ = tmux_output(&self.tmux_bin, &["-L", &self.socket, "kill-server"]);
}
}
#[cfg(test)]
mod tests {
use super::*;
const TMUX: &str = "tmux";
fn scratch_name(tag: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
reserved_session_name(&format!("guard-{tag}-{nanos}"))
}
#[test]
fn the_fixture_spawn_seam_captures_output() {
let out = tmux_output("/bin/echo", &["disclaim-seam"]).expect("spawn /bin/echo");
assert!(out.status.success(), "echo exited {}", out.status);
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
"disclaim-seam",
"the seam must capture stdout the way `.output()` does"
);
}
#[test]
fn no_raw_process_spawn_survives_in_this_fixture() {
let src = include_str!("test_tmux_session.rs");
let needle = concat!("Command", "::new");
assert_eq!(
src.matches(needle).count(),
0,
"a raw process spawn is back in this fixture — the tmux server it forks takes \
its TCC responsible process from the signed parent and macOS prompts the \
operator once per test run (#7060). Route it through `tmux_output` instead."
);
}
#[test]
fn the_spawn_seam_takes_the_path_lock() {
let src = include_str!("test_tmux_session.rs");
assert!(
src.contains("let _path_env = super::lock_path_env();"),
"`tmux_output_owned` must hold the target's PATH lock across the exec — a bare \
`tmux` is resolved through `$PATH`, and a concurrent `set_var` tears that read \
(#7996)"
);
}
#[test]
fn a_minted_name_is_one_the_daemon_refuses() {
let name = reserved_session_name("mint");
assert!(trusty_common::session_naming::is_reserved_test_session_name(&name));
assert!(trusty_common::session_naming::is_managed_session_name(
&name
));
}
#[test]
fn a_derived_project_slug_yields_a_reserved_name() {
let derived = trusty_common::session_naming::build_managed_session_name(
Some(&reserved_project_slug("deadrt1234")),
std::path::Path::new("/tmp"),
&[],
)
.expect("derive a name");
assert!(
trusty_common::session_naming::is_reserved_test_session_name(&derived),
"a fixture's derived name must land in the reserved namespace, got {derived}"
);
}
#[test]
fn stale_sweep_selects_only_old_reserved_sessions() {
let now = 1_000_000;
let listing = "\
900000 tm-xtest-deadrt1-01
999999 tm-xtest-running-now-01
900000 tm-trusty-tools-01
900000 work
not-an-epoch tm-xtest-garbage-01
";
assert_eq!(
stale_reserved_names(listing, now, STALE_SESSION_AGE_SECS),
vec!["tm-xtest-deadrt1-01".to_string()],
"only the aged, reserved, parseable session may be killed"
);
}
#[test]
fn drop_kills_the_session_even_when_the_body_panics() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let server = PrivateTmuxServer::new(TMUX, "panic");
let name = scratch_name("panic");
let session = ScratchTmuxSession::spawn_on_socket(TMUX, Some(server.name()), &name, "sh");
assert!(
ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), session.name()),
"fixture precondition: the session must be live before the panic"
);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _owned = session;
panic!("simulated mid-test failure while the guard is alive");
}));
assert!(
outcome.is_err(),
"the closure must actually have panicked, or this proves nothing"
);
assert!(
!ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &name),
"session '{name}' survived a panicking test — this is #6116, where a \
post-hoc kill-session placed after the code under test never ran"
);
}
#[test]
fn drop_kills_only_the_exact_name_it_created() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let server = PrivateTmuxServer::new(TMUX, "exact"); let owned_name = scratch_name("exact");
let sibling_name = format!("{owned_name}-sibling");
let sibling =
ScratchTmuxSession::spawn_on_socket(TMUX, Some(server.name()), &sibling_name, "sh");
{
let _owned =
ScratchTmuxSession::spawn_on_socket(TMUX, Some(server.name()), &owned_name, "sh");
}
assert!(
!ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &owned_name),
"the guard must kill its own session on drop"
);
assert!(
ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &sibling_name),
"'{sibling_name}' shares a prefix with the dropped guard's name but was created \
by someone else; a bare `-t` target would have killed it"
);
drop(sibling);
}
#[test]
fn a_session_opened_under_the_root_is_killed_on_drop() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let server = PrivateTmuxServer::new(TMUX, "under"); let root = tempfile::tempdir().expect("fixture root");
let name = scratch_name("under");
let guard = FixtureTmuxSessions::watch_on_socket(TMUX, Some(server.name()), root.path());
let owner = ScratchTmuxSession::spawn_in_on_socket(
TMUX,
Some(server.name()),
&name,
Some(root.path()),
"sh",
);
assert_eq!(
guard.spawned(),
vec![name.clone()],
"the guard must claim the session that appeared under its root"
);
drop(guard);
assert!(
!ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &name),
"session '{name}' survived the guard — this is #6542, where the \
guided-fallback tests left one tm-<uuid> session behind per run"
);
drop(owner);
}
#[test]
fn a_session_outside_the_root_is_left_alone() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let server = PrivateTmuxServer::new(TMUX, "outside");
let root = tempfile::tempdir().expect("fixture root");
let elsewhere = tempfile::tempdir().expect("unrelated dir");
let name = scratch_name("outside");
let guard = FixtureTmuxSessions::watch_on_socket(TMUX, Some(server.name()), root.path());
let sibling = ScratchTmuxSession::spawn_in_on_socket(
TMUX,
Some(server.name()),
&name,
Some(elsewhere.path()),
"sh",
);
assert!(
guard.spawned().is_empty(),
"a session outside the fixture root is not this guard's to claim"
);
drop(guard);
assert!(
ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &name),
"'{name}' sits outside the fixture root and belongs to someone else"
);
drop(sibling);
}
#[test]
fn a_session_predating_the_guard_is_left_alone() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let server = PrivateTmuxServer::new(TMUX, "predates"); let root = tempfile::tempdir().expect("fixture root");
let name = scratch_name("predates");
let earlier = ScratchTmuxSession::spawn_in_on_socket(
TMUX,
Some(server.name()),
&name,
Some(root.path()),
"sh",
);
let guard = FixtureTmuxSessions::watch_on_socket(TMUX, Some(server.name()), root.path());
assert!(
guard.spawned().is_empty(),
"a session that predates the guard is not new, so not the guard's"
);
drop(guard);
assert!(
ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &name),
"'{name}' predates the guard and must survive it"
);
drop(earlier);
}
#[test]
fn spawning_a_duplicate_name_panics_instead_of_claiming_it() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let server = PrivateTmuxServer::new(TMUX, "dup"); let name = scratch_name("dup");
let owner = ScratchTmuxSession::spawn_on_socket(TMUX, Some(server.name()), &name, "sh");
let second = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ScratchTmuxSession::spawn_on_socket(TMUX, Some(server.name()), &name, "sh")
}));
assert!(
second.is_err(),
"a duplicate `new-session` must panic, not hand out a second owner"
);
assert!(
ScratchTmuxSession::exists_on_socket(TMUX, Some(server.name()), &name),
"the failed spawn must leave the original owner's session untouched"
);
drop(owner);
assert!(!ScratchTmuxSession::exists_on_socket(
TMUX,
Some(server.name()),
&name
));
}
}