use crate::app::tmux_handler::tmux_state::{MuxJob, MuxJobResult, TmuxTransport};
use par_term_emu_core_rust::mux::MuxClient;
use par_term_mux::MuxSessionClient;
use par_term_tmux::TmuxPaneId;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{
Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel,
};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
const DAEMON_UNRESPONSIVE_AFTER: Duration = Duration::from_millis(800);
const SEND_LOCK_WAIT: Duration = Duration::from_millis(250);
pub(crate) const SEND_LOCK_BUSY: &str =
"mux send worker still holds the client — daemon unresponsive?";
const OUTBOX_CAPACITY: usize = 512;
enum WorkItem {
Send(String),
Job(MuxJob),
}
pub(crate) struct MuxTransport {
client: Arc<Mutex<MuxSessionClient>>,
outbox: SyncSender<WorkItem>,
health: Arc<MuxHealth>,
job_results: Mutex<Receiver<MuxJobResult>>,
inline_waiters: Arc<AtomicUsize>,
socket: Option<PathBuf>,
}
impl MuxTransport {
pub(crate) fn with_socket(client: MuxSessionClient, socket: Option<PathBuf>) -> Self {
Self {
socket,
..Self::new(client)
}
}
pub(crate) fn new(client: MuxSessionClient) -> Self {
let client = Arc::new(Mutex::new(client));
let health = Arc::new(MuxHealth::default());
let inline_waiters = Arc::new(AtomicUsize::new(0));
let (outbox, inbox) = sync_channel::<WorkItem>(OUTBOX_CAPACITY);
let (results_tx, results_rx) = channel();
spawn_send_worker(
Arc::clone(&client),
inbox,
Arc::clone(&health),
results_tx,
Arc::clone(&inline_waiters),
);
Self {
client,
outbox,
health,
job_results: Mutex::new(results_rx),
inline_waiters,
socket: None,
}
}
#[cfg(test)]
pub(crate) fn connect_or_spawn_at(path: &std::path::Path) -> io::Result<Self> {
Ok(Self::with_socket(
MuxSessionClient::connect_or_spawn_at(path)?,
Some(path.to_path_buf()),
))
}
pub(crate) fn client(&self) -> MutexGuard<'_, MuxSessionClient> {
self.client
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn lock_for_send(&self) -> io::Result<MutexGuard<'_, MuxSessionClient>> {
let deadline = Instant::now() + SEND_LOCK_WAIT;
self.inline_waiters.fetch_add(1, Ordering::SeqCst);
let _waiting = WaiterGuard(&self.inline_waiters);
loop {
match self.client.try_lock() {
Ok(guard) => return Ok(guard),
Err(std::sync::TryLockError::Poisoned(poisoned)) => {
return Ok(poisoned.into_inner());
}
Err(std::sync::TryLockError::WouldBlock) => {
if Instant::now() >= deadline {
return Err(io::Error::other(SEND_LOCK_BUSY));
}
std::thread::sleep(Duration::from_millis(5));
}
}
}
}
}
struct WaiterGuard<'a>(&'a AtomicUsize);
impl Drop for WaiterGuard<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
fn spawn_send_worker(
client: Arc<Mutex<MuxSessionClient>>,
inbox: Receiver<WorkItem>,
health: Arc<MuxHealth>,
results: Sender<MuxJobResult>,
inline_waiters: Arc<AtomicUsize>,
) {
std::thread::spawn(move || {
let send = |command: &str| worker_send(&client, &health, command);
let job_send = |command: &str| {
let deadline = Instant::now() + SEND_LOCK_WAIT;
while inline_waiters.load(Ordering::SeqCst) > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
send(command)
};
while let Ok(item) = inbox.recv() {
match item {
WorkItem::Send(command) => {
let _ = send(&command);
}
WorkItem::Job(job) => {
if results.send(run_job(job, &job_send)).is_err() {
return;
}
}
}
}
});
}
fn worker_send(
client: &Mutex<MuxSessionClient>,
health: &MuxHealth,
command: &str,
) -> io::Result<Vec<String>> {
let mut client = client.lock().unwrap_or_else(|p| p.into_inner());
health.send_started();
let result = client.send(command);
health.send_finished(&result);
result
}
fn run_job(job: MuxJob, send: &dyn Fn(&str) -> io::Result<Vec<String>>) -> MuxJobResult {
match job {
MuxJob::DiscoverWindow(window) => {
let mut panes = Vec::new();
if let Ok(listed) = send("list-panes") {
for pane in listed
.iter()
.filter_map(|l| l.trim().strip_prefix('%')?.parse::<TmuxPaneId>().ok())
{
if let Ok(info) = send(&format!("pane-info -t %{pane}"))
&& let Some((w, size)) = info.first().and_then(|l| parse_pane_info(l))
&& w == window
{
panes.push((pane, size));
}
}
}
MuxJobResult::WindowPanes { window, panes }
}
MuxJob::SeedPanes(panes) => MuxJobResult::PaneSeeds(
panes
.into_iter()
.map(|pane| (pane, send(&format!("refresh-client -t %{pane}"))))
.collect(),
),
}
}
fn side_query(socket: &Path, command: &str, budget: Duration) -> io::Result<Vec<String>> {
let (tx, rx) = channel();
let (socket, command) = (socket.to_path_buf(), command.to_string());
std::thread::Builder::new()
.name("mux-side-query".into())
.spawn(move || {
let reply = MuxClient::connect(&socket).and_then(|mut client| client.send(&command));
let _ = tx.send(reply);
})?;
match rx.recv_timeout(budget) {
Ok(reply) => reply,
Err(RecvTimeoutError::Timeout) => Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("no reply within {} ms", budget.as_millis()),
)),
Err(RecvTimeoutError::Disconnected) => Err(io::Error::other("query thread died")),
}
}
pub(super) fn parse_pane_info(line: &str) -> Option<(par_term_tmux::TmuxWindowId, (u16, u16))> {
let mut fields = line.split_whitespace().skip(1);
let window = fields.next()?.strip_prefix('@')?.parse().ok()?;
let (cols, rows) = fields.next()?.split_once('x')?;
Some((window, (cols.parse().ok()?, rows.parse().ok()?)))
}
#[derive(Default)]
pub(crate) struct MuxHealth {
state: Mutex<MuxHealthState>,
}
#[derive(Default)]
struct MuxHealthState {
started: Option<Instant>,
timed_out: bool,
toast_shown: bool,
dropped: u64,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DaemonHealthSignal {
Unresponsive,
Recovered,
}
impl MuxHealth {
pub(crate) fn send_started(&self) {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
state.started = Some(Instant::now());
}
pub(crate) fn send_finished(&self, result: &io::Result<Vec<String>>) {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
state.started = None;
state.timed_out = matches!(
result,
Err(e) if e.kind() == io::ErrorKind::TimedOut
);
}
pub(crate) fn count_dropped(&self) {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
state.dropped += 1;
}
pub(crate) fn poll_event(&self, threshold: Duration) -> Option<DaemonHealthSignal> {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
let hung = state.timed_out
|| state
.started
.is_some_and(|started| started.elapsed() >= threshold);
match (hung, state.toast_shown) {
(true, false) => {
state.toast_shown = true;
Some(DaemonHealthSignal::Unresponsive)
}
(false, true) => {
state.toast_shown = false;
Some(DaemonHealthSignal::Recovered)
}
_ => None,
}
}
}
impl TmuxTransport for MuxTransport {
fn drain(
&self,
) -> (
Vec<par_term_emu_core_rust::tmux_control::TmuxNotification>,
bool,
) {
let Ok(mut client) = self.client.try_lock() else {
return (Vec::new(), false);
};
client.drain_core_notifications()
}
fn send_command(&self, command: &str) -> io::Result<Vec<String>> {
let mut client = self.lock_for_send()?;
client.send(command)
}
fn query_bounded(&self, command: &str, budget: Duration) -> io::Result<Vec<String>> {
let Some(socket) = &self.socket else {
return self.send_command(command);
};
match side_query(socket, command, budget) {
Err(e) if e.kind() == io::ErrorKind::TimedOut => Err(e),
Err(e) => {
log::warn!(
"par-mux: query on {} failed ({e}), using the attached connection",
socket.display()
);
self.send_command(command)
}
reply => reply,
}
}
fn send_command_no_wait(&self, command: &str) -> io::Result<()> {
match self.outbox.try_send(WorkItem::Send(command.to_string())) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => {
self.health.count_dropped();
Ok(())
}
Err(TrySendError::Disconnected(_)) => self.send_command(command).map(|_| ()),
}
}
fn daemon_health_event(&self) -> Option<String> {
let dropped = self
.health
.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.dropped;
self.health
.poll_event(DAEMON_UNRESPONSIVE_AFTER)
.map(|signal| match signal {
DaemonHealthSignal::Unresponsive => {
if dropped > 0 {
format!(
"par-mux daemon not responding — input dropped ({dropped} commands)"
)
} else {
"par-mux daemon not responding — input queued until it recovers".into()
}
}
DaemonHealthSignal::Recovered => "par-mux daemon responding again".into(),
})
}
fn submit_job(&self, job: MuxJob) -> bool {
self.outbox.try_send(WorkItem::Job(job)).is_ok()
}
fn take_job_results(&self) -> Vec<MuxJobResult> {
let rx = self
.job_results
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
rx.try_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::super::mux::tests::{connect, socket_path, spawn_daemon};
use crate::app::tmux_handler::tmux_state::{MuxJob, TmuxTransport};
#[test]
fn inline_sends_overtake_a_long_off_loop_job() {
use super::super::mux_test_seams::wait_until;
use super::SEND_LOCK_BUSY;
const BUSY_BUDGET: usize = 10;
let path = socket_path("job-priority");
spawn_daemon(&path);
let transport = connect(&path);
transport
.send_command("list-sessions")
.expect("the daemon answers before the job starts");
assert!(transport.submit_job(MuxJob::SeedPanes(vec![0; 200_000])));
wait_until("the job's first command is in flight", || {
transport
.health
.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.started
.is_some()
});
let started = std::time::Instant::now();
let mut busy = 0;
for i in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
loop {
match transport.send_command("list-sessions") {
Ok(_) => break,
Err(e) if e.to_string() == SEND_LOCK_BUSY && busy < BUSY_BUDGET => busy += 1,
Err(e) => panic!(
"inline send {i} starved behind the job after {busy} fail-fast \
retries: {e}"
),
}
}
}
assert!(
transport.take_job_results().is_empty(),
"the job must still be running, or the test proves nothing (took {:?})",
started.elapsed()
);
let _ = std::fs::remove_file(&path);
}
}