use super::*;
use rmux_core::{TargetFindContext, TargetFindFlags, TargetFindType, UnresolvedTarget};
const PINNED_SECOND: i64 = 1_785_500_000;
const ANCIENT_SECOND: i64 = 1_000_000_000;
const USED: &str = "zulu";
const SPARE: &str = "alfa";
const RENAMED_USED: &str = "renamed-used";
async fn pin_public_seconds(handler: &RequestHandler) {
pin_public_seconds_to(handler, PINNED_SECOND).await;
}
async fn pin_public_seconds_to(handler: &RequestHandler, seconds: i64) {
let mut state = handler.state.lock().await;
let names = state
.sessions
.iter()
.map(|(name, _)| name.clone())
.collect::<Vec<_>>();
for name in names {
state
.sessions
.session_mut(&name)
.expect("listed session exists")
.pin_public_times_for_tests(seconds);
}
assert!(
state
.sessions
.iter()
.all(|(_, session)| session.created_at() == seconds
&& session.activity_at() == seconds),
"the fixture must leave no public second able to order the sessions"
);
}
async fn default_session(handler: &RequestHandler) -> SessionName {
let state = handler.state.lock().await;
let target = state
.sessions
.resolve_unresolved_target(
&UnresolvedTarget::none(),
TargetFindType::Session,
TargetFindFlags::NONE,
&TargetFindContext::new(None),
)
.expect("default session resolves");
let Target::Session(name) = target else {
panic!("default session target did not resolve to a session: {target:?}");
};
name
}
async fn interaction_fixture(
attach_pid: u32,
) -> (RequestHandler, mpsc::UnboundedReceiver<AttachControl>) {
let handler = RequestHandler::new();
let control_rx = create_quiet_attached_session(&handler, attach_pid, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
pin_public_seconds(&handler).await;
assert_eq!(
default_session(&handler).await,
session_name(SPARE),
"the fixture must start with the untouched session winning"
);
(handler, control_rx)
}
#[tokio::test]
async fn an_accepted_prefix_key_advances_targetless_session_recency() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
handler
.handle_attached_live_input_for_test(attach_pid, b"\x02")
.await
.expect("prefix key input succeeds");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn an_accepted_key_binding_advances_targetless_session_recency() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
handler
.handle_attached_live_input_for_test(attach_pid, b"\x02c")
.await
.expect("prefix c input succeeds");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn a_locally_consumed_mode_key_advances_targetless_session_recency() {
let attach_pid = std::process::id();
let handler = RequestHandler::new();
let _control_rx =
create_quiet_attached_session(&handler, attach_pid, &session_name(USED)).await;
handler
.handle_attached_live_input_for_test(attach_pid, b"\x02[")
.await
.expect("prefix [ enters copy mode");
assert!(
pane_mode_status(&handler, &session_name(USED))
.await
.contains("copy-mode"),
"the fixture must actually be in a mode for its keys to be consumed locally"
);
create_quiet_session(&handler, &session_name(SPARE)).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
handler
.handle_attached_live_input_for_test(attach_pid, b"\x1b[A")
.await
.expect("copy-mode cursor key succeeds");
assert!(
pane_mode_status(&handler, &session_name(USED))
.await
.contains("copy-mode"),
"the cursor key must have been consumed by the mode, not sent to the pane"
);
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn successful_pane_input_advances_targetless_session_recency() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
handler
.handle_attached_live_input_for_test(attach_pid, b"x")
.await
.expect("plain pane input succeeds");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn synchronized_pane_input_advances_the_session_at_the_admission_boundary() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
split_used_window(&handler).await;
set_synchronize_panes(&handler).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
handler
.handle_attached_live_input_for_test(attach_pid, b"y")
.await
.expect("synchronized pane input succeeds");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn switching_a_client_advances_the_target_session_recency() {
let attach_pid = std::process::id();
let handler = RequestHandler::new();
create_quiet_session(&handler, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
let (control_tx, _control_rx) = mpsc::unbounded_channel();
handler
.register_attach(attach_pid, session_name(SPARE), control_tx)
.await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
let switched = handler
.handle(Request::SwitchClient(SwitchClientRequest {
target: session_name(USED),
}))
.await;
assert!(
matches!(switched, Response::SwitchClient(_)),
"{switched:?}"
);
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn empty_attached_input_does_not_advance_targetless_session_recency() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
handler
.handle_attached_live_input_for_test(attach_pid, b"")
.await
.expect("empty attached input succeeds");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
async fn read_only_fixture(
attach_pid: u32,
) -> (RequestHandler, mpsc::UnboundedReceiver<AttachControl>) {
let handler = RequestHandler::new();
create_quiet_session(&handler, &session_name(USED)).await;
let (control_tx, control_rx) = mpsc::unbounded_channel();
handler
.register_attach_with_closing(
attach_pid,
session_name(USED),
control_tx,
Arc::new(AtomicBool::new(false)),
OuterTerminalContext::default(),
crate::client_flags::ClientFlags::READONLY,
)
.await;
create_quiet_session(&handler, &session_name(SPARE)).await;
(handler, control_rx)
}
#[tokio::test]
async fn read_only_client_input_does_not_advance_targetless_session_recency() {
let attach_pid = std::process::id();
let (handler, _control_rx) = read_only_fixture(attach_pid).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
let _ = handler
.handle_attached_live_input_for_test(attach_pid, b"x")
.await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn input_from_a_vanished_attach_does_not_advance_targetless_session_recency() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
let error = handler
.handle_attached_live_input_for_test(attach_pid.wrapping_add(1), b"x")
.await
.expect_err("input for an unregistered attach must fail");
assert_eq!(error.kind(), std::io::ErrorKind::Other, "{error:?}");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn input_must_not_advance_a_same_name_session_recreated_under_the_client() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
let original_id = session_id(&handler, USED).await;
{
let mut state = handler.state.lock().await;
state
.sessions
.remove_session(&session_name(USED))
.expect("session removal succeeds");
state
.sessions
.create_session(session_name(USED), TerminalSize { cols: 80, rows: 24 })
.expect("replacement session creation succeeds");
}
assert_ne!(
session_id(&handler, USED).await,
original_id,
"the recreated session must be a new identity"
);
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
touch_spare(&handler).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
let _ = handler
.handle_attached_live_input_for_test(attach_pid, b"x")
.await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn a_live_attach_registration_credits_the_session_it_attached_to() {
let attach_pid = std::process::id();
let handler = RequestHandler::new();
create_quiet_session(&handler, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
pin_public_seconds(&handler).await;
assert_eq!(
default_session(&handler).await,
session_name(SPARE),
"the fixture must start with the session nobody attached to winning"
);
let (control_tx, _control_rx) = mpsc::unbounded_channel();
handler
.register_attach(attach_pid, session_name(USED), control_tx)
.await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(USED));
}
#[tokio::test]
async fn attach_registration_must_not_credit_a_same_name_replacement_session() {
let attach_pid = std::process::id();
let handler = Arc::new(RequestHandler::new());
create_quiet_session(&handler, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
let original_id = session_id(&handler, USED).await;
let pause = handler.install_attach_registration_activity_pause();
let (control_tx, _control_rx) = mpsc::unbounded_channel();
let registering_handler = Arc::clone(&handler);
let registering = tokio::spawn(async move {
registering_handler
.register_attach(attach_pid, session_name(USED), control_tx)
.await
});
tokio::time::timeout(ATTACH_LIFECYCLE_TIMEOUT, pause.reached.notified())
.await
.expect("attach registration reaches its activity commit");
{
let mut state = handler.state.lock().await;
state
.sessions
.remove_session(&session_name(USED))
.expect("session removal succeeds");
state
.sessions
.create_session(session_name(USED), TerminalSize { cols: 80, rows: 24 })
.expect("replacement session creation succeeds");
}
assert_ne!(
session_id(&handler, USED).await,
original_id,
"the replacement session must be a new identity"
);
touch_spare(&handler).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
pause.release.notify_one();
registering.await.expect("attach registration task joins");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn attach_registration_must_not_credit_a_client_that_finished_first() {
let attach_pid = std::process::id();
let handler = Arc::new(RequestHandler::new());
create_quiet_session(&handler, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
let pause = handler.install_attach_registration_activity_pause();
let (control_tx, _control_rx) = mpsc::unbounded_channel();
let registering_handler = Arc::clone(&handler);
let registering = tokio::spawn(async move {
registering_handler
.register_attach(attach_pid, session_name(USED), control_tx)
.await
});
tokio::time::timeout(ATTACH_LIFECYCLE_TIMEOUT, pause.reached.notified())
.await
.expect("attach registration reaches its activity commit");
let attach_id = handler
.active_attach
.lock()
.await
.by_pid
.get(&attach_pid)
.expect("the registration published its attach")
.id;
handler.finish_attach(attach_pid, attach_id).await;
touch_spare(&handler).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
pause.release.notify_one();
registering.await.expect("attach registration task joins");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn attach_registration_credit_follows_a_rename_of_the_same_session_lifetime() {
let attach_pid = std::process::id();
let handler = Arc::new(RequestHandler::new());
create_quiet_session(&handler, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
let attached_id = session_id(&handler, USED).await;
let pause = handler.install_attach_registration_activity_pause();
let (control_tx, _control_rx) = mpsc::unbounded_channel();
let registering_handler = Arc::clone(&handler);
let registering = tokio::spawn(async move {
registering_handler
.register_attach(attach_pid, session_name(USED), control_tx)
.await
});
tokio::time::timeout(ATTACH_LIFECYCLE_TIMEOUT, pause.reached.notified())
.await
.expect("attach registration reaches its activity commit");
let renamed = session_name(RENAMED_USED);
let response = handler
.handle(Request::RenameSession(RenameSessionRequest {
target: session_name(USED),
new_name: renamed.clone(),
}))
.await;
assert!(
matches!(response, Response::RenameSession(_)),
"{response:?}"
);
assert_eq!(
session_id(&handler, RENAMED_USED).await,
attached_id,
"renaming must preserve the attached session's identity"
);
touch_spare(&handler).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
pause.release.notify_one();
registering.await.expect("attach registration task joins");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, renamed);
}
#[tokio::test]
async fn detaching_does_not_advance_targetless_session_recency() {
let attach_pid = std::process::id();
let handler = RequestHandler::new();
create_quiet_session(&handler, &session_name(USED)).await;
let (control_tx, _control_rx) = mpsc::unbounded_channel();
let attach_id = handler
.register_attach(attach_pid, session_name(USED), control_tx)
.await;
create_quiet_session(&handler, &session_name(SPARE)).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
handler.finish_attach(attach_pid, attach_id).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn explicit_send_keys_to_a_detached_session_does_not_advance_its_recency() {
let handler = RequestHandler::new();
create_quiet_session(&handler, &session_name(USED)).await;
create_quiet_session(&handler, &session_name(SPARE)).await;
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
let response = handler
.handle(Request::SendKeys(SendKeysRequest {
target: PaneTarget::new(session_name(USED), 0),
keys: vec!["x".to_owned()],
}))
.await;
assert!(matches!(response, Response::SendKeys(_)), "{response:?}");
pin_public_seconds(&handler).await;
assert_eq!(default_session(&handler).await, session_name(SPARE));
}
#[tokio::test]
async fn accepted_input_advances_the_rendered_session_activity() {
let attach_pid = std::process::id();
let (handler, _control_rx) = interaction_fixture(attach_pid).await;
pin_public_seconds_to(&handler, ANCIENT_SECOND).await;
handler
.handle_attached_live_input_for_test(attach_pid, b"x")
.await
.expect("plain pane input succeeds");
assert!(
rendered_session_activity(&handler, USED).await > ANCIENT_SECOND,
"an accepted interaction must advance the rendered #{{session_activity}}"
);
assert_eq!(
rendered_session_activity(&handler, SPARE).await,
ANCIENT_SECOND,
"only the session the client interacted with may advance"
);
}
#[tokio::test]
async fn read_only_input_leaves_the_rendered_session_activity_alone() {
let attach_pid = std::process::id();
let (handler, _control_rx) = read_only_fixture(attach_pid).await;
pin_public_seconds_to(&handler, ANCIENT_SECOND).await;
let _ = handler
.handle_attached_live_input_for_test(attach_pid, b"x")
.await;
assert_eq!(
rendered_session_activity(&handler, USED).await,
ANCIENT_SECOND,
"rejected input must not reach the published activity second"
);
}
async fn rendered_session_activity(handler: &RequestHandler, name: &str) -> i64 {
let response = handler
.handle(Request::ListSessions(rmux_proto::ListSessionsRequest {
format: Some("#{session_name}=#{session_activity}".to_owned()),
filter: None,
sort_order: None,
reversed: false,
}))
.await;
let Response::ListSessions(listed) = response else {
panic!("expected list-sessions response, got {response:?}");
};
let rendered =
String::from_utf8(listed.output.stdout().to_vec()).expect("list-sessions stdout is utf-8");
let prefix = format!("{name}=");
rendered
.lines()
.find_map(|line| line.strip_prefix(prefix.as_str()))
.unwrap_or_else(|| panic!("{name} is missing from list-sessions output {rendered:?}"))
.parse()
.expect("#{session_activity} renders whole seconds")
}
async fn session_id(handler: &RequestHandler, name: &str) -> rmux_proto::SessionId {
handler
.state
.lock()
.await
.sessions
.session(&session_name(name))
.expect("session exists")
.id()
}
async fn touch_spare(handler: &RequestHandler) {
handler
.state
.lock()
.await
.sessions
.session_mut(&session_name(SPARE))
.expect("spare session exists")
.touch_attached();
}
async fn split_used_window(handler: &RequestHandler) {
let response = handler
.handle(Request::SplitWindow(SplitWindowRequest {
target: SplitWindowTarget::Session(session_name(USED)),
direction: rmux_proto::SplitDirection::Vertical,
before: false,
environment: None,
}))
.await;
assert!(matches!(response, Response::SplitWindow(_)), "{response:?}");
}
async fn set_synchronize_panes(handler: &RequestHandler) {
let response = handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Window(WindowTarget::with_window(session_name(USED), 0)),
option: OptionName::SynchronizePanes,
value: "on".to_owned(),
mode: SetOptionMode::Replace,
}))
.await;
assert!(matches!(response, Response::SetOption(_)), "{response:?}");
}