use crate::app::window_state::WindowState;
use par_term_mux::MuxSessionClient;
use std::time::{Duration, Instant};
#[derive(Clone)]
pub(crate) struct MuxReconnect {
pub(crate) session: String,
pub(crate) windows: Vec<String>,
pub(crate) attempts: Vec<Instant>,
}
pub(crate) const MAX_ATTEMPTS: usize = 3;
pub(crate) const ATTEMPT_WINDOW: Duration = Duration::from_secs(60);
impl WindowState {
pub(crate) fn capture_mux_reconnect(&mut self) {
if !self.config.load().tmux.mux_auto_reconnect {
return;
}
let Some(session) = self.tmux_state.tmux_session_name.clone() else {
return;
};
let windows: Vec<String> = self
.tab_manager
.tabs()
.iter()
.filter_map(|tab| self.tmux_state.tmux_sync.get_window(tab.id).map(|_| tab))
.map(|tab| {
let title = tab.title.trim();
let placeholder = title.is_empty()
|| title.starts_with("par-mux @")
|| title.starts_with("tmux @");
(if placeholder { "" } else { title }).to_string()
})
.collect();
if windows.is_empty() {
return;
}
let attempts = self
.tmux_state
.mux_reconnect
.take()
.map(|pending| pending.attempts)
.unwrap_or_default();
crate::debug_info!(
"MUX",
"daemon lost — captured reconnect: session '{session}', {} window(s)",
windows.len()
);
self.tmux_state.mux_reconnect = Some(MuxReconnect {
session,
windows,
attempts,
});
}
pub(crate) fn trigger_mux_reconnect(&mut self) {
let Some(pending) = self.tmux_state.mux_reconnect.as_ref() else {
return;
};
let now = Instant::now();
let mut attempts: Vec<Instant> = pending
.attempts
.iter()
.copied()
.filter(|t| now.duration_since(*t) < ATTEMPT_WINDOW)
.collect();
if attempts.len() >= MAX_ATTEMPTS {
let session = pending.session.clone();
self.tmux_state.mux_reconnect = None;
log::error!(
"MUX: auto-reconnect for '{session}' gave up after {MAX_ATTEMPTS} attempts in 60 s"
);
self.record_mux_error(format!(
"par-mux: auto-reconnect gave up after {MAX_ATTEMPTS} attempts in 60 s — \
start the daemon and attach to '{session}' manually"
));
return;
}
attempts.push(now);
let session = pending.session.clone();
if let Some(pending) = self.tmux_state.mux_reconnect.as_mut() {
pending.attempts = attempts;
}
crate::debug_info!("MUX", "auto-reconnect: re-attaching to '{session}'");
self.begin_mux_session_attach(&session);
}
pub(crate) fn consume_mux_reconnect_windows(
&mut self,
name: &str,
outcome: &par_term_mux::AttachOutcome,
) -> Option<Vec<String>> {
let pending = self.tmux_state.mux_reconnect.take()?;
if pending.session != name {
log::info!(
"MUX: manual attach to '{name}' superseded the pending reconnect to '{}'",
pending.session
);
return None;
}
self.tmux_state.mux_last_error = None;
if !matches!(outcome, par_term_mux::AttachOutcome::Created(_)) {
return None;
}
Some(pending.windows)
}
}
pub(crate) fn restore_windows(
client: &mut MuxSessionClient,
windows: &[String],
) -> Vec<(u64, String)> {
let existing = match client.list_windows() {
Ok(existing) => existing,
Err(e) => {
log::error!("MUX: reconnect window restore — list-windows failed: {e}");
return Vec::new();
}
};
let mut titled = Vec::new();
for (i, name) in windows.iter().enumerate() {
if let Some(window) = existing.get(i) {
if !name.is_empty() {
send_restore(
client,
&format!("rename-window -t @{} {}", window.id, quote(name)),
);
titled.push((window.id, name.clone()));
}
continue;
}
let command = if name.is_empty() {
"new-window".to_string()
} else {
format!("new-window -n {}", quote(name))
};
let reply = match client.send(&command) {
Ok(reply) => reply,
Err(e) => {
log::error!("MUX: reconnect window restore — {command:?} failed: {e}");
break;
}
};
let Some(id) = reply.iter().find_map(|line| {
line.trim()
.strip_prefix('@')
.and_then(|rest| rest.parse::<u64>().ok())
}) else {
continue; };
if !name.is_empty() {
send_restore(client, &format!("rename-window -t @{id} {}", quote(name)));
titled.push((id, name.clone()));
}
}
titled
}
fn send_restore(client: &mut MuxSessionClient, command: &str) {
if let Err(e) = client.send(command) {
log::error!("MUX: reconnect window restore — {command:?} failed: {e}");
}
}
fn quote(name: &str) -> String {
par_term_mux::quote_env_value(name)
}
#[cfg(test)]
mod tests {
use super::*;
fn pending(session: &str, windows: &[&str]) -> MuxReconnect {
MuxReconnect {
session: session.to_string(),
windows: windows.iter().map(|w| w.to_string()).collect(),
attempts: Vec::new(),
}
}
#[test]
fn guard_prunes_attempts_outside_the_window_and_counts_inside_it() {
let now = Instant::now();
let mut attempts = vec![
now - Duration::from_secs(120), now - Duration::from_secs(10),
];
attempts.retain(|t| now.duration_since(*t) < ATTEMPT_WINDOW);
assert_eq!(attempts.len(), 1);
attempts.push(now);
assert_eq!(attempts.len(), 2);
}
#[test]
fn guard_gives_up_at_max_attempts() {
let now = Instant::now();
let attempts: Vec<Instant> = (0..MAX_ATTEMPTS)
.map(|i| now - Duration::from_secs(10 * (i as u64 + 1)))
.collect();
assert!(attempts.len() >= MAX_ATTEMPTS);
}
#[test]
fn pending_state_carries_session_windows_and_attempts() {
let p = pending("dev", &["claude", "", "zsh"]);
assert_eq!(p.session, "dev");
assert_eq!(
p.windows,
vec!["claude".to_string(), String::new(), "zsh".to_string()]
);
assert!(p.attempts.is_empty());
}
#[cfg(unix)]
mod daemon_driven {
use super::super::super::mux::tests::{manners_state, socket_path, spawn_daemon};
use super::super::super::mux_test_seams::{
SettledSend, attached_window_with_tabs, wait_until,
};
use super::super::*;
fn enable_reconnect(ws: &mut crate::app::window_state::WindowState) {
let mut cfg = crate::config::Config::default();
cfg.tmux.mux_auto_reconnect = true;
ws.config.store(std::sync::Arc::new(cfg));
}
fn name_windows_and_wait(ws: &mut crate::app::window_state::WindowState) {
let rows = ws
.tmux_state
.transport
.as_ref()
.expect("attached")
.send_settled("list-windows")
.expect("list-windows");
let ids: Vec<u64> = rows
.iter()
.filter_map(|row| {
row.trim()
.strip_prefix('@')
.and_then(|rest| rest.split(':').next())
.and_then(|id| id.parse().ok())
})
.collect();
for (i, id) in ids.iter().enumerate() {
ws.tmux_state
.transport
.as_ref()
.expect("attached")
.send_settled(&format!("rename-window -t @{id} title-{i}"))
.expect("rename");
}
wait_until("tabs carry the renamed titles", || {
ws.check_tmux_notifications();
ws.tab_manager
.tabs()
.iter()
.filter(|t| t.title.starts_with("title-"))
.count()
== ids.len()
});
}
#[test]
fn capture_holds_the_window_set_and_titles() {
let (mut ws, _path) = attached_window_with_tabs("recon-capture", 3);
enable_reconnect(&mut ws);
name_windows_and_wait(&mut ws);
ws.capture_mux_reconnect();
let rc = ws
.tmux_state
.mux_reconnect
.as_ref()
.expect("the daemon loss is captured")
.clone();
assert_eq!(rc.session, "recon-capture");
assert_eq!(
rc.windows,
vec![
"title-0".to_string(),
"title-1".to_string(),
"title-2".to_string()
]
);
assert!(rc.attempts.is_empty());
}
#[test]
fn the_reconnect_restores_the_window_set_on_a_fresh_daemon() {
let (mut ws, _old) = attached_window_with_tabs("recon-flow", 3);
enable_reconnect(&mut ws);
name_windows_and_wait(&mut ws);
let _ = ws.tmux_state.transport.take();
ws.capture_mux_reconnect();
ws.tmux_state.mux_last_error = Some("par-mux: daemon connection lost".to_string());
ws.handle_tmux_session_ended_for_test();
assert_eq!(
ws.tab_manager.tab_count(),
0,
"the teardown closes the daemon tabs"
);
assert!(ws.tmux_state.transport.is_none());
let fresh = socket_path("recon-flow-fresh");
spawn_daemon(&fresh);
let core = par_term_emu_core_rust::mux::MuxClient::connect(&fresh).expect("connect");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(core)).unwrap();
drop(tx);
ws.tmux_state.mux_attach_pending = Some(super::super::super::mux::MuxAttachPending {
name: "recon-flow".to_string(),
rx,
});
ws.poll_mux_attach();
assert!(ws.tmux_state.transport.is_some());
assert!(ws.tmux_state.mux_reconnect.is_none());
assert!(ws.tmux_state.mux_last_error.is_none());
wait_until("the restored window set", || {
ws.check_tmux_notifications();
let tabs = ws.tab_manager.tabs();
tabs.len() == 3
&& tabs
.iter()
.filter(|t| t.title.starts_with("title-"))
.count()
== 3
});
}
#[test]
fn a_trigger_below_the_ceiling_records_the_attempt() {
let mut ws = manners_state();
ws.tmux_state.mux_reconnect = Some(MuxReconnect {
session: "recon-guard".to_string(),
windows: vec!["a".to_string()],
attempts: Vec::new(),
});
let (_tx, rx) = std::sync::mpsc::channel::<
std::io::Result<par_term_emu_core_rust::mux::MuxClient>,
>();
drop(_tx); ws.tmux_state.mux_attach_pending = Some(
crate::app::tmux_handler::notifications::mux::MuxAttachPending {
name: "recon-guard".to_string(),
rx,
},
);
ws.trigger_mux_reconnect();
let rc = ws
.tmux_state
.mux_reconnect
.as_ref()
.expect("below the ceiling the state survives")
.clone();
assert_eq!(rc.attempts.len(), 1, "the attempt is recorded");
assert!(ws.tmux_state.mux_attach_pending.is_some());
}
#[test]
fn the_guard_gives_up_instead_of_looping() {
let mut ws = manners_state();
let now = std::time::Instant::now();
ws.tmux_state.mux_reconnect = Some(MuxReconnect {
session: "recon-loop".to_string(),
windows: vec!["a".to_string()],
attempts: (0..MAX_ATTEMPTS).map(|_| now).collect(),
});
ws.trigger_mux_reconnect();
assert!(ws.tmux_state.mux_reconnect.is_none());
assert!(ws.tmux_state.mux_attach_pending.is_none());
assert!(
ws.tmux_state
.mux_last_error
.as_deref()
.is_some_and(|e| e.contains("gave up")),
"the chip error names the give-up, got {:?}",
ws.tmux_state.mux_last_error
);
}
}
}