use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use super::RequestHandler;
use rmux_core::{input::InputParser, GridRenderOptions, Screen, ScreenCaptureRange};
use rmux_proto::types::OptionScopeSelector;
#[cfg(unix)]
use rmux_proto::ListBuffersRequest;
use rmux_proto::{
CapturePaneRequest, CapturePaneTargetActionRequest, LoadBufferRequest, NewSessionRequest,
PaneTarget, Request, Response, SaveBufferRequest, SendKeysRequest, SetBufferRequest,
SetOptionByNameRequest, SetOptionMode, ShowBufferRequest, TerminalSize,
};
use tokio::time::sleep;
static UNIQUE_ID: AtomicUsize = AtomicUsize::new(0);
fn session_name(value: &str) -> rmux_proto::SessionName {
rmux_proto::SessionName::new(value).expect("valid session name")
}
fn capture_pane_request(
target: PaneTarget,
start: Option<i64>,
end: Option<i64>,
print: bool,
buffer_name: Option<&str>,
) -> CapturePaneRequest {
CapturePaneRequest {
target,
start,
end,
print,
buffer_name: buffer_name.map(str::to_owned),
alternate: false,
escape_ansi: false,
escape_sequences: false,
include_format: false,
hyperlinks: false,
line_numbers: false,
join_wrapped: false,
use_mode_screen: false,
preserve_trailing_spaces: false,
do_not_trim_spaces: false,
pending_input: false,
quiet: false,
start_is_absolute: false,
end_is_absolute: false,
}
}
fn capture_stdout(response: Response) -> Vec<u8> {
let Response::CapturePane(response) = response else {
panic!("expected capture-pane response, got {response:?}");
};
response
.command_output()
.expect("capture-pane -p returns command output")
.stdout()
.to_vec()
}
fn set_buffer_request(name: &str, content: &[u8]) -> SetBufferRequest {
SetBufferRequest {
name: Some(name.to_owned()),
content: content.to_vec(),
append: false,
new_name: None,
set_clipboard: false,
target_client: None,
}
}
fn load_buffer_request(
path: &std::path::Path,
cwd: Option<std::path::PathBuf>,
name: &str,
) -> LoadBufferRequest {
LoadBufferRequest {
path: path.display().to_string(),
cwd,
name: Some(name.to_owned()),
set_clipboard: false,
target_client: None,
}
}
fn save_buffer_request(
path: &std::path::Path,
cwd: Option<std::path::PathBuf>,
name: &str,
) -> SaveBufferRequest {
SaveBufferRequest {
path: path.display().to_string(),
cwd,
name: Some(name.to_owned()),
append: false,
}
}
async fn create_session(handler: &RequestHandler, name: &str) {
create_session_with_size(handler, name, TerminalSize { cols: 80, rows: 24 }).await;
}
async fn create_session_with_size(handler: &RequestHandler, name: &str, size: TerminalSize) {
let response = handler
.handle(Request::NewSession(NewSessionRequest {
session_name: session_name(name),
detached: true,
size: Some(size),
environment: None,
}))
.await;
assert!(matches!(response, Response::NewSession(_)));
}
#[tokio::test]
async fn target_action_capture_resolves_raw_target_server_side() {
let handler = RequestHandler::new();
create_session_with_size(&handler, "alpha", TerminalSize { cols: 20, rows: 4 }).await;
let target = PaneTarget::with_window(session_name("alpha"), 0, 0);
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 20, rows: 4 },
b"target-capture",
)
.await;
let response = handler
.handle(Request::CapturePaneTargetAction(Box::new(
CapturePaneTargetActionRequest {
target: Some("alpha:0.0".to_owned()),
start: Some(0),
end: Some(0),
print: true,
buffer_name: None,
alternate: false,
escape_ansi: false,
escape_sequences: false,
include_format: false,
hyperlinks: false,
line_numbers: false,
join_wrapped: false,
use_mode_screen: false,
preserve_trailing_spaces: false,
do_not_trim_spaces: false,
pending_input: false,
quiet: false,
start_is_absolute: false,
end_is_absolute: false,
},
)))
.await;
let Response::CapturePane(response) = response else {
panic!("expected capture-pane response, got {response:?}");
};
let output = response
.command_output()
.expect("capture-pane -p returns command output");
assert_eq!(output.stdout(), b"target-capture\n");
}
#[tokio::test]
async fn direct_and_target_action_capture_join_stop_at_active_alternate_boundary() {
let handler = RequestHandler::new();
create_session_with_size(&handler, "alpha", TerminalSize { cols: 8, rows: 2 }).await;
let target = PaneTarget::with_window(session_name("alpha"), 0, 0);
replace_transcript_contents(
&handler,
&target,
TerminalSize { cols: 8, rows: 2 },
b"abcdefghijkl\r\n\x1b[?1049h\x1b[HVIM",
)
.await;
let mut direct = capture_pane_request(target, None, None, true, None);
direct.join_wrapped = true;
direct.start_is_absolute = true;
let direct = handler.handle(Request::CapturePane(Box::new(direct))).await;
assert_eq!(capture_stdout(direct), b"abcdefgh\nVIM\n\n");
let target_action = handler
.handle(Request::CapturePaneTargetAction(Box::new(
CapturePaneTargetActionRequest {
target: Some("alpha:0.0".to_owned()),
start: None,
end: None,
print: true,
buffer_name: None,
alternate: false,
escape_ansi: false,
escape_sequences: false,
include_format: false,
hyperlinks: false,
line_numbers: false,
join_wrapped: true,
use_mode_screen: false,
preserve_trailing_spaces: false,
do_not_trim_spaces: false,
pending_input: false,
quiet: false,
start_is_absolute: true,
end_is_absolute: false,
},
)))
.await;
assert_eq!(capture_stdout(target_action), b"abcdefgh\nVIM\n\n");
let queued = handler
.parse_control_commands("capture-pane -pJ -S - -t alpha:0.0")
.await
.expect("queued capture-pane parses");
let queued = handler
.execute_parsed_commands_for_test(std::process::id(), queued)
.await
.expect("queued capture-pane executes");
assert_eq!(queued.stdout(), b"abcdefgh\nVIM\n\n");
}
#[tokio::test]
async fn named_capture_buffer_keeps_dch_field_boundary_like_tmux_3_7b() {
let handler = RequestHandler::new();
let size = TerminalSize { cols: 12, rows: 8 };
create_session_with_size(&handler, "mutations", size).await;
let target = PaneTarget::with_window(session_name("mutations"), 0, 0);
replace_transcript_contents(
&handler,
&target,
size,
b"ABCDEFGHIJKLmnopqrstuvwx012345678\r\nNXT\r\nEND\
\x1b[r\x1b[2;1H\x1b[99P",
)
.await;
let mut capture = capture_pane_request(target, None, None, false, Some("mutation-consumer"));
capture.join_wrapped = true;
let response = handler
.handle(Request::CapturePane(Box::new(capture)))
.await;
let Response::CapturePane(response) = response else {
panic!("expected capture-pane buffer response, got {response:?}");
};
assert_eq!(response.buffer_name.as_deref(), Some("mutation-consumer"));
let shown = handler
.handle(Request::ShowBuffer(ShowBufferRequest {
name: Some("mutation-consumer".to_owned()),
}))
.await;
let bytes = shown
.command_output()
.expect("show-buffer returns captured bytes")
.stdout()
.to_vec();
let nonempty = bytes
.split(|byte| *byte == b'\n')
.filter(|field| !field.is_empty())
.collect::<Vec<_>>();
assert_eq!(
nonempty,
[
b"ABCDEFGHIJKL".as_slice(),
b"012345678".as_slice(),
b"NXT".as_slice(),
b"END".as_slice(),
]
);
}
async fn replace_transcript_contents(
handler: &RequestHandler,
target: &PaneTarget,
size: TerminalSize,
content: &[u8],
) {
let transcript = {
let state = handler.state.lock().await;
state
.transcript_handle(target)
.expect("session transcript must exist")
};
let history_limit = transcript
.lock()
.expect("pane transcript mutex must not be poisoned")
.history_limit();
let mut screen = Screen::new(size, history_limit);
let mut parser = InputParser::new();
parser.parse(content, &mut screen);
transcript
.lock()
.expect("pane transcript mutex must not be poisoned")
.set_screen_for_test(screen);
}
async fn send_marker(handler: &RequestHandler, target: PaneTarget, marker: &str) {
let response = handler
.handle(Request::SendKeys(SendKeysRequest {
target,
keys: vec![marker_print_command(marker), "Enter".to_owned()],
}))
.await;
assert!(matches!(response, Response::SendKeys(_)));
}
#[cfg(unix)]
fn marker_print_command(marker: &str) -> String {
format!("printf '{marker}\\n'")
}
#[cfg(windows)]
fn marker_print_command(marker: &str) -> String {
format!("echo {marker}")
}
async fn wait_for_capture(handler: &RequestHandler, target: PaneTarget, marker: &str) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_secs(10);
let mut last_stdout = Vec::new();
while Instant::now() < deadline {
let response = handler
.handle(Request::CapturePane(Box::new(capture_pane_request(
target.clone(),
None,
None,
true,
None,
))))
.await;
let output = response
.command_output()
.expect("capture-pane -p returns command output");
last_stdout = output.stdout().to_vec();
if String::from_utf8_lossy(output.stdout()).contains(marker) {
return output.stdout().to_vec();
}
sleep(Duration::from_millis(20)).await;
}
panic!(
"capture output never contained marker {marker}; last stdout: {:?}",
String::from_utf8_lossy(&last_stdout)
);
}
#[tokio::test]
async fn capture_pane_prints_transcript_without_creating_buffer() {
let handler = RequestHandler::new();
let target = PaneTarget::with_window(session_name("alpha"), 0, 0);
let marker = "handler_capture_print_marker";
create_session(&handler, "alpha").await;
send_marker(&handler, target.clone(), marker).await;
let output = wait_for_capture(&handler, target, marker).await;
assert!(String::from_utf8_lossy(&output).contains(marker));
let show = handler
.handle(Request::ShowBuffer(ShowBufferRequest { name: None }))
.await;
assert!(matches!(show, Response::Error(_)));
}
#[tokio::test]
async fn capture_pane_writes_named_buffer() {
let handler = RequestHandler::new();
let target = PaneTarget::with_window(session_name("alpha"), 0, 0);
let marker = "handler_capture_buffer_marker";
create_session(&handler, "alpha").await;
send_marker(&handler, target.clone(), marker).await;
wait_for_capture(&handler, target.clone(), marker).await;
let capture = handler
.handle(Request::CapturePane(Box::new(capture_pane_request(
target,
None,
None,
false,
Some("capture-buffer"),
))))
.await;
match capture {
Response::CapturePane(response) => {
assert_eq!(response.buffer_name.as_deref(), Some("capture-buffer"));
assert!(response.command_output().is_none());
}
other => panic!("expected capture response, got {other:?}"),
}
let show = handler
.handle(Request::ShowBuffer(ShowBufferRequest {
name: Some("capture-buffer".to_owned()),
}))
.await;
let output = show.command_output().expect("show-buffer returns output");
assert!(String::from_utf8_lossy(output.stdout()).contains(marker));
}
#[tokio::test]
async fn capture_pane_do_not_trim_uses_tmux_cell_capacity() {
let handler = RequestHandler::new();
let target = PaneTarget::with_window(session_name("capacity"), 0, 0);
let size = TerminalSize { cols: 20, rows: 6 };
create_session_with_size(&handler, "capacity", size).await;
replace_transcript_contents(&handler, &target, size, b"a\r\nabcde\r\nabcdefghij\r\n").await;
let mut request = capture_pane_request(target, None, None, true, None);
request.do_not_trim_spaces = true;
let response = handler
.handle(Request::CapturePane(Box::new(request)))
.await;
let output = response
.command_output()
.expect("capture-pane -Np returns command output");
let output = String::from_utf8(output.stdout().to_vec()).expect("capture output is utf-8");
assert_eq!(output, "a \nabcde \nabcdefghij \n\n\n\n");
}
#[tokio::test]
async fn alternate_screen_off_keeps_program_output_on_main_screen() {
let handler = RequestHandler::new();
let target = PaneTarget::with_window(session_name("altscreen"), 0, 0);
create_session_with_size(&handler, "altscreen", TerminalSize { cols: 20, rows: 5 }).await;
let response = handler
.handle(Request::SetOptionByName(Box::new(SetOptionByNameRequest {
scope: OptionScopeSelector::WindowGlobal,
name: "alternate-screen".to_owned(),
value: Some("off".to_owned()),
mode: SetOptionMode::Replace,
only_if_unset: false,
unset: false,
unset_pane_overrides: false,
format: false,
format_target: None,
})))
.await;
assert!(matches!(response, Response::SetOptionByName(_)));
let transcript = {
let state = handler.state.lock().await;
state
.transcript_handle(&target)
.expect("session transcript must exist")
};
let output = {
let mut transcript = transcript
.lock()
.expect("pane transcript mutex must not be poisoned");
transcript.append_bytes(b"\x1b[2J\x1b[H\x1b[?1049hALTLINE\r\n\x1b[?1049lMAINLINE\r\n");
assert!(!transcript.is_alternate());
transcript.capture_main(ScreenCaptureRange::default(), GridRenderOptions::default())
};
let output = String::from_utf8(output).expect("capture output is utf8");
assert!(output.contains("ALTLINE"), "{output:?}");
assert!(output.contains("MAINLINE"), "{output:?}");
}
#[tokio::test]
async fn load_buffer_reads_server_file() {
let handler = RequestHandler::new();
let path = temp_path("load-success");
std::fs::write(&path, b"loaded data").expect("write input");
let response = handler
.handle(Request::LoadBuffer(Box::new(load_buffer_request(
&path, None, "loaded",
))))
.await;
match response {
Response::LoadBuffer(response) => assert_eq!(response.buffer_name, "loaded"),
other => panic!("expected load-buffer response, got {other:?}"),
}
let show = handler
.handle(Request::ShowBuffer(ShowBufferRequest {
name: Some("loaded".to_owned()),
}))
.await;
assert_eq!(
show.command_output()
.expect("show-buffer returns output")
.stdout(),
b"loaded data"
);
let _ = std::fs::remove_file(path);
}
#[cfg(unix)]
#[tokio::test]
async fn load_buffer_waiting_on_fifo_does_not_block_other_requests() {
let handler = RequestHandler::new();
let path = temp_path("load-fifo");
let output = std::process::Command::new("mkfifo")
.arg(&path)
.output()
.expect("run mkfifo");
assert!(
output.status.success(),
"mkfifo failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(2));
std::fs::write(writer_path, b"fifo data").expect("write fifo");
});
let load_handler = handler.clone();
let load_path = path.clone();
let load = tokio::spawn(async move {
load_handler
.handle(Request::LoadBuffer(Box::new(load_buffer_request(
&load_path, None, "fifo",
))))
.await
});
let concurrent_response = tokio::time::timeout(Duration::from_millis(500), async {
tokio::task::yield_now().await;
handler
.handle(Request::ListBuffers(ListBuffersRequest::default()))
.await
})
.await
.expect("a blocked FIFO read must not stall unrelated daemon requests");
assert!(matches!(concurrent_response, Response::ListBuffers(_)));
let load_response = load.await.expect("load-buffer task should finish");
assert!(matches!(load_response, Response::LoadBuffer(_)));
writer.join().expect("FIFO writer should finish");
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn load_buffer_failure_does_not_mutate_existing_buffer() {
let handler = RequestHandler::new();
let missing_path = temp_path("load-missing");
handler
.handle(Request::SetBuffer(Box::new(set_buffer_request(
"stable",
b"original",
))))
.await;
let response = handler
.handle(Request::LoadBuffer(Box::new(load_buffer_request(
&missing_path,
None,
"stable",
))))
.await;
assert!(matches!(response, Response::Error(_)));
let show = handler
.handle(Request::ShowBuffer(ShowBufferRequest {
name: Some("stable".to_owned()),
}))
.await;
assert_eq!(
show.command_output()
.expect("show-buffer returns output")
.stdout(),
b"original"
);
}
#[tokio::test]
async fn load_buffer_resolves_relative_path_against_request_cwd() {
let handler = RequestHandler::new();
let root = temp_path("load-relative-root");
let nested_dir = root.join("nested");
std::fs::create_dir_all(&nested_dir).expect("create nested dir");
std::fs::write(nested_dir.join("input.txt"), b"relative data").expect("write input");
let response = handler
.handle(Request::LoadBuffer(Box::new(load_buffer_request(
&std::path::Path::new("nested").join("input.txt"),
Some(root.clone()),
"loaded",
))))
.await;
match response {
Response::LoadBuffer(response) => assert_eq!(response.buffer_name, "loaded"),
other => panic!("expected load-buffer response, got {other:?}"),
}
let show = handler
.handle(Request::ShowBuffer(ShowBufferRequest {
name: Some("loaded".to_owned()),
}))
.await;
assert_eq!(
show.command_output()
.expect("show-buffer returns output")
.stdout(),
b"relative data"
);
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn save_buffer_writes_server_file() {
let handler = RequestHandler::new();
let path = temp_path("save-success");
handler
.handle(Request::SetBuffer(Box::new(set_buffer_request(
"saved", b"save me",
))))
.await;
let response = handler
.handle(Request::SaveBuffer(save_buffer_request(
&path, None, "saved",
)))
.await;
match response {
Response::SaveBuffer(response) => assert_eq!(response.buffer_name, "saved"),
other => panic!("expected save-buffer response, got {other:?}"),
}
assert_eq!(std::fs::read(&path).expect("read saved file"), b"save me");
let _ = std::fs::remove_file(path);
}
#[cfg(unix)]
#[tokio::test]
async fn save_buffer_waiting_on_fifo_does_not_block_other_requests() {
for append in [false, true] {
let handler = RequestHandler::new();
let path = temp_path(if append {
"save-append-fifo"
} else {
"save-overwrite-fifo"
});
let output = std::process::Command::new("mkfifo")
.arg(&path)
.output()
.expect("run mkfifo");
assert!(
output.status.success(),
"mkfifo failed: {}",
String::from_utf8_lossy(&output.stderr)
);
handler
.handle(Request::SetBuffer(Box::new(set_buffer_request(
"saved",
b"fifo data",
))))
.await;
let reader_path = path.clone();
let reader = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(2));
std::fs::read(reader_path).expect("read fifo")
});
let save_handler = handler.clone();
let save_path = path.clone();
let save = tokio::spawn(async move {
let mut request = save_buffer_request(&save_path, None, "saved");
request.append = append;
save_handler.handle(Request::SaveBuffer(request)).await
});
let concurrent_response = tokio::time::timeout(Duration::from_millis(500), async {
tokio::task::yield_now().await;
handler
.handle(Request::ListBuffers(ListBuffersRequest::default()))
.await
})
.await
.expect("a blocked FIFO write must not stall unrelated daemon requests");
assert!(matches!(concurrent_response, Response::ListBuffers(_)));
let save_response = save.await.expect("save-buffer task should finish");
assert!(matches!(save_response, Response::SaveBuffer(_)));
assert_eq!(
reader.join().expect("FIFO reader should finish"),
b"fifo data"
);
let _ = std::fs::remove_file(path);
}
}
#[tokio::test]
async fn save_buffer_resolves_relative_path_against_request_cwd() {
let handler = RequestHandler::new();
let root = temp_path("save-relative-root");
let nested_dir = root.join("nested");
std::fs::create_dir_all(&nested_dir).expect("create nested dir");
handler
.handle(Request::SetBuffer(Box::new(set_buffer_request(
"saved",
b"relative save",
))))
.await;
let response = handler
.handle(Request::SaveBuffer(save_buffer_request(
&std::path::Path::new("nested").join("output.txt"),
Some(root.clone()),
"saved",
)))
.await;
match response {
Response::SaveBuffer(response) => assert_eq!(response.buffer_name, "saved"),
other => panic!("expected save-buffer response, got {other:?}"),
}
assert_eq!(
std::fs::read(nested_dir.join("output.txt")).expect("read saved file"),
b"relative save"
);
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn save_buffer_failure_does_not_mutate_existing_buffer() {
let handler = RequestHandler::new();
let path = temp_path("missing-parent").join("out.txt");
handler
.handle(Request::SetBuffer(Box::new(set_buffer_request(
"stable",
b"original",
))))
.await;
let response = handler
.handle(Request::SaveBuffer(save_buffer_request(
&path, None, "stable",
)))
.await;
assert!(matches!(response, Response::Error(_)));
let show = handler
.handle(Request::ShowBuffer(ShowBufferRequest {
name: Some("stable".to_owned()),
}))
.await;
assert_eq!(
show.command_output()
.expect("show-buffer returns output")
.stdout(),
b"original"
);
}
fn temp_path(label: &str) -> std::path::PathBuf {
let unique_id = UNIQUE_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"rmux-handler-{label}-{}-{unique_id}",
std::process::id()
))
}