use super::*;
pub(super) const CLIENT_SIZE: TerminalSize = TerminalSize {
cols: 100,
rows: 40,
};
const STALE_CLIENT_SIZE: TerminalSize = TerminalSize { cols: 80, rows: 24 };
const REPLACEMENT_CLIENT_SIZE: TerminalSize = TerminalSize {
cols: 120,
rows: 50,
};
const HELD_CLIENT_SIZE: TerminalSize = TerminalSize {
cols: 120,
rows: 50,
};
const REQUESTED_CLIENT_SIZE: TerminalSize = TerminalSize { cols: 80, rows: 24 };
const STATUS_TWO: &str = "2";
pub(super) const STATUS_OFF: &str = "off";
pub(super) const SWITCHING_PID: u32 = 93_101;
pub(super) const SOURCE_WINDOW_INDEX: u32 = 0;
pub(super) const TARGET_WINDOW_INDEX: u32 = 1;
const SWITCH_FRAME_MATRIX: [(&str, &str, TerminalSize); 4] = [
(
STATUS_TWO,
STATUS_OFF,
TerminalSize {
cols: 100,
rows: 40,
},
),
(
STATUS_OFF,
STATUS_TWO,
TerminalSize {
cols: 100,
rows: 38,
},
),
(
STATUS_TWO,
STATUS_TWO,
TerminalSize {
cols: 100,
rows: 38,
},
),
(
STATUS_OFF,
STATUS_OFF,
TerminalSize {
cols: 100,
rows: 40,
},
),
];
#[tokio::test]
async fn switch_client_frames_the_target_with_the_joined_sessions_status() {
let mut regressions = Vec::new();
for (source_status, target_status, expected) in SWITCH_FRAME_MATRIX {
for policy in ["smallest", "largest", "latest"] {
let handler = RequestHandler::new();
let (alpha, beta) = linked_alias_sessions(&handler, source_status, target_status).await;
set_window_size_policy(&handler, &alpha, SOURCE_WINDOW_INDEX, policy).await;
set_window_size_policy(&handler, &beta, TARGET_WINDOW_INDEX, policy).await;
let mut control_rx =
register_declared_attach(&handler, SWITCHING_PID, &alpha, CLIENT_SIZE).await;
drain_attach_controls(&mut control_rx);
let response = handler
.dispatch(
SWITCHING_PID,
Request::SwitchClient(SwitchClientRequest {
target: beta.clone(),
}),
)
.await
.response;
assert!(
matches!(response, Response::SwitchClient(_)),
"switch-client must succeed, got {response:?}"
);
let framed = frame_geometry(
recv_switch_target(&mut control_rx, "linked-alias switch frame").await,
);
if framed != expected {
regressions.push(format!(
"source status={source_status} target status={target_status} \
window-size={policy}: switch frame is {framed:?}, expected {expected:?}"
));
}
let settled = window_content_size(&handler, &beta, TARGET_WINDOW_INDEX).await;
if settled != expected {
regressions.push(format!(
"source status={source_status} target status={target_status} \
window-size={policy}: settled window is {settled:?}, expected {expected:?}"
));
}
}
}
assert!(
regressions.is_empty(),
"a migrating client owns one vote, cast under the session it joins, but \
{regressions:?}"
);
}
#[tokio::test]
async fn attach_session_frames_the_target_with_the_joined_sessions_status() {
let mut regressions = Vec::new();
for (source_status, target_status, expected) in SWITCH_FRAME_MATRIX {
for policy in ["smallest", "largest"] {
let handler = RequestHandler::new();
let (alpha, beta) = linked_alias_sessions(&handler, source_status, target_status).await;
set_window_size_policy(&handler, &alpha, SOURCE_WINDOW_INDEX, policy).await;
set_window_size_policy(&handler, &beta, TARGET_WINDOW_INDEX, policy).await;
let mut control_rx =
register_declared_attach(&handler, SWITCHING_PID, &alpha, CLIENT_SIZE).await;
drain_attach_controls(&mut control_rx);
let response = handler
.dispatch(
SWITCHING_PID,
Request::AttachSession(rmux_proto::AttachSessionRequest {
target: beta.clone(),
}),
)
.await
.response;
assert!(
matches!(response, Response::SwitchClient(_)),
"attach-session from an attached client must switch it, got {response:?}"
);
let framed = frame_geometry(
recv_switch_target(&mut control_rx, "linked-alias attach frame").await,
);
if framed != expected {
regressions.push(format!(
"source status={source_status} target status={target_status} \
window-size={policy}: attach frame is {framed:?}, expected {expected:?}"
));
}
let settled = window_content_size(&handler, &beta, TARGET_WINDOW_INDEX).await;
if settled != expected {
regressions.push(format!(
"source status={source_status} target status={target_status} \
window-size={policy}: settled window is {settled:?}, expected {expected:?}"
));
}
}
}
assert!(
regressions.is_empty(),
"attach-session moves a client the same way switch-client does, but \
{regressions:?}"
);
}
#[tokio::test]
async fn a_migrating_client_replaces_its_own_stale_registration_in_the_selection() {
let mut regressions = Vec::new();
for (source_status, target_status, expected) in SWITCH_FRAME_MATRIX {
for policy in ["smallest", "largest", "latest"] {
let handler = RequestHandler::new();
let (alpha, beta) = linked_alias_sessions(&handler, source_status, target_status).await;
set_window_size_policy(&handler, &alpha, SOURCE_WINDOW_INDEX, policy).await;
set_window_size_policy(&handler, &beta, TARGET_WINDOW_INDEX, policy).await;
let _control_rx =
register_declared_attach(&handler, SWITCHING_PID, &alpha, CLIENT_SIZE).await;
let selected = handler
.selected_attached_session_size(
&beta,
Some(super::super::attach_support::IncomingSizeClient::joining(
Some(attach_generation(&handler, SWITCHING_PID).await),
CLIENT_SIZE,
super::super::attach_support::ClientFlags::default(),
handler.next_client_size_sequence(),
)),
)
.await
.expect("the target session resolves a size selection")
.selected_size();
if selected != Some(expected) {
regressions.push(format!(
"source status={source_status} target status={target_status} \
window-size={policy}: selected {selected:?}, expected {expected:?}"
));
}
}
}
assert!(
regressions.is_empty(),
"the session a client is leaving must not vote for it, but {regressions:?}"
);
}
#[tokio::test]
async fn a_stale_attach_session_must_not_displace_a_same_pid_replacement() {
let mut regressions = Vec::new();
for policy in ["largest", "smallest"] {
let handler = RequestHandler::new();
let (alpha, beta) = linked_alias_sessions(&handler, STATUS_OFF, STATUS_OFF).await;
set_window_size_policy(&handler, &alpha, SOURCE_WINDOW_INDEX, policy).await;
set_window_size_policy(&handler, &beta, TARGET_WINDOW_INDEX, policy).await;
let mut stale_rx =
register_declared_attach(&handler, SWITCHING_PID, &alpha, STALE_CLIENT_SIZE).await;
drain_attach_controls(&mut stale_rx);
let stale_identity = handler.active_attach_identity_for_test(SWITCHING_PID).await;
let pause = handler.install_attached_size_selection_pause();
let stale_attach = super::super::with_expected_attach_and_session_identity(
stale_identity,
alpha.clone(),
stale_identity.session_id(),
handler.dispatch(
SWITCHING_PID,
Request::AttachSessionExt2(Box::new(AttachSessionExt2Request {
target: Some(beta.clone()),
target_spec: Some(beta.to_string()),
detach_other_clients: false,
kill_other_clients: false,
read_only: false,
skip_environment_update: false,
flags: None,
working_directory: None,
client_terminal: rmux_proto::ClientTerminalContext::default(),
client_size: Some(STALE_CLIENT_SIZE),
})),
),
);
let replace_the_registration = async {
pause.reached.notified().await;
let mut replacement_rx =
register_declared_attach(&handler, SWITCHING_PID, &beta, REPLACEMENT_CLIENT_SIZE)
.await;
drain_attach_controls(&mut replacement_rx);
let replacement_generation = attach_generation_id(&handler, SWITCHING_PID).await;
let staged = window_content_size(&handler, &beta, TARGET_WINDOW_INDEX).await;
pause.release.notify_one();
(replacement_rx, replacement_generation, staged)
};
let (stale, (mut replacement_rx, replacement_generation, staged)) =
tokio::join!(stale_attach, replace_the_registration);
assert_ne!(
replacement_generation,
stale_identity.attach_id(),
"the re-attach must install a new generation under the same pid"
);
assert_eq!(
staged, REPLACEMENT_CLIENT_SIZE,
"window-size={policy}: the replacement must own the shared window \
before the stale command is released"
);
if !matches!(stale.response, Response::Error(_)) {
regressions.push(format!(
"window-size={policy}: the stale attach-session must fail, got {:?}",
stale.response
));
}
for (alias, window_index) in [(&beta, TARGET_WINDOW_INDEX), (&alpha, SOURCE_WINDOW_INDEX)] {
let settled = window_content_size(&handler, alias, window_index).await;
if settled != REPLACEMENT_CLIENT_SIZE {
regressions.push(format!(
"window-size={policy}: alias {alias}:{window_index} is {settled:?}, \
expected the replacement's {REPLACEMENT_CLIENT_SIZE:?}"
));
}
}
let held = attach_generation_id(&handler, SWITCHING_PID).await;
if held != replacement_generation {
regressions.push(format!(
"window-size={policy}: the replacement must still hold the pid"
));
}
while let Ok(control) = replacement_rx.try_recv() {
if matches!(control, AttachControl::Switch(_)) {
regressions.push(format!(
"window-size={policy}: the stale command must not frame the replacement"
));
}
}
}
assert!(
regressions.is_empty(),
"a stale command must fail before it displaces or moves a same-pid \
replacement, but {regressions:?}"
);
}
#[derive(Clone, Copy, Debug)]
enum LostSwitchDelivery {
Detached,
ClosedReceiver,
OverloadedBacklog,
}
#[tokio::test]
async fn a_detached_attach_session_must_not_resize_before_its_switch_fails() {
assert_lost_switch_delivery_fails_before_any_resize(LostSwitchDelivery::Detached).await;
}
#[tokio::test]
async fn a_closed_attach_receiver_must_not_resize_before_its_switch_fails() {
assert_lost_switch_delivery_fails_before_any_resize(LostSwitchDelivery::ClosedReceiver).await;
}
#[tokio::test]
async fn an_overloaded_attach_session_must_not_resize_before_its_switch_fails() {
assert_lost_switch_delivery_fails_before_any_resize(LostSwitchDelivery::OverloadedBacklog)
.await;
}
async fn assert_lost_switch_delivery_fails_before_any_resize(lost: LostSwitchDelivery) {
let mut regressions = Vec::new();
for policy in ["largest", "smallest"] {
let handler = RequestHandler::new();
let (alpha, beta) = linked_alias_sessions(&handler, STATUS_OFF, STATUS_OFF).await;
set_window_size_policy(&handler, &alpha, SOURCE_WINDOW_INDEX, policy).await;
set_window_size_policy(&handler, &beta, TARGET_WINDOW_INDEX, policy).await;
let mut control_rx =
register_declared_attach(&handler, SWITCHING_PID, &alpha, HELD_CLIENT_SIZE).await;
drain_attach_controls(&mut control_rx);
let identity = handler.active_attach_identity_for_test(SWITCHING_PID).await;
assert_held_geometry(&handler, &alpha, &beta, HELD_CLIENT_SIZE, "before").await;
let pause = handler.install_attached_size_selection_pause();
let sized_attach = super::super::with_expected_attach_and_session_identity(
identity,
alpha.clone(),
identity.session_id(),
handler.dispatch(
SWITCHING_PID,
Request::AttachSessionExt2(Box::new(AttachSessionExt2Request {
target: Some(beta.clone()),
target_spec: Some(beta.to_string()),
detach_other_clients: false,
kill_other_clients: false,
read_only: false,
skip_environment_update: false,
flags: None,
working_directory: None,
client_terminal: rmux_proto::ClientTerminalContext::default(),
client_size: Some(REQUESTED_CLIENT_SIZE),
})),
),
);
let lose_the_delivery = async {
pause.reached.notified().await;
lose_switch_delivery(&handler, lost, identity.attach_id(), &mut control_rx).await;
let staged = window_content_size(&handler, &beta, TARGET_WINDOW_INDEX).await;
pause.release.notify_one();
staged
};
let (attached, staged) = tokio::join!(sized_attach, lose_the_delivery);
assert_eq!(
staged, HELD_CLIENT_SIZE,
"window-size={policy}: {lost:?} must not itself move the shared window \
before the paused command is released"
);
if !matches!(attached.response, Response::Error(_)) {
regressions.push(format!(
"window-size={policy}: {lost:?}: the sized attach-session must fail, \
got {:?}",
attached.response
));
}
for (alias, window_index) in [(&beta, TARGET_WINDOW_INDEX), (&alpha, SOURCE_WINDOW_INDEX)] {
let settled = window_content_size(&handler, alias, window_index).await;
if settled != HELD_CLIENT_SIZE {
regressions.push(format!(
"window-size={policy}: {lost:?}: alias {alias}:{window_index} is \
{settled:?}, expected the held {HELD_CLIENT_SIZE:?}"
));
}
let pty = pane_pty_size(&handler, alias, window_index).await;
if pty != HELD_CLIENT_SIZE {
regressions.push(format!(
"window-size={policy}: {lost:?}: the PTY behind {alias}:{window_index} \
is {pty:?}, expected the held {HELD_CLIENT_SIZE:?}"
));
}
}
while let Ok(control) = control_rx.try_recv() {
if matches!(control, AttachControl::Switch(_)) {
regressions.push(format!(
"window-size={policy}: {lost:?}: a failed command must not frame \
the client"
));
}
}
}
assert!(
regressions.is_empty(),
"a generation that can no longer receive its switch must fail before the \
shared window moves, but {regressions:?}"
);
}
async fn lose_switch_delivery(
handler: &RequestHandler,
lost: LostSwitchDelivery,
expected_attach_id: u64,
control_rx: &mut mpsc::UnboundedReceiver<AttachControl>,
) {
match lost {
LostSwitchDelivery::Detached => {
let response = handler
.dispatch(SWITCHING_PID, Request::DetachClient(DetachClientRequest))
.await
.response;
assert!(
matches!(response, Response::DetachClient(_)),
"detach-client must succeed, got {response:?}"
);
}
LostSwitchDelivery::ClosedReceiver => control_rx.close(),
LostSwitchDelivery::OverloadedBacklog => {
let mut active_attach = handler.active_attach.lock().await;
let active = active_attach
.by_pid
.get_mut(&SWITCHING_PID)
.expect("the attached client is registered");
let payload = vec![
0_u8;
(super::super::attach_support::ATTACH_CONTROL_BACKLOG_LIMIT - 1)
* AttachControl::BACKLOG_UNIT_BYTES
];
active
.control_tx
.send(AttachControl::Write(payload))
.expect("the last control that fits the budget is accepted");
}
}
let active_attach = handler.active_attach.lock().await;
let active = active_attach
.by_pid
.get(&SWITCHING_PID)
.expect("every one of these states keeps the registration under its pid");
assert_eq!(
active.id, expected_attach_id,
"{lost:?} must not replace the captured generation"
);
let closing = active.closing.load(Ordering::SeqCst);
let receiver_closed = active.control_tx.is_closed();
let backlog = active.control_backlog.load(Ordering::Acquire);
match lost {
LostSwitchDelivery::Detached => assert!(
closing && !receiver_closed,
"a detached registration is latched closing with its receiver intact"
),
LostSwitchDelivery::ClosedReceiver => assert!(
receiver_closed && !closing,
"a closed receiver is visible to the sender without latching closing"
),
LostSwitchDelivery::OverloadedBacklog => assert!(
backlog >= super::super::attach_support::ATTACH_CONTROL_BACKLOG_LIMIT
&& !closing
&& !receiver_closed,
"an overloaded backlog leaves a live receiver that cannot accept more, \
got {backlog} units"
),
}
}
async fn assert_held_geometry(
handler: &RequestHandler,
alpha: &SessionName,
beta: &SessionName,
expected: TerminalSize,
phase: &str,
) {
for (alias, window_index) in [(alpha, SOURCE_WINDOW_INDEX), (beta, TARGET_WINDOW_INDEX)] {
assert_eq!(
window_content_size(handler, alias, window_index).await,
expected,
"{phase}: alias {alias}:{window_index} must hold {expected:?}"
);
assert_eq!(
pane_pty_size(handler, alias, window_index).await,
expected,
"{phase}: the PTY behind {alias}:{window_index} must hold {expected:?}"
);
}
}
pub(super) async fn pane_pty_size(
handler: &RequestHandler,
session: &SessionName,
window_index: u32,
) -> TerminalSize {
let master = {
let mut state = handler.state.lock().await;
state
.clone_pane_master_if_alive(session, window_index, 0)
.expect("pane PTY is alive")
};
let size = master.size().expect("pane PTY size is readable");
TerminalSize {
cols: size.cols,
rows: size.rows,
}
}
pub(super) fn frame_geometry(target: crate::pane_io::AttachTarget) -> TerminalSize {
TerminalSize {
cols: target.active_pane_geometry.cols(),
rows: target.active_pane_geometry.rows(),
}
}
pub(super) async fn linked_alias_sessions(
handler: &RequestHandler,
source_status: &str,
target_status: &str,
) -> (SessionName, SessionName) {
let alpha = session_name("switch-frame-alpha");
let beta = session_name("switch-frame-beta");
create_session(handler, &alpha).await;
create_session(handler, &beta).await;
set_session_status(handler, &alpha, source_status).await;
set_session_status(handler, &beta, target_status).await;
let linked = handler
.handle(Request::LinkWindow(LinkWindowRequest {
source: WindowTarget::with_window(alpha.clone(), SOURCE_WINDOW_INDEX),
target: WindowTarget::with_window(beta.clone(), TARGET_WINDOW_INDEX),
after: false,
before: false,
kill_destination: false,
detached: false,
}))
.await;
assert!(
matches!(linked, Response::LinkWindow(_)),
"expected link-window success, got {linked:?}"
);
assert_eq!(
active_window_index(handler, &beta).await,
TARGET_WINDOW_INDEX,
"the target session must be showing the linked alias"
);
(alpha, beta)
}
async fn create_session(handler: &RequestHandler, session: &SessionName) {
let created = handler
.handle(Request::NewSession(NewSessionRequest {
session_name: session.clone(),
detached: true,
size: Some(CLIENT_SIZE),
environment: None,
}))
.await;
assert!(
matches!(created, Response::NewSession(_)),
"expected new-session success, got {created:?}"
);
}
pub(super) async fn set_session_status(
handler: &RequestHandler,
session: &SessionName,
value: &str,
) {
let response = handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Session(session.clone()),
option: OptionName::Status,
value: value.to_owned(),
mode: SetOptionMode::Replace,
}))
.await;
assert!(matches!(response, Response::SetOption(_)), "{response:?}");
}
pub(super) async fn set_window_size_policy(
handler: &RequestHandler,
session: &SessionName,
window_index: u32,
value: &str,
) {
let response = handler
.handle(Request::SetOption(SetOptionRequest {
scope: ScopeSelector::Window(WindowTarget::with_window(session.clone(), window_index)),
option: OptionName::WindowSize,
value: value.to_owned(),
mode: SetOptionMode::Replace,
}))
.await;
assert!(matches!(response, Response::SetOption(_)), "{response:?}");
}
pub(super) async fn register_declared_attach(
handler: &RequestHandler,
requester_pid: u32,
session: &SessionName,
size: TerminalSize,
) -> mpsc::UnboundedReceiver<AttachControl> {
let (control_tx, control_rx) = mpsc::unbounded_channel();
let uid = current_owner_uid();
handler
.register_attach_with_access(
requester_pid,
session.clone(),
None,
AttachRegistration {
control_tx,
control_backlog: Arc::new(AtomicUsize::new(0)),
closing: Arc::new(AtomicBool::new(false)),
persistent_overlay_epoch: Arc::new(AtomicU64::new(0)),
terminal_context: OuterTerminalContext::default(),
client_title: None,
flags: super::super::attach_support::ClientFlags::default(),
render_stream: false,
uid,
user: rmux_os::identity::UserIdentity::Uid(uid),
can_write: true,
client_size: Some(size),
},
)
.await
.expect("declared attach registration succeeds");
handler
.handle_attached_resize(requester_pid, size)
.await
.expect("declared client size is accepted");
control_rx
}
async fn attach_generation(
handler: &RequestHandler,
requester_pid: u32,
) -> super::super::attach_support::AttachGeneration {
super::super::attach_support::AttachGeneration::new(
requester_pid,
attach_generation_id(handler, requester_pid).await,
)
}
async fn attach_generation_id(handler: &RequestHandler, requester_pid: u32) -> u64 {
handler
.active_attach_identity_for_test(requester_pid)
.await
.attach_id()
}
pub(super) async fn active_window_index(handler: &RequestHandler, session: &SessionName) -> u32 {
handler
.state
.lock()
.await
.sessions
.session(session)
.expect("session exists")
.active_window_index()
}
pub(super) async fn window_content_size(
handler: &RequestHandler,
session: &SessionName,
window_index: u32,
) -> TerminalSize {
handler
.state
.lock()
.await
.sessions
.session(session)
.expect("session exists")
.window_at(window_index)
.expect("window exists")
.size()
}