#![cfg(all(feature = "control-mode", feature = "test-support"))]
use std::time::Duration;
use libtmux::control::{ControlEvents, ControlMode, ControlSender, Event};
use libtmux::test::TestServer;
use libtmux::{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);
fn is_a_new_window(event: &Event) -> bool {
matches!(
event,
Event::WindowAdded { .. } | Event::UnlinkedWindowAdded { .. } | Event::WindowRenamed { .. }
)
}
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(event)) if wanted(&event) => return Some(event),
Ok(Some(_)) => {}
Ok(None) | Err(_) => return None,
}
}
}
#[tokio::test]
async fn commands_travel_down_one_connection() {
let guard = TestServer::builder().start().await.expect("tmux starts");
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 the_server_reports_changes_as_they_happen() {
let guard = TestServer::builder().start().await.expect("tmux starts");
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 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 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 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(Result::ok)
.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 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 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 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 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 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
.get_option("@spaced")
.await
.expect("read")
.expect("the option is set"),
"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 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 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 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 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 {
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 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 a_block_carrying_pane_ids_keeps_them_as_output() {
let guard = TestServer::builder().start().await.expect("tmux starts");
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 {
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_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 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 real_tmux_compat_muting_a_producing_pane_leaves_the_server_up() {
use libtmux::{SplitDirection, SplitOptions};
const MARKER: &str = "libtmux-mute-regression";
let mut guard = TestServer::builder().start().await.expect("tmux starts");
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");
}