#![cfg(all(feature = "control-mode", feature = "test-support"))]
#[path = "support/versions.rs"]
mod versions;
use std::time::Duration;
use libtmux::control::{ControlEvents, ControlMode, ControlSender, Event, Subscription};
use libtmux::test::TestServer;
use libtmux::{Client, Command, NewWindowOptions};
use static_assertions::assert_impl_all;
use tokio_stream::StreamExt as _;
assert_impl_all!(ControlSender: Clone, Send, Sync, Unpin);
assert_impl_all!(ControlEvents: Send, Sync, Unpin, futures_core::Stream);
assert_impl_all!(ControlMode: Send, Sync, Unpin);
assert_impl_all!(libtmux::control::PaneOutput: Send, Sync, Unpin, futures_core::Stream);
fn is_a_new_window(event: &Event) -> bool {
matches!(
event,
Event::WindowAdded { .. } | Event::UnlinkedWindowAdded { .. } | Event::WindowRenamed { .. }
)
}
#[allow(clippy::panic, reason = "test assertion helper")]
async fn wait_for(
events: &mut ControlEvents,
mut wanted: impl FnMut(&Event) -> bool,
) -> Option<Event> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match tokio::time::timeout_at(deadline, events.next_event()).await {
Ok(Some(Ok(event))) if wanted(&event) => return Some(event),
Ok(Some(Ok(_))) => {}
Ok(Some(Err(error))) => panic!("the watched connection failed: {error:?}"),
Ok(None) | Err(_) => return None,
}
}
}
#[allow(clippy::panic, clippy::expect_used, reason = "test assertion helper")]
async fn settle_stream(
pane: &libtmux::Pane,
output: &mut libtmux::control::PaneOutput,
marker: &str,
) {
pane.send_keys(format!("echo {marker}\n"))
.await
.expect("a settle command is sent");
loop {
let chunk = tokio::time::timeout(Duration::from_secs(5), output.next_chunk())
.await
.expect("settle output arrives")
.expect("the stream is alive");
if String::from_utf8_lossy(&chunk).contains(marker) {
break;
}
}
while let Ok(Some(_)) =
tokio::time::timeout(Duration::from_millis(300), output.next_chunk()).await
{}
}
#[tokio::test]
async fn commands_travel_down_one_connection() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("control").await.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let listed = control
.send(Command::new("list-windows").arg("-F").arg("#{window_name}"))
.await
.expect("the command is answered");
assert!(listed.succeeded());
assert_eq!(listed.output().len(), 1);
let refused = control
.send(Command::new("kill-window").arg("-t").arg("@999"))
.await
.expect("the command is answered");
assert!(!refused.succeeded(), "tmux closed the block with %error");
assert!(!refused.output().is_empty(), "the reason is preserved");
assert_ne!(listed.number(), refused.number());
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn owns_control_client_reports_its_own_spawned_connections() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("owns-control-client")
.await
.expect("session");
assert!(
server.clients().await.expect("clients list").is_empty(),
"nothing is attached before any connection opens",
);
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let clients = server.clients().await.expect("clients list");
let pids: Vec<u32> = clients.iter().map(Client::pid).collect();
assert_eq!(pids.len(), 1, "exactly one client is attached: {pids:?}");
let pid = pids[0];
assert!(
server.owns_control_client(pid),
"the connection this process just spawned is its own",
);
assert!(
!server.owns_control_client(pid.wrapping_add(1)),
"an arbitrary other pid is not",
);
control.shutdown().await.expect("control mode shuts down");
libtmux::test::retry_until(Duration::from_secs(5), async || {
!server.owns_control_client(pid)
})
.await
.expect("the pid is released once the connection actually ends");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn stream_reports_server_shutdown_once_before_eof() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let session = guard.session("terminal-error").await.expect("session");
let (commands, mut events) = ControlMode::attach(guard.server(), session.id())
.await
.expect("control mode attaches")
.split();
guard.server().shutdown().await.expect("client shuts down");
let error = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let item: Result<Event, libtmux::Error> = events
.next()
.await
.expect("shutdown must be reported before EOF");
if let Err(error) = item {
break error;
}
}
})
.await
.expect("the terminal diagnostic arrives");
assert!(matches!(error, libtmux::Error::ExecutorShutdown { .. }));
assert!(commands.is_closed());
assert!(events.next_event().await.is_none());
assert!(events.next().await.is_none());
events
.shutdown()
.await
.expect("the error was already delivered");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn the_server_reports_changes_as_they_happen() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("watched").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
session
.new_window(NewWindowOptions::new("appeared").command("sleep 300"))
.await
.expect("window is created");
let reported = wait_for(&mut events, is_a_new_window).await;
assert!(
reported.is_some(),
"a window appearing is reported without polling",
);
let listed = commands
.send(Command::new("list-windows").arg("-F").arg("#{window_name}"))
.await
.expect("a command sent in reaction to an event is answered");
assert!(listed.succeeded());
assert_eq!(listed.output().len(), 2);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn watching_and_sending_run_at_the_same_time() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("concurrent").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
let sender = commands.clone();
let watcher =
tokio::spawn(async move { wait_for(&mut events, is_a_new_window).await.map(|_| events) });
let created = sender
.send(
Command::new("new-window")
.arg("-d")
.arg("-n")
.arg("concurrent")
.arg("sleep 300"),
)
.await
.expect("the command is answered");
assert!(created.succeeded());
let events = watcher
.await
.expect("the watcher task finishes")
.expect("the watcher saw the window the sender created");
drop((commands, sender));
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn pane_output_keeps_bytes_no_string_would_hold() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("binary").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.send(
Command::new("new-window")
.arg("-d")
.arg("-n")
.arg("emitting")
.arg(r"printf '\377\303\050'; sleep 300"),
)
.await
.expect("the command is answered");
let output = wait_for(
&mut events,
|event| matches!(event, Event::Output { bytes, .. } if bytes.contains(&0xff)),
)
.await
.expect("the pane's output arrives");
let Event::Output { bytes, .. } = output else {
panic!("the matched event is pane output");
};
let start = bytes
.windows(3)
.position(|window| window == [0xff, 0xc3, b'('])
.expect("the exact bytes the pane wrote are preserved");
assert_eq!(&bytes[start..start + 3], [0xff, 0xc3, b'(']);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn events_compose_with_the_async_ecosystem() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("streamed").await.expect("session");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
session
.new_window(NewWindowOptions::new("streamed").command("sleep 300"))
.await
.expect("window is created");
let named = events
.timeout(Duration::from_secs(10))
.filter_map(|event| match event {
Ok(event) => Some(event.expect("healthy stream")),
Err(_elapsed) => None,
})
.filter(is_a_new_window);
let mut named = std::pin::pin!(named);
assert!(
named.next().await.is_some(),
"the stream yields the window notification",
);
drop(commands);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn attaching_finishes_before_anything_can_be_missed() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("racing").await.expect("session");
for round in 0..20 {
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
let name = format!("round-{round}");
session
.new_window(NewWindowOptions::new(name.as_str()).command("sleep 300"))
.await
.expect("window is created");
assert!(
wait_for(&mut events, |event| { is_a_new_window(event) })
.await
.is_some(),
"round {round}: the window that appeared right after attaching is reported",
);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
}
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn either_half_keeps_the_connection_alive_on_its_own() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("halves").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
drop(commands);
session
.new_window(NewWindowOptions::new("watched").command("sleep 300"))
.await
.expect("window is created");
assert!(
wait_for(&mut events, |event| { is_a_new_window(event) })
.await
.is_some(),
"events still arrive after the sender is gone",
);
events.shutdown().await.expect("control mode shuts down");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
drop(events);
assert!(
commands
.send(Command::new("list-windows"))
.await
.expect("the connection outlives the events handle")
.succeeded(),
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn shutting_down_does_not_wait_for_a_sender_that_is_still_alive() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("stopping").await.expect("session");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
tokio::time::timeout(Duration::from_secs(5), events.shutdown())
.await
.expect("shutdown does not wait on the sender")
.expect("control mode shuts down");
assert!(
commands.send(Command::new("list-windows")).await.is_err(),
"a command sent after shutdown fails rather than hangs",
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn one_pane_can_be_watched_without_the_protocol_showing() {
use libtmux::SplitDirection;
use libtmux::SplitOptions;
use libtmux::control::PaneOutput;
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("streaming").await.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let watched = window
.split(
SplitOptions::new(SplitDirection::Below)
.command(r"while true; do printf 'watched '; sleep 0.1; done"),
)
.await
.expect("pane is created");
window
.split(
SplitOptions::new(SplitDirection::Right)
.command(r"while true; do printf 'ignored '; sleep 0.1; done"),
)
.await
.expect("pane is created");
let mut output: PaneOutput = watched.stream_output().await.expect("the pane streams");
assert_eq!(output.pane(), watched.id());
let mut collected = Vec::new();
while collected.len() < 64 {
let chunk = tokio::time::timeout(Duration::from_secs(10), output.next_chunk())
.await
.expect("the pane keeps writing")
.expect("the pane is still open");
collected.extend_from_slice(&chunk);
}
let seen = String::from_utf8_lossy(&collected);
assert!(
seen.contains("watched"),
"the watched pane's output: {seen}"
);
assert!(
!seen.contains("ignored"),
"the other pane's output is filtered out: {seen}",
);
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_command_holding_spaces_survives_the_text_protocol() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("quoting").await.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let result = control
.send(
Command::new("set-option")
.arg("-s")
.arg("@spaced")
.arg("a b c"),
)
.await
.expect("the command is answered");
assert!(result.succeeded(), "tmux parsed the quoted token");
assert_eq!(
server.typed_option("@spaced").await.expect("read"),
Some(libtmux::OptionValue::from("a b c")),
);
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_command_that_cannot_be_a_line_is_refused_before_it_is_sent() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("unrepresentable")
.await
.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let error = control
.send(
Command::new("set-option")
.arg("-s")
.arg("@binary")
.arg(OsString::from_vec(vec![0xff])),
)
.await
.expect_err("a non-UTF-8 argument cannot be a control-mode line");
assert!(
matches!(
error,
libtmux::Error::ControlMode {
kind: libtmux::ControlModeErrorKind::UnrepresentableCommand,
..
},
),
"the reason is distinguishable from the connection closing: {error:?}",
);
assert!(
control
.send(Command::new("list-windows"))
.await
.expect("the connection still works")
.succeeded(),
);
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn detaching_a_session_removes_the_clients_attached_to_it() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("busy").await.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let (sender, events) = control.split();
assert_eq!(server.clients().await.expect("clients").len(), 1);
session.detach_clients().await.expect("clients detach");
assert!(
server.clients().await.expect("clients").is_empty(),
"the attached client is gone",
);
session
.detach_clients()
.await
.expect("detaching nobody is a no-op");
drop(sender);
let _ = events.shutdown().await;
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_client_reports_the_session_window_and_pane_it_is_attached_to() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("attached").await.expect("session");
let window = session
.active_window()
.await
.expect("windows")
.expect("a session has a window");
let pane = window
.active_pane()
.await
.expect("panes")
.expect("a window has a pane");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let client = server
.clients()
.await
.expect("clients")
.into_iter()
.next()
.expect("the control-mode client");
assert_eq!(
client
.attached_session()
.await
.expect("session resolves")
.expect("it is attached")
.id(),
session.id(),
);
assert_eq!(
client
.attached_window()
.await
.expect("window resolves")
.expect("it is attached")
.id(),
window.id(),
);
assert_eq!(
client
.attached_pane()
.await
.expect("pane resolves")
.expect("it is attached")
.id(),
pane.id(),
);
let by_name = server
.client(client.name().as_bytes())
.await
.expect("lookup")
.expect("the client is listed");
assert_eq!(by_name.name(), client.name());
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_muted_pane_never_reaches_this_connection() {
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("muting").await.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let watched = window
.split(SplitOptions::new(SplitDirection::Below).command("sleep 300"))
.await
.expect("pane is created");
let noisy = window
.split(SplitOptions::new(SplitDirection::Right).command("sleep 300"))
.await
.expect("pane is created");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.watch_only(std::slice::from_ref(watched.id()))
.await
.expect("the connection narrows to one pane");
noisy
.send_keys("yes flooding-the-neighbour")
.await
.expect("the neighbour floods");
watched
.send_keys("echo watched-pane-marker")
.await
.expect("the watched pane writes");
let mut saw_marker = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !saw_marker {
let Ok(Some(event)) = tokio::time::timeout_at(deadline, events.next_event()).await else {
break;
};
if let Event::Output { pane, bytes } = &event.expect("healthy stream") {
assert_ne!(
pane,
noisy.id(),
"a muted pane reached this connection: {}",
String::from_utf8_lossy(bytes),
);
if pane == watched.id() {
saw_marker |= String::from_utf8_lossy(bytes).contains("watched-pane-marker");
}
}
}
assert!(saw_marker, "the watched pane's own output still arrives");
events.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_pane_created_after_narrowing_is_muted_too() {
use libtmux::control::PaneOutput;
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("late-pane").await.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let watched = window
.split(SplitOptions::new(SplitDirection::Below).command("sleep 300"))
.await
.expect("pane is created");
let mut output: PaneOutput = watched.stream_output().await.expect("the pane streams");
let latecomer = window
.split(SplitOptions::new(SplitDirection::Right).command("sleep 300"))
.await
.expect("pane is created");
latecomer
.send_keys("yes flooding-from-a-late-pane")
.await
.expect("the latecomer floods");
watched
.send_keys("echo still-mine")
.await
.expect("the watched pane writes");
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let mut seen = Vec::new();
while !String::from_utf8_lossy(&seen).contains("still-mine") {
let Ok(Some(chunk)) = tokio::time::timeout_at(deadline, output.next_chunk()).await else {
panic!("the watched pane's marker never arrived");
};
seen.extend_from_slice(&chunk);
}
tokio::time::sleep(Duration::from_secs(2)).await;
let quiet = tokio::time::timeout(Duration::from_millis(500), output.next_chunk()).await;
assert!(
quiet.is_err(),
"the late pane is still flooding this connection",
);
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn watching_one_pane_does_not_touch_an_unrelated_session() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let watched_session = server
.new_session("watch-scope-watched")
.await
.expect("session");
let watched = watched_session.panes().await.expect("panes list").remove(0);
let other_session = server
.new_session("watch-scope-unrelated")
.await
.expect("session");
let other = other_session.panes().await.expect("panes list").remove(0);
let mut output = watched.stream_output().await.expect("the pane streams");
settle_stream(&watched, &mut output, "mcp-watch-scope-settle").await;
other
.send_line("echo mcp-watch-scope-unrelated-marker")
.await
.expect("the unrelated session's pane runs a command");
let arrived = libtmux::test::retry_until(Duration::from_secs(5), async || {
other.capture().await.is_ok_and(|lines| {
lines.iter().any(|line| {
line.to_string_lossy()
.contains("mcp-watch-scope-unrelated-marker")
})
})
})
.await;
assert!(
arrived.is_ok(),
"an unrelated session's pane keeps producing output while a watch is open elsewhere",
);
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_block_carrying_pane_ids_keeps_them_as_output() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("ids").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
let listed = commands
.send(
Command::new("list-panes")
.arg("-a")
.arg("-F")
.arg("#{pane_id}"),
)
.await
.expect("the command runs");
assert!(listed.succeeded(), "list-panes succeeded");
let rows: Vec<_> = listed
.output()
.iter()
.map(|row| row.as_str().expect("a pane id is ASCII").to_owned())
.collect();
assert!(
rows.iter().all(|row| row.starts_with('%')),
"every row is a pane id: {rows:?}",
);
assert!(!rows.is_empty(), "the session has at least one pane");
let stray = tokio::time::timeout(Duration::from_millis(200), events.next_event()).await;
if let Ok(Some(event)) = stray {
let event = event.expect("healthy stream");
assert!(
!matches!(&event, Event::Other { name, .. } if name.chars().all(char::is_numeric)),
"a pane id was reported as a notification: {event:?}",
);
}
events.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_reply_arrives_while_a_pane_floods_and_nobody_reads() {
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("unread").await.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
window
.split(SplitOptions::new(SplitDirection::Below).command("seq 1 20000"))
.await
.expect("pane is created");
tokio::time::sleep(Duration::from_millis(500)).await;
let outcome = tokio::time::timeout(
Duration::from_secs(10),
commands.send(Command::new("list-windows")),
)
.await
.expect("an answer arrives rather than a wait that never ends");
match outcome {
Ok(answered) => assert!(answered.succeeded()),
Err(refused) => assert_eq!(
refused.kind(),
libtmux::ErrorKind::Refused,
"the connection says why rather than stalling: {refused:?}",
),
}
assert!(
!commands.is_closed(),
"the connection is not closed either way"
);
drop(commands);
tokio::time::timeout(Duration::from_secs(30), events.shutdown())
.await
.expect("the connection shuts down rather than hanging the suite")
.expect("control mode shuts down");
tokio::time::timeout(Duration::from_secs(30), guard.shutdown())
.await
.expect("the fixture shuts down rather than hanging the suite")
.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn replies_arrive_when_a_caller_only_ever_sends() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("sending").await.expect("session");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
for index in 0..200 {
let created = tokio::time::timeout(
Duration::from_secs(10),
commands.send(Command::new("new-window").arg("-d")),
)
.await
.unwrap_or_else(|_| panic!("command {index} was answered"))
.expect("the command is answered");
assert!(created.succeeded(), "command {index} succeeded");
}
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_slow_reader_of_one_pane_is_given_every_byte() {
use libtmux::{SplitDirection, SplitOptions};
const LAST: u32 = 50_000;
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("counted").await.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let counted = window
.split(
SplitOptions::new(SplitDirection::Below)
.command(format!("sh -c 'sleep 2; seq 1 {LAST}; sleep 60'")),
)
.await
.expect("pane is created");
let mut output = counted.stream_output().await.expect("the pane streams");
let mut received = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(90);
while tokio::time::Instant::now() < deadline {
let Ok(chunk) = tokio::time::timeout(Duration::from_secs(5), output.next_chunk()).await
else {
break;
};
let Some(chunk) = chunk else { break };
received.extend_from_slice(&chunk);
tokio::time::sleep(Duration::from_millis(5)).await;
if received_reaches(&received, LAST) {
break;
}
}
let text = String::from_utf8_lossy(&received);
let mut expected = 1u32;
for number in text
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
{
if number.parse::<u32>() == Ok(expected) {
expected += 1;
}
}
assert_eq!(
expected - 1,
LAST,
"the stream skipped from {} onward; {} bytes arrived",
expected,
received.len(),
);
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
fn received_reaches(received: &[u8], last: u32) -> bool {
let text = String::from_utf8_lossy(received);
text.split(|c: char| !c.is_ascii_digit())
.any(|piece| piece.parse::<u32>() == Ok(last))
}
#[tokio::test]
async fn a_stream_opens_on_a_server_that_is_already_flooding() {
use libtmux::control::PaneOutput;
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("busy").await.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let watched = window
.split(SplitOptions::new(SplitDirection::Below).command("sleep 300"))
.await
.expect("pane is created");
let noisy = window
.split(SplitOptions::new(SplitDirection::Right).command("sleep 300"))
.await
.expect("pane is created");
noisy
.send_keys("yes flooding-before-the-attach")
.await
.expect("the neighbour floods");
tokio::time::sleep(Duration::from_millis(500)).await;
let output: PaneOutput = tokio::time::timeout(Duration::from_secs(20), watched.stream_output())
.await
.expect("opening the stream does not hang")
.expect("the pane streams");
assert_eq!(output.pane(), watched.id());
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn observed_pane_death_ends_the_stream_via_layout_change() {
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("pane-death-layout")
.await
.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let survivor = window
.split(SplitOptions::new(SplitDirection::Right).command("sleep 300"))
.await
.expect("pane is created");
let watched = window
.split(SplitOptions::new(SplitDirection::Below).command("sleep 300"))
.await
.expect("pane is created");
let mut output = watched.stream_output().await.expect("the pane streams");
watched.kill().await.expect("the watched pane is killed");
let ended = tokio::time::timeout(Duration::from_secs(8), output.next_chunk()).await;
assert_eq!(
ended,
Ok(None),
"next_chunk must end, not hang, once the watched pane is confirmed gone",
);
let _ = survivor;
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn observed_pane_death_ends_the_stream_via_window_close() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("pane-death-window")
.await
.expect("session");
session
.new_window(NewWindowOptions::new("elsewhere").command("sleep 300"))
.await
.expect("a second window is created");
let first = session
.windows()
.await
.expect("windows")
.into_iter()
.find(|window| window.name().as_bytes() != b"elsewhere")
.expect("the original window still exists");
let watched = first
.panes()
.await
.expect("panes list")
.into_iter()
.next()
.expect("the original window's one pane");
let mut output = watched.stream_output().await.expect("the pane streams");
watched.kill().await.expect("the watched pane is killed");
let ended = tokio::time::timeout(Duration::from_secs(8), output.next_chunk()).await;
assert_eq!(
ended,
Ok(None),
"next_chunk must end, not hang, once the watched pane's window closes under it",
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn observed_pane_death_in_a_never_active_window_ends_the_stream() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("pane-death-inactive-window")
.await
.expect("session");
let watched_window = session
.new_window(NewWindowOptions::new("closing"))
.await
.expect("a second, unselected window is created");
let watched = watched_window
.panes()
.await
.expect("panes list")
.into_iter()
.next()
.expect("the new window's one pane");
let mut output = watched.stream_output().await.expect("the pane streams");
settle_stream(&watched, &mut output, "mcp-inactive-window-marker").await;
watched.kill().await.expect("the watched pane is killed");
let ended = tokio::time::timeout(Duration::from_secs(8), output.next_chunk()).await;
assert_eq!(
ended,
Ok(None),
"next_chunk must end, not hang, once a never-active window closes under it",
);
assert!(
server
.has_session("pane-death-inactive-window")
.await
.expect("tmux still answers"),
"the session, and its still-active first window, survive",
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn pane_output_sender_reaches_the_streams_own_connection() {
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("stream-mutes-itself")
.await
.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let pane = window
.split(SplitOptions::new(SplitDirection::Below).command("sleep 300"))
.await
.expect("pane is created");
let mut output = pane.stream_output().await.expect("the pane streams");
assert!(!output.sender().is_closed(), "a fresh sender is open");
output
.sender()
.mute_pane(output.pane())
.await
.expect("the stream mutes its own pane");
pane.send_keys("echo muted-via-sender")
.await
.expect("the muted marker is sent");
let muted = tokio::time::timeout(Duration::from_millis(500), output.next_chunk()).await;
assert!(
muted.is_err(),
"no chunk should arrive on a stream muted through its own sender: {muted:?}",
);
output
.sender()
.unmute_pane(output.pane())
.await
.expect("the stream unmutes its own pane");
pane.send_keys("echo unmuted-via-sender")
.await
.expect("the unmuted marker is sent");
let resumed = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let chunk = output.next_chunk().await.expect("the stream is still open");
if String::from_utf8_lossy(&chunk).contains("unmuted-via-sender") {
return;
}
}
})
.await;
assert!(
resumed.is_ok(),
"the stream must resume once unmuted through its own sender",
);
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn real_tmux_compat_muting_a_producing_pane_leaves_the_server_up() {
use libtmux::{SplitDirection, SplitOptions};
const MARKER: &str = "libtmux-mute-regression";
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(mut guard) = versions::control_fixture(guard).await else {
return;
};
let session = guard.session("mute").await.expect("session is created");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let mut noisy = Vec::new();
for _ in 0..3 {
noisy.push(
window
.split(SplitOptions::new(SplitDirection::Below).command(format!("yes {MARKER}")))
.await
.expect("pane is created"),
);
}
let (commands, mut events) = ControlMode::attach(guard.server(), session.id())
.await
.expect("control mode attaches")
.split();
wait_for(&mut events, |event| {
matches!(event, Event::Output { bytes, .. }
if String::from_utf8_lossy(bytes).contains(MARKER))
})
.await
.expect("a flooding pane reaches the connection");
let reader = tokio::spawn(async move { while events.next_event().await.is_some() {} });
for pane in &noisy {
let muted = commands.mute_pane(pane.id()).await;
let daemon = guard.daemon_state();
assert!(
daemon.is_running() && muted.is_ok(),
"muting {} mid-write left the fixture daemon {daemon} and answered {muted:?}",
pane.id(),
);
}
for probe in 0..10 {
let answered = commands
.send(Command::new("display-message").arg("-p").arg("up"))
.await;
let daemon = guard.daemon_state();
assert!(
daemon.is_running() && answered.is_ok(),
"probe {probe} after muting left the fixture daemon {daemon} and \
answered {answered:?}",
);
}
for pane in &noisy {
let unmuted = commands.unmute_pane(pane.id()).await;
let daemon = guard.daemon_state();
assert!(
daemon.is_running() && unmuted.is_ok(),
"unmuting {} left the fixture daemon {daemon} and answered {unmuted:?}",
pane.id(),
);
}
drop(commands);
reader.abort();
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[allow(clippy::expect_used, reason = "test assertion helper")]
async fn next_report(events: &mut ControlEvents, name: &str, within: Duration) -> Option<String> {
let deadline = tokio::time::Instant::now() + within;
loop {
let event = tokio::time::timeout_at(deadline, events.next_event())
.await
.ok()??;
let event = event.expect("subscription connection remains healthy");
if let Event::SubscriptionChanged {
name: reported,
value,
..
} = event
{
if reported.as_str().is_ok_and(|reported| reported == name) {
return value.as_str().ok().map(str::to_owned);
}
}
}
}
#[tokio::test]
async fn a_subscription_reports_a_change_under_its_own_name() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("watched").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.subscribe("title", &Subscription::Session, "#{session_name}")
.await
.expect("the subscription is accepted");
assert_eq!(
next_report(&mut events, "title", Duration::from_secs(15)).await,
Some("watched".to_owned()),
"the first report carries the value as it already is"
);
commands
.send(Command::new("rename-session").arg("renamed"))
.await
.expect("the session is renamed");
assert_eq!(
next_report(&mut events, "title", Duration::from_secs(15)).await,
Some("renamed".to_owned()),
"a change is reported under the same name"
);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn unsubscribing_stops_the_reports() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("watched").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.subscribe("title", &Subscription::Session, "#{session_name}")
.await
.expect("the subscription is accepted");
assert!(
next_report(&mut events, "title", Duration::from_secs(15))
.await
.is_some(),
"the subscription reports before it is removed"
);
commands
.unsubscribe("title")
.await
.expect("the subscription is removed");
commands
.send(Command::new("rename-session").arg("renamed"))
.await
.expect("the session is renamed");
assert_eq!(
next_report(&mut events, "title", Duration::from_secs(3)).await,
None,
"nothing is reported once the subscription is gone"
);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_subscription_format_may_contain_colons() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("colonised").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.subscribe(
"pair",
&Subscription::Session,
"#{session_name}:#{session_windows}",
)
.await
.expect("the subscription is accepted");
let reported = next_report(&mut events, "pair", Duration::from_secs(15))
.await
.expect("the subscription reports");
assert_eq!(
reported, "colonised:1",
"both halves of the format arrive, colon and all"
);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_subscription_name_tmux_would_misread_is_refused() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("guarded").await.expect("session");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
for name in ["with:colon", ""] {
let refused = commands
.subscribe(name, &Subscription::Session, "#{session_name}")
.await
.expect_err("a name tmux would misread is refused");
assert_eq!(refused.kind(), libtmux::ErrorKind::InvalidInput);
let refused = commands
.unsubscribe(name)
.await
.expect_err("removal refuses the same names");
assert_eq!(refused.kind(), libtmux::ErrorKind::InvalidInput);
}
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn the_pause_threshold_and_its_resume_are_accepted() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("paused").await.expect("session");
let pane = session.panes().await.expect("panes").remove(0);
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.pause_after(Duration::from_secs(1))
.await
.expect("the pause threshold is accepted");
commands
.resume_pane(pane.id())
.await
.expect("resuming is accepted");
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn resume_pane_recovers_what_mute_pane_muted() {
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("resume-recovers-mute")
.await
.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let pane = window
.split(SplitOptions::new(SplitDirection::Below).command("sleep 300"))
.await
.expect("pane is created");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands.mute_pane(pane.id()).await.expect("the pane mutes");
pane.send_keys("echo while-muted-marker")
.await
.expect("the first marker is sent");
commands
.resume_pane(pane.id())
.await
.expect("resuming is accepted");
pane.send_keys("echo after-resume-marker")
.await
.expect("the second marker is sent");
let saw_marker = wait_for(&mut events, |event| {
matches!(event, Event::Output { pane: reported, bytes }
if reported == pane.id()
&& String::from_utf8_lossy(bytes).contains("after-resume-marker"))
})
.await
.is_some();
assert!(
saw_marker,
"resume_pane must leave the pane able to receive output again",
);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn layout_events_and_snapshots_agree_on_format() {
use libtmux::{SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server
.new_session("layout-agreement")
.await
.expect("session");
let window = session
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let (_commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
window
.split(SplitOptions::new(SplitDirection::Right).command("sleep 300"))
.await
.expect("the split changes the layout");
let event = wait_for(&mut events, |event| {
matches!(event, Event::LayoutChanged { window: reported, .. } if reported == window.id())
})
.await
.expect("the split reports a layout change");
let Event::LayoutChanged { layout, .. } = event else {
unreachable!("wait_for only returns what its predicate matched");
};
let mut refreshed = window;
refreshed.refresh().await.expect("the window still exists");
assert_eq!(
layout.as_bytes(),
refreshed.layout().as_bytes(),
"the event and a plain snapshot must report the same layout form",
);
let json_capable = server
.capabilities()
.await
.expect("capabilities read")
.tmux_version()
.has_behavior(&libtmux::since::JSON_LAYOUTS);
let starts_json = layout.as_bytes().starts_with(b"{");
assert_eq!(
starts_json, json_capable,
"the agreed form must be JSON from since::JSON_LAYOUTS onward, classic below it",
);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_pane_is_watched_wherever_it_now_lives() {
use libtmux::{JoinOptions, SplitDirection, SplitOptions};
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let origin = server.new_session("origin").await.expect("session");
let destination = server.new_session("destination").await.expect("session");
let origin_window = origin
.windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let writer = origin_window
.split(
SplitOptions::new(SplitDirection::Below)
.command(r"while true; do printf 'moved '; sleep 0.1; done"),
)
.await
.expect("pane is created");
let landing = destination
.panes()
.await
.expect("panes")
.into_iter()
.next()
.expect("one pane");
let stale = writer.clone();
writer
.join_into(&landing, JoinOptions::new(SplitDirection::Below))
.await
.expect("the pane moves between sessions");
let mut output = stale.stream_output().await.expect("the pane streams");
let chunk = tokio::time::timeout(Duration::from_secs(10), output.next_chunk())
.await
.expect("a relocated pane still reports its output")
.expect("the pane is still open");
assert!(!chunk.is_empty(), "the relocated pane wrote something");
output.shutdown().await.expect("the stream shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_clients_listing_tells_our_own_connection_apart() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let Some(guard) = versions::control_fixture(guard).await else {
return;
};
let server = guard.server();
let session = server.new_session("watched").await.expect("session");
assert!(
server
.clients()
.await
.expect("clients list")
.iter()
.all(|client| !client.is_own()),
);
let mode = ControlMode::attach(server, session.id())
.await
.expect("the connection attaches");
let clients = server.clients().await.expect("clients list");
let ours = clients.iter().filter(|client| client.is_own()).count();
assert_eq!(ours, 1, "our own control client, and only it: {clients:?}");
assert!(
clients
.iter()
.filter(|client| client.is_own())
.all(Client::is_control_mode),
);
mode.shutdown().await.expect("the connection closes");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn attach_reports_a_missing_tmux_as_the_process_path_does() {
let server = libtmux::Server::builder()
.socket_path("/tmp/libtmux-rs-dev/never-bound.sock")
.tmux_executable("/nonexistent/libtmux-rs/tmux")
.build()
.expect("the configuration is valid");
let session: libtmux::SessionId = "$0".parse().expect("a session id");
let Err(attach) = ControlMode::attach(&server, &session).await else {
panic!("attach succeeded with no tmux");
};
let listing = server
.sessions()
.await
.expect_err("a listing fails with no tmux");
assert!(
matches!(attach, libtmux::Error::ExecutableNotFound { .. }),
"attach: {attach:?}",
);
assert_eq!(attach.kind(), listing.kind());
}