use crate::app::tmux_handler::notifications::mux::{MuxAttachPending, tests as mux_tests};
use crate::app::tmux_handler::notifications::mux_test_seams::{
DAEMON_DEADLINE, SettledSend, attach_until_installed, quiesce, wait_until,
};
use crate::app::window_state::WindowState;
use std::time::{Duration, Instant};
fn attached_two_pane_state(
tag: &str,
jobs_to_ignore: Vec<String>,
) -> (WindowState, std::path::PathBuf) {
let path = mux_tests::socket_path(tag);
mux_tests::spawn_daemon(&path);
attach_two_panes(tag, &path, &path, jobs_to_ignore)
}
fn attach_two_panes(
tag: &str,
connect_to: &std::path::Path,
daemon_socket: &std::path::Path,
jobs_to_ignore: Vec<String>,
) -> (WindowState, std::path::PathBuf) {
let path = daemon_socket.to_path_buf();
let mut config = crate::config::Config::default();
config.shell.confirm_close_running_jobs = true;
config.shell.jobs_to_ignore = jobs_to_ignore;
let runtime = std::sync::Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime"),
);
let mut ws = WindowState::new(config, runtime);
attach_until_installed(
&mut ws,
|| {
let core_client =
par_term_emu_core_rust::mux::MuxClient::connect(connect_to).expect("connect");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(core_client)).unwrap();
drop(tx);
MuxAttachPending {
name: tag.to_string(),
rx,
}
},
connect_to,
);
wait_until("%0 mapped", || {
if let Some(t) = ws.tmux_state.transport.as_ref() {
let _ = t.send_command_no_wait("refresh-client -t %0 -C 80x24");
}
ws.check_mux_notifications();
ws.tmux_state.tmux_pane_owners.contains_key(&0)
});
quiesce(&mut ws);
assert!(ws.split_pane_via_mux(true), "split gives a second pane");
wait_until("%1 mapped", || {
ws.check_mux_notifications();
ws.tmux_state.tmux_pane_owners.contains_key(&1)
});
(ws, path)
}
fn send(ws: &WindowState, command: &str) -> Vec<String> {
ws.tmux_state
.transport
.as_ref()
.expect("transport")
.send_settled(command)
.expect("daemon command")
}
fn query(ws: &WindowState, command: &str) -> Vec<String> {
ws.tmux_state
.transport
.as_ref()
.expect("transport")
.query_settled(command)
.expect("daemon query")
}
fn register_test_seed_and_quiesce(ws: &mut WindowState, pane: u64) {
*ws.tmux_state.mux_seeds_in_flight.entry(pane).or_default() += 1;
quiesce(ws);
}
fn daemon_pane_count(ws: &WindowState) -> usize {
query(ws, "list-panes")
.iter()
.filter(|l| l.starts_with('%'))
.count()
}
fn wait_for_pane_count(ws: &mut WindowState, want: usize, what: &str) {
wait_until(what, || {
ws.check_mux_notifications();
daemon_pane_count(ws) == want
});
}
fn wait_for_foreground(ws: &WindowState, pane: u64, want: &str) {
let mut reply = Vec::new();
let transport = ws.tmux_state.transport.as_ref().expect("transport");
let seen = super::notifications::mux_test_seams::poll_until(|| {
if let Ok(lines) = transport.query_settled(&format!("pane-info -t %{pane}")) {
reply = lines;
}
par_term_mux::pane_foreground_command(&reply, pane).as_deref() == Some(want)
});
assert!(
seen,
"daemon never reported {want:?} as the foreground of %{pane}: {reply:?}"
);
}
#[test]
fn closing_an_attached_pane_running_a_job_asks_first() {
let shells = ["bash", "zsh", "fish", "sh"].map(String::from).to_vec();
let (mut ws, path) = attached_two_pane_state("mux-close-confirm", shells);
let target = ws.focused_mux_pane_from_native().expect("focused mux pane");
if !daemon_reports_foreground(&ws, target) {
eprintln!("old daemon (no pane-info cmd token): asserting the unconfirmed close");
send(&ws, &format!("send-keys -t %{target} -l 'sleep 100'"));
send(&ws, &format!("send-keys -t %{target} Enter"));
quiesce(&mut ws);
assert!(!ws.close_focused_pane());
assert!(!ws.overlay_ui.close_confirmation_ui.is_visible());
wait_for_pane_count(&mut ws, 1, "unconfirmed close never killed the pane");
let _ = std::fs::remove_file(&path);
return;
}
send(&ws, &format!("send-keys -t %{target} -l 'sleep 100'"));
send(&ws, &format!("send-keys -t %{target} Enter"));
wait_for_foreground(&ws, target, "sleep");
quiesce(&mut ws);
assert!(
!ws.close_focused_pane(),
"the close must wait for the dialog"
);
assert!(
ws.overlay_ui.close_confirmation_ui.is_visible(),
"a running foreground job must raise the confirmation"
);
assert_eq!(
daemon_pane_count(&ws),
2,
"nothing is killed before confirming"
);
assert!(!ws.close_focused_pane_confirmed());
wait_for_pane_count(&mut ws, 1, "confirmed close never killed the pane");
let _ = std::fs::remove_file(&path);
}
type Stream = std::sync::Arc<par_term_emu_core_rust::mux::LocalStream>;
type Trigger = std::sync::Arc<std::sync::Mutex<Option<(String, Duration)>>>;
struct StallProxy {
trigger: Trigger,
}
impl StallProxy {
fn spawn(listen: &std::path::Path, daemon: &std::path::Path) -> Self {
use par_term_emu_core_rust::mux::{
accept_connection, bind_local_listener, connect_local_stream,
};
let trigger: Trigger = std::sync::Arc::default();
let listener = bind_local_listener(listen).expect("proxy binds");
let daemon = daemon.to_path_buf();
let accept_trigger = std::sync::Arc::clone(&trigger);
std::thread::spawn(move || {
while let Ok((client, _abort)) = accept_connection(&listener) {
let Ok(upstream) = connect_local_stream(&daemon) else {
continue;
};
let (client, upstream) = (Stream::new(client), Stream::new(upstream));
let trigger = std::sync::Arc::clone(&accept_trigger);
let (c, u) = (Stream::clone(&client), Stream::clone(&upstream));
std::thread::spawn(move || relay_commands(&c, &u, &trigger));
std::thread::spawn(move || relay_bytes(&upstream, &client));
}
});
Self { trigger }
}
fn arm(&self, substring: &str, hold: Duration) {
*self.trigger.lock().unwrap() = Some((substring.to_string(), hold));
}
fn fired(&self) -> bool {
self.trigger.lock().unwrap().is_none()
}
}
fn relay_commands(from: &Stream, to: &Stream, trigger: &Trigger) {
use std::io::{BufRead, Write};
let mut reader = std::io::BufReader::new(&**from);
let mut writer = &**to;
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
let hold = {
let mut armed = trigger.lock().unwrap();
match armed.as_ref() {
Some((substring, hold)) if line.contains(substring.as_str()) => {
let hold = *hold;
*armed = None;
Some(hold)
}
_ => None,
}
};
if let Some(hold) = hold {
std::thread::sleep(hold);
}
if writer.write_all(line.as_bytes()).is_err() {
return;
}
}
}
fn relay_bytes(from: &Stream, to: &Stream) {
use std::io::{Read, Write};
let (mut reader, mut writer) = (&**from, &**to);
let mut buf = [0u8; 16 * 1024];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => return,
Ok(n) => {
if writer.write_all(&buf[..n]).is_err() {
return;
}
}
}
}
}
const STALL: Duration = Duration::from_secs(8);
fn proxied_pane_running_sleep(
tag: &str,
) -> Option<(
WindowState,
StallProxy,
std::path::PathBuf,
std::path::PathBuf,
)> {
let (ws, proxy, daemon, proxy_path) = proxied_attach(tag)?;
let target = ws.focused_mux_pane_from_native().expect("focused mux pane");
send(&ws, &format!("send-keys -t %{target} -l 'sleep 100'"));
send(&ws, &format!("send-keys -t %{target} Enter"));
wait_for_foreground(&ws, target, "sleep");
Some((ws, proxy, daemon, proxy_path))
}
#[cfg(unix)]
#[test]
fn a_slow_in_flight_daemon_command_does_not_skip_the_job_confirmation() {
use crate::app::tmux_handler::tmux_state::MuxJob;
let Some((mut ws, proxy, daemon, proxy_path)) = proxied_pane_running_sleep("mux-close-stall")
else {
return;
};
quiesce(&mut ws);
proxy.arm("refresh-client -t %0", STALL);
let transport = ws.tmux_state.transport.as_ref().expect("transport");
assert!(transport.submit_job(MuxJob::SeedPanes(vec![0])));
wait_until("the seed query is held at the proxy", || proxy.fired());
let started = Instant::now();
assert!(
!ws.close_focused_pane(),
"the close must not end the window"
);
let took = started.elapsed();
assert!(
ws.overlay_ui.close_confirmation_ui.is_visible(),
"a running job behind a slow in-flight command must still raise the confirmation"
);
assert_eq!(ws.overlay_ui.close_confirmation_ui.command_name(), "sleep");
assert!(
took < STALL / 2,
"the close check waited out the in-flight command ({took:?})"
);
register_test_seed_and_quiesce(&mut ws, 0);
assert_eq!(
daemon_pane_count(&ws),
2,
"the job's pane must survive the close"
);
let _ = std::fs::remove_file(&daemon);
let _ = std::fs::remove_file(&proxy_path);
}
#[cfg(unix)]
#[test]
fn a_job_check_the_daemon_cannot_answer_in_time_holds_the_close() {
let Some((mut ws, proxy, daemon, proxy_path)) = proxied_pane_running_sleep("mux-close-hung")
else {
return;
};
let target = ws.focused_mux_pane_from_native().expect("focused mux pane");
quiesce(&mut ws);
proxy.arm(&format!("pane-info -t %{target}"), STALL);
let stalled_at = Instant::now();
assert!(
!ws.close_focused_pane(),
"the close must not end the window"
);
let took = stalled_at.elapsed();
assert!(
took < STALL / 2,
"the close blocked the event loop on an unanswered check ({took:?})"
);
assert!(
ws.overlay_ui.close_confirmation_ui.is_visible(),
"a check that could not answer must hold the close for confirmation"
);
quiesce(&mut ws);
assert_eq!(
daemon_pane_count(&ws),
2,
"nothing is killed while the check is unanswered"
);
let _ = std::fs::remove_file(&daemon);
let _ = std::fs::remove_file(&proxy_path);
}
fn daemon_window_count(ws: &WindowState) -> usize {
query(ws, "list-windows")
.iter()
.filter(|l| l.contains('@'))
.count()
}
fn proxied_tab_with_a_background_job(
tag: &str,
) -> Option<(
WindowState,
StallProxy,
std::path::PathBuf,
std::path::PathBuf,
u64,
)> {
let (mut ws, proxy, daemon, proxy_path) = proxied_attach(tag)?;
let tab = ws.tab_manager.active_tab_id().expect("active tab");
send(&ws, "new-window");
let deadline = Instant::now() + DAEMON_DEADLINE;
while ws.tmux_state.tmux_pane_owners.len() < 3 {
assert!(Instant::now() < deadline, "the second window never mapped");
for line in send(&ws, "list-panes") {
if let Some(pane) = line.strip_prefix('%').and_then(|p| p.parse::<u64>().ok())
&& !ws.tmux_state.tmux_pane_owners.contains_key(&pane)
{
send(&ws, &format!("refresh-client -t %{pane} -C 80x24"));
}
}
ws.check_mux_notifications();
std::thread::sleep(Duration::from_millis(50));
}
ws.tab_manager.switch_to(tab);
let focused = ws.focused_mux_pane_from_native().expect("focused mux pane");
let job = ws
.tmux_state
.tab_tmux_pane_ids(tab)
.into_iter()
.find(|p| *p != focused)
.expect("the tab's other pane");
send(&ws, &format!("send-keys -t %{job} -l 'sleep 100'"));
send(&ws, &format!("send-keys -t %{job} Enter"));
wait_for_foreground(&ws, job, "sleep");
Some((ws, proxy, daemon, proxy_path, job))
}
fn daemon_reports_foreground(ws: &WindowState, pane: u64) -> bool {
let deadline = Instant::now() + DAEMON_DEADLINE;
loop {
let reply = ws
.tmux_state
.transport
.as_ref()
.expect("transport")
.query_settled(&format!("pane-info -t %{pane}"));
if let Ok(lines) = reply.as_ref()
&& lines.iter().any(|l| l.contains(" cmd="))
{
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn proxied_attach(
tag: &str,
) -> Option<(
WindowState,
StallProxy,
std::path::PathBuf,
std::path::PathBuf,
)> {
let shells = ["bash", "zsh", "fish", "sh"].map(String::from).to_vec();
let daemon = mux_tests::socket_path(&format!("{tag}-d"));
let proxy_path = mux_tests::socket_path(&format!("{tag}-p"));
mux_tests::spawn_daemon(&daemon);
let proxy = StallProxy::spawn(&proxy_path, &daemon);
let (ws, _) = attach_two_panes(tag, &proxy_path, &daemon, shells);
let target = ws.focused_mux_pane_from_native().expect("focused mux pane");
if !daemon_reports_foreground(&ws, target) {
eprintln!("old daemon (no pane-info cmd token): nothing to confirm on");
let _ = std::fs::remove_file(&daemon);
let _ = std::fs::remove_file(&proxy_path);
return None;
}
Some((ws, proxy, daemon, proxy_path))
}
#[cfg(unix)]
#[test]
fn closing_an_attached_tab_with_a_daemon_job_asks_first_past_a_slow_command() {
use crate::app::tmux_handler::tmux_state::MuxJob;
let Some((mut ws, proxy, daemon, proxy_path, _job)) =
proxied_tab_with_a_background_job("mux-tabclose-stall")
else {
return;
};
proxy.arm("refresh-client -t %0", STALL);
let transport = ws.tmux_state.transport.as_ref().expect("transport");
assert!(transport.submit_job(MuxJob::SeedPanes(vec![0])));
wait_until("the seed query is held at the proxy", || proxy.fired());
let started = Instant::now();
assert!(!ws.close_current_tab(), "the close must not end the window");
let took = started.elapsed();
assert!(
ws.overlay_ui.close_confirmation_ui.is_visible(),
"a daemon job in the tab must raise the confirmation"
);
assert_eq!(ws.overlay_ui.close_confirmation_ui.command_name(), "sleep");
assert!(
took < STALL / 2,
"the close check waited out the in-flight command ({took:?})"
);
register_test_seed_and_quiesce(&mut ws, 0);
assert_eq!(
daemon_window_count(&ws),
2,
"the job's window must survive the close"
);
let _ = std::fs::remove_file(&daemon);
let _ = std::fs::remove_file(&proxy_path);
}
#[cfg(unix)]
#[test]
fn a_tab_job_check_the_daemon_cannot_answer_in_time_holds_the_close() {
let Some((mut ws, proxy, daemon, proxy_path, _job)) =
proxied_tab_with_a_background_job("mux-tabclose-hung")
else {
return;
};
proxy.arm("pane-info", STALL);
let stalled_at = Instant::now();
assert!(!ws.close_current_tab(), "the close must not end the window");
let took = stalled_at.elapsed();
assert!(
took < STALL / 2,
"the close blocked the event loop on an unanswered check ({took:?})"
);
assert!(
ws.overlay_ui.close_confirmation_ui.is_visible(),
"a check that could not answer must hold the close for confirmation"
);
quiesce(&mut ws);
assert_eq!(
daemon_window_count(&ws),
2,
"nothing is killed while the check is unanswered"
);
let _ = std::fs::remove_file(&daemon);
let _ = std::fs::remove_file(&proxy_path);
}
#[test]
fn confirming_a_job_on_the_last_attached_tab_still_asks_about_the_session() {
let shells = ["bash", "zsh", "fish", "sh"].map(String::from).to_vec();
let (mut ws, path) = attached_two_pane_state("mux-tabclose-last", shells);
let target = ws.focused_mux_pane_from_native().expect("focused mux pane");
if !daemon_reports_foreground(&ws, target) {
eprintln!("old daemon (no pane-info cmd token): nothing to confirm on");
let _ = std::fs::remove_file(&path);
return;
}
send(&ws, &format!("send-keys -t %{target} -l 'sleep 100'"));
send(&ws, &format!("send-keys -t %{target} Enter"));
wait_for_foreground(&ws, target, "sleep");
assert!(
!ws.close_current_tab(),
"the close must wait for the dialog"
);
assert!(
ws.overlay_ui.close_confirmation_ui.is_visible(),
"the job is asked about first"
);
assert!(!ws.overlay_ui.mux_last_tab_ui.is_visible());
ws.overlay_ui.close_confirmation_ui.hide();
assert!(!ws.close_current_tab_confirmed());
assert!(
ws.overlay_ui.mux_last_tab_ui.is_visible(),
"confirming the job must still ask before ending the session"
);
assert_eq!(
daemon_window_count(&ws),
1,
"the session's only window survives"
);
let _ = std::fs::remove_file(&path);
}