use tracing::warn;
pub use trusty_common::tmux::{
DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TMUX_MOUSE, HISTORY_LIMIT_OPTION, MOUSE_OPTION,
PANE_LIST_FORMAT, SESSION_LIST_FORMAT, TmuxCommand, TmuxTarget, WINDOW_LIST_FORMAT,
managed_session_commands, scrollback_option_commands, tmux_argv,
};
pub fn resolve_tmux_binary() -> Option<std::path::PathBuf> {
trusty_common::bin_resolve::resolve_binary("tmux")
}
pub fn resolve_tmux_binary_or_bare() -> String {
resolve_tmux_binary()
.and_then(|p| p.to_str().map(str::to_string))
.unwrap_or_else(|| "tmux".to_string())
}
pub fn run_tmux_with_bin(
tmux_bin: &str,
cmd: &TmuxCommand,
) -> std::io::Result<std::process::Output> {
crate::core::spawn_disclaim::disclaimed_output(tmux_bin, &tmux_argv(cmd))
}
pub fn run_tmux(cmd: &TmuxCommand) -> std::io::Result<std::process::Output> {
run_tmux_with_bin(&resolve_tmux_binary_or_bare(), cmd)
}
fn managed_session_command_sequence(
name: &str,
workdir: Option<&str>,
history_limit: u32,
mouse: bool,
) -> Vec<TmuxCommand> {
managed_session_commands(name, workdir, history_limit, mouse, true, None)
}
#[derive(Debug)]
pub struct ManagedSessionOutcome {
pub output: std::process::Output,
pub options_verified: bool,
}
pub fn warn_if_options_unverified(outcome: &ManagedSessionOutcome, session_name: &str) {
if !outcome.options_verified {
tracing::error!(
session = %session_name,
"#3386: tmux scrollback ergonomics could not be verified before this pane was \
created — it may be capped at tmux's factory history-limit of 2000 instead of \
the configured value"
);
}
}
pub fn create_managed_session(
tmux_bin: Option<&str>,
name: &str,
workdir: Option<&str>,
) -> std::io::Result<ManagedSessionOutcome> {
let owned_bin;
let bin = match tmux_bin {
Some(b) => b,
None => {
owned_bin = resolve_tmux_binary_or_bare();
&owned_bin
}
};
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let opts = crate::core::trusty_tools_config::resolve_tmux_options(&config);
let commands = managed_session_command_sequence(name, workdir, opts.history_limit, opts.mouse);
let split = commands.len().saturating_sub(1);
let (options, new_session) = commands.split_at(split);
let options_verified = apply_and_verify_scrollback_options(bin, options, opts.history_limit);
if !options_verified {
warn!(
"#3386: tmux scrollback ergonomics could not be verified after \
{APPLY_VERIFY_MAX_ATTEMPTS} attempts for session '{name}' — the pane about to be \
created may be capped at tmux's factory history-limit of 2000; returning \
options_verified=false for the caller to surface"
);
}
let output = run_tmux_with_bin(
bin,
new_session
.first()
.expect("managed_session_command_sequence always ends with NewSession"),
)?;
Ok(ManagedSessionOutcome {
output,
options_verified,
})
}
const START_SERVER_MAX_ATTEMPTS: u8 = 3;
const START_SERVER_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);
const APPLY_VERIFY_MAX_ATTEMPTS: u8 = 3;
const APPLY_VERIFY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);
pub(crate) fn ensure_server_up(bin: &str) -> Result<(), String> {
let mut last_err = String::new();
for attempt in 1..=START_SERVER_MAX_ATTEMPTS {
match run_tmux_with_bin(bin, &TmuxCommand::StartServer) {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => last_err = String::from_utf8_lossy(&output.stderr).into_owned(),
Err(e) => last_err = e.to_string(),
}
if attempt < START_SERVER_MAX_ATTEMPTS {
std::thread::sleep(START_SERVER_RETRY_DELAY);
}
}
Err(last_err)
}
fn apply_and_verify_scrollback_options(
bin: &str,
options: &[TmuxCommand],
expected_history_limit: u32,
) -> bool {
for attempt in 1..=APPLY_VERIFY_MAX_ATTEMPTS {
if let Err(e) = ensure_server_up(bin) {
warn!(
"#3386: tmux start-server failed on apply-and-verify attempt \
{attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
);
} else {
for cmd in options {
match run_tmux_with_bin(bin, cmd) {
Ok(output) if !output.status.success() => {
warn!(
"#3386: tmux {cmd:?} exited non-zero on apply-and-verify attempt \
{attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
warn!(
"#3386: failed to run tmux {cmd:?} on apply-and-verify attempt \
{attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
);
}
Ok(_) => {}
}
}
match probe_history_limit(bin) {
Ok(observed) if observed == expected_history_limit => return true,
Ok(observed) => warn!(
"#3386: tmux history-limit verification mismatch on apply-and-verify \
attempt {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS} — expected \
{expected_history_limit}, observed {observed}"
),
Err(e) => warn!(
"#3386: tmux history-limit verification probe failed on apply-and-verify \
attempt {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
),
}
}
if attempt < APPLY_VERIFY_MAX_ATTEMPTS {
std::thread::sleep(APPLY_VERIFY_RETRY_DELAY);
}
}
false
}
fn probe_history_limit(bin: &str) -> Result<u32, String> {
match run_tmux_with_bin(
bin,
&TmuxCommand::ShowGlobalOption {
name: HISTORY_LIMIT_OPTION.to_string(),
},
) {
Ok(output) if output.status.success() => {
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
raw.parse::<u32>()
.map_err(|_| format!("unparsable show-options output: {raw:?}"))
}
Ok(output) => Err(String::from_utf8_lossy(&output.stderr).into_owned()),
Err(e) => Err(e.to_string()),
}
}
pub fn send_line(
tmux_bin: Option<&str>,
target: &TmuxTarget,
text: &str,
) -> std::io::Result<std::process::Output> {
let owned_bin;
let bin = match tmux_bin {
Some(b) => b,
None => {
owned_bin = resolve_tmux_binary_or_bare();
&owned_bin
}
};
let literal_output = run_tmux_with_bin(
bin,
&TmuxCommand::SendKeys {
target: target.clone(),
keys: text.to_string(),
literal: true,
},
)?;
if !literal_output.status.success() {
return Ok(literal_output);
}
run_tmux_with_bin(
bin,
&TmuxCommand::SendKeys {
target: target.clone(),
keys: "Enter".to_string(),
literal: false,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_tmux_binary_does_not_panic() {
let _ = resolve_tmux_binary();
}
#[test]
fn resolve_tmux_binary_or_bare_never_empty() {
assert!(!resolve_tmux_binary_or_bare().is_empty());
}
#[test]
fn run_tmux_with_bin_does_not_panic_on_missing_binary() {
let result = run_tmux_with_bin(
"definitely-not-a-real-tmux-binary-2398",
&TmuxCommand::ListSessions,
);
assert!(result.is_err());
}
#[test]
fn create_managed_session_does_not_panic_on_missing_binary() {
let result = create_managed_session(
Some("definitely-not-a-real-tmux-binary-2398"),
"tmpm-test-2398-no-bin",
None,
);
assert!(result.is_err());
}
#[test]
fn managed_session_command_sequence_matches_shared_layer() {
let got = managed_session_command_sequence("tmpm-sess", Some("/tmp/proj"), 100_000, true);
let want = trusty_common::tmux::managed_session_commands(
"tmpm-sess",
Some("/tmp/proj"),
100_000,
true,
true,
None,
);
assert_eq!(got, want);
}
#[test]
fn managed_session_command_sequence_applies_options_before_new_session() {
let cmds = managed_session_command_sequence("tmpm-sess", None, 50_000, false);
assert_eq!(cmds.len(), 3);
assert!(matches!(cmds[0], TmuxCommand::SetGlobalOption { .. }));
assert!(matches!(cmds[1], TmuxCommand::SetGlobalOption { .. }));
assert!(matches!(cmds[2], TmuxCommand::NewSession { .. }));
}
fn write_fake_tmux(dir: &std::path::Path, name: &str, body: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, body).expect("write fake tmux script");
let mut perms = std::fs::metadata(&path)
.expect("stat fake tmux script")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("chmod fake tmux script");
path.to_string_lossy().into_owned()
}
#[test]
fn ensure_server_up_retries_then_succeeds() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("attempts");
std::fs::write(&counter, "0").unwrap();
let script = format!(
"#!/bin/sh\nn=$(cat '{counter}')\nn=$((n + 1))\necho \"$n\" > '{counter}'\nif [ \"$n\" -lt 3 ]; then\n echo 'error connecting to socket (No such file or directory)' >&2\n exit 1\nfi\nexit 0\n",
counter = counter.display()
);
let bin = write_fake_tmux(dir.path(), "fake-tmux-retry", &script);
let result = ensure_server_up(&bin);
assert!(
result.is_ok(),
"must eventually succeed within {START_SERVER_MAX_ATTEMPTS} attempts: {result:?}"
);
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
"3",
"must have retried exactly up to the succeeding attempt"
);
}
#[test]
fn ensure_server_up_fails_loudly_after_exhausting_retries() {
let dir = tempfile::tempdir().unwrap();
let script = "#!/bin/sh\necho 'error connecting to socket (No such file or directory)' >&2\nexit 1\n";
let bin = write_fake_tmux(dir.path(), "fake-tmux-always-fails", script);
let result = ensure_server_up(&bin);
assert!(
result.is_err(),
"an always-failing server probe must error loudly, never proceed as if it succeeded"
);
assert!(
result.unwrap_err().contains("error connecting to socket"),
"the Err must carry the underlying failure reason"
);
}
#[test]
fn probe_history_limit_reads_back_configured_value() {
let dir = tempfile::tempdir().unwrap();
let script = "#!/bin/sh\necho 100000\nexit 0\n";
let bin = write_fake_tmux(dir.path(), "fake-tmux-show-options", script);
assert_eq!(probe_history_limit(&bin), Ok(100_000));
}
#[test]
fn probe_history_limit_errors_on_unparsable_output() {
let dir = tempfile::tempdir().unwrap();
let script = "#!/bin/sh\necho 'not-a-number'\nexit 0\n";
let bin = write_fake_tmux(dir.path(), "fake-tmux-bad-output", script);
let result = probe_history_limit(&bin);
assert!(
result.is_err(),
"unparsable show-options output must error loudly, not silently pass verification"
);
}
#[test]
fn probe_history_limit_errors_on_nonzero_exit() {
let dir = tempfile::tempdir().unwrap();
let script = "#!/bin/sh\necho 'no server running' >&2\nexit 1\n";
let bin = write_fake_tmux(dir.path(), "fake-tmux-show-options-fails", script);
assert!(probe_history_limit(&bin).is_err());
}
#[test]
fn create_managed_session_confirms_server_before_applying_options() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("calls.log");
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let opts = crate::core::trusty_tools_config::resolve_tmux_options(&config);
let script = format!(
"#!/bin/sh\necho \"$1\" >> '{log}'\ncase \"$1\" in\n show-options) echo {history_limit} ;;\nesac\nexit 0\n",
log = log.display(),
history_limit = opts.history_limit
);
let bin = write_fake_tmux(dir.path(), "fake-tmux-order", &script);
let outcome = create_managed_session(Some(&bin), "tmpm-3386-order-test", None)
.expect("fake tmux always exits 0");
assert!(
outcome.options_verified,
"a fake tmux that echoes back the configured history-limit must verify successfully"
);
let calls = std::fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = calls.lines().collect();
assert_eq!(
lines.first(),
Some(&"start-server"),
"the server must be confirmed up BEFORE any set-option -g (#3386): {lines:?}"
);
let new_session_pos = lines
.iter()
.position(|l| *l == "new-session")
.expect("new-session must still run");
let set_option_positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| **l == "set-option")
.map(|(i, _)| i)
.collect();
assert!(
!set_option_positions.is_empty(),
"the scrollback/mouse options must still be applied: {lines:?}"
);
assert!(
set_option_positions
.iter()
.all(|&p| p > 0 && p < new_session_pos),
"set-option -g must run strictly AFTER start-server and BEFORE new-session \
(#3386 / pre-existing #2398 ordering guarantee): {lines:?}"
);
}
#[test]
fn apply_and_verify_scrollback_options_succeeds_on_second_attempt() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("probe-attempts");
std::fs::write(&counter, "0").unwrap();
let script = format!(
"#!/bin/sh\ncase \"$1\" in\n show-options)\n n=$(cat '{counter}')\n n=$((n + 1))\n echo \"$n\" > '{counter}'\n if [ \"$n\" -lt 2 ]; then\n echo 2000\n else\n echo 100000\n fi\n ;;\nesac\nexit 0\n",
counter = counter.display()
);
let bin = write_fake_tmux(dir.path(), "fake-tmux-verify-retry", &script);
let options = trusty_common::tmux::scrollback_option_commands(100_000, true);
let verified = apply_and_verify_scrollback_options(&bin, &options, 100_000);
assert!(
verified,
"must succeed once the probe reports the expected value on a later attempt"
);
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
"2",
"must have retried the WHOLE cycle (not just start-server) exactly once"
);
}
#[test]
fn apply_and_verify_scrollback_options_returns_false_after_exhausting_retries() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("probe-attempts");
std::fs::write(&counter, "0").unwrap();
let script = format!(
"#!/bin/sh\ncase \"$1\" in\n show-options)\n n=$(cat '{counter}')\n n=$((n + 1))\n echo \"$n\" > '{counter}'\n echo 2000\n ;;\nesac\nexit 0\n",
counter = counter.display()
);
let bin = write_fake_tmux(dir.path(), "fake-tmux-verify-always-wrong", &script);
let options = trusty_common::tmux::scrollback_option_commands(100_000, true);
let verified = apply_and_verify_scrollback_options(&bin, &options, 100_000);
assert!(
!verified,
"must return false (never silently proceed as verified) once every \
apply-and-verify attempt is exhausted — this is the #3386 review's caller-visible \
degraded signal"
);
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
APPLY_VERIFY_MAX_ATTEMPTS.to_string(),
"must have made exactly APPLY_VERIFY_MAX_ATTEMPTS probe attempts"
);
}
}