use std::process::Command;
use std::sync::Once;
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) = Command::new(tmux_bin)
.args(["list-sessions", "-F", "#{session_created} #{session_name}"])
.output()
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 _ = Command::new(tmux_bin)
.args(["kill-session", "-t", &format!("={name}")])
.output();
}
}
pub(crate) struct ScratchTmuxSession {
tmux_bin: String,
name: String,
}
impl ScratchTmuxSession {
pub(crate) fn spawn(tmux_bin: &str, name: &str, pane_command: &str) -> Self {
SWEEP_ONCE.call_once(|| sweep_stale_reserved_sessions(tmux_bin));
let status = Command::new(tmux_bin)
.args(["new-session", "-d", "-s", name, pane_command])
.status()
.unwrap_or_else(|e| panic!("spawn `{tmux_bin} new-session -s {name}`: {e}"));
assert!(
status.success(),
"`{tmux_bin} new-session -d -s {name}` failed with {status}; the session was NOT \
created, so no guard may claim it"
);
Self {
tmux_bin: tmux_bin.to_string(),
name: name.to_string(),
}
}
pub(crate) fn name(&self) -> &str {
&self.name
}
pub(crate) fn exists(tmux_bin: &str, name: &str) -> bool {
Command::new(tmux_bin)
.args(["has-session", "-t", &format!("={name}")])
.output()
.is_ok_and(|out| out.status.success())
}
pub(crate) fn tmux_available(tmux_bin: &str) -> bool {
Command::new(tmux_bin)
.arg("-V")
.output()
.is_ok_and(|out| out.status.success())
}
}
impl Drop for ScratchTmuxSession {
fn drop(&mut self) {
let _ = Command::new(&self.tmux_bin)
.args(["kill-session", "-t", &format!("={}", self.name)])
.output();
}
}
#[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 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 name = scratch_name("panic");
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let session = ScratchTmuxSession::spawn(TMUX, &name, "sh");
assert!(
ScratchTmuxSession::exists(TMUX, session.name()),
"fixture precondition: the session must be live before the panic"
);
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(TMUX, &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 owned_name = scratch_name("exact");
let sibling_name = format!("{owned_name}-sibling");
let sibling = ScratchTmuxSession::spawn(TMUX, &sibling_name, "sh");
{
let _owned = ScratchTmuxSession::spawn(TMUX, &owned_name, "sh");
}
assert!(
!ScratchTmuxSession::exists(TMUX, &owned_name),
"the guard must kill its own session on drop"
);
assert!(
ScratchTmuxSession::exists(TMUX, &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 spawning_a_duplicate_name_panics_instead_of_claiming_it() {
if !ScratchTmuxSession::tmux_available(TMUX) {
eprintln!("tmux not available; skipping");
return;
}
let name = scratch_name("dup");
let owner = ScratchTmuxSession::spawn(TMUX, &name, "sh");
let second = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ScratchTmuxSession::spawn(TMUX, &name, "sh")
}));
assert!(
second.is_err(),
"a duplicate `new-session` must panic, not hand out a second owner"
);
assert!(
ScratchTmuxSession::exists(TMUX, &name),
"the failed spawn must leave the original owner's session untouched"
);
drop(owner);
assert!(!ScratchTmuxSession::exists(TMUX, &name));
}
}