use std::process::Command;
pub(crate) struct ScratchTmuxSession {
tmux_bin: String,
name: String,
}
impl ScratchTmuxSession {
pub(crate) fn spawn(tmux_bin: &str, name: &str, pane_command: &str) -> Self {
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());
format!("tm-scratchguard-{tag}-{}-{nanos}", std::process::id())
}
#[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));
}
}