From 28b69b256ce71ad39edfc4c05d6af2a761349b23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 19:41:26 -0500 Subject: [PATCH 001/117] TmuxText(feat): Accept names as byte references why: Accessor names should feed byte-based lookups without an explicit conversion or any change to invalid UTF-8. what: - Implement AsRef<[u8]> as an allocation-preserving borrow - Exercise accessor-to-lookup composition and exact invalid-byte matching - Regenerate the public API index --- crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/formats/text.rs | 8 +++++ crates/libtmux/tests/tmux_text.rs | 53 ++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 427000e6..4d9b2339 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -962,6 +962,7 @@ function libtmux::test::reap_abandoned_servers: fn(older_than: std::time::Durati function libtmux::test::retry_until: async fn(within: std::time::Duration, condition: impl AsyncFnMut() -> bool) -> Result<(), libtmux::test::RetryTimeout> function libtmux::test::scaled: fn(base: std::time::Duration) -> std::time::Duration function libtmux::test::unique_name: fn(prefix: &str) -> String +impl AsRef<[u8]> for libtmux::TmuxText impl AsRef for libtmux::PaneId impl AsRef for libtmux::SessionId impl AsRef for libtmux::WindowId diff --git a/crates/libtmux/src/formats/text.rs b/crates/libtmux/src/formats/text.rs index 3f031477..a48428de 100644 --- a/crates/libtmux/src/formats/text.rs +++ b/crates/libtmux/src/formats/text.rs @@ -5,6 +5,8 @@ use std::fmt; /// /// `TmuxText` preserves bytes exactly. Callers choose whether to inspect the /// raw bytes, require UTF-8, or decode lossily. +/// [`AsRef<[u8]>`] borrows those bytes, including invalid UTF-8, so accessor +/// results can be passed directly to name lookups such as [`crate::Server::session`]. /// /// # Examples /// @@ -139,6 +141,12 @@ impl From<&str> for TmuxText { } } +impl AsRef<[u8]> for TmuxText { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + impl From for TmuxText { fn from(value: String) -> Self { Self::from_bytes(value.into_bytes()) diff --git a/crates/libtmux/tests/tmux_text.rs b/crates/libtmux/tests/tmux_text.rs index 89d92a9d..e530a705 100644 --- a/crates/libtmux/tests/tmux_text.rs +++ b/crates/libtmux/tests/tmux_text.rs @@ -78,6 +78,59 @@ fn explicit_views_prevent_implicit_lossy_conversion() { assert_eq!(invalid.as_bytes(), [b'a', 0xff, b'b']); } +#[test] +fn byte_references_preserve_the_allocation_and_invalid_utf8() { + let text = TmuxText::from_bytes([b'a', 0, 0xff]); + let bytes: &[u8] = text.as_ref(); + assert!(std::ptr::eq(bytes, text.as_bytes())); + assert_eq!(bytes, [b'a', 0, 0xff]); +} + +#[cfg(feature = "test-support")] +#[tokio::test] +async fn accessor_names_compose_with_byte_name_lookups() { + let guard = libtmux::test::TestServer::new().await.expect("tmux starts"); + let session = guard + .server() + .new_session("s\u{fffd}") + .await + .expect("session"); + let found = guard + .server() + .session(session.name()) + .await + .expect("session lookup") + .expect("session exists"); + assert_eq!(found.id(), session.id()); + assert_eq!(found.name(), session.name()); + assert!( + guard + .server() + .session(TmuxText::from_bytes([b's', 0xff])) + .await + .expect("byte name lookup") + .is_none(), + "invalid UTF-8 must not match a name containing the replacement character" + ); + + let window = session.new_window("w\u{fffd}").await.expect("window"); + let found = session + .window(window.name()) + .await + .expect("window lookup") + .expect("window exists"); + assert_eq!(found.id(), window.id()); + assert_eq!(found.name(), window.name()); + assert!( + session + .window(TmuxText::from_bytes([b'w', 0xfe])) + .await + .expect("byte name lookup") + .is_none() + ); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[test] fn bytewise_traits_reject_unicode_based_equality_and_ordering() { let lower = TmuxText::from_bytes(Vec::from([0x80])); From 4b8cea24954951fbbab87b18e34bf653cea9b905 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 19:46:59 -0500 Subject: [PATCH 002/117] Query(feat): Compose owned iterator results why: Selecting owned values should not require cloning them. A generic Borrow replacement makes generic matchers ambiguous on references. what: - Keep matching for borrowed candidates and add matching_owned - Preserve each iterator's item type in cardinality results - Cover non-Clone values, adapters, matcher inference and lifetimes - Record the public trait-bound change and owned consumer examples --- crates/libtmux/docs/public-api.txt | 11 +++-- crates/libtmux/src/lib.rs | 32 +++++++++++-- crates/libtmux/src/query.rs | 52 ++++++++++++++++---- crates/libtmux/tests/query.rs | 76 +++++++++++++++++++++++++++++- 4 files changed, 154 insertions(+), 17 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 4d9b2339..b8db009e 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -925,9 +925,10 @@ function libtmux::query::ManyRelation::any: fn(self, expression: libtmux::query: function libtmux::query::ManyRelation::none: fn(self, expression: libtmux::query::FilterExpr) -> libtmux::query::FilterExpr function libtmux::query::Matcher::matches: fn(&self, candidate: &T) -> bool function libtmux::query::OneRelation::is: fn(self, expression: libtmux::query::FilterExpr) -> libtmux::query::FilterExpr -function libtmux::query::QueryIteratorExt::exactly_one: fn(self) -> Result<&'a T, libtmux::query::ExactlyOneError> -function libtmux::query::QueryIteratorExt::matching: fn>(self, matcher: M) -> impl Iterator -function libtmux::query::QueryIteratorExt::one_or_none: fn(self) -> Result, libtmux::query::MultipleItemsError> +function libtmux::query::QueryIteratorExt::exactly_one: fn(self) -> Result<::Item, libtmux::query::ExactlyOneError> +function libtmux::query::QueryIteratorExt::matching: fn<'a, T: 'a, M: libtmux::query::Matcher>(self, matcher: M) -> impl Iterator where Self: Iterator +function libtmux::query::QueryIteratorExt::matching_owned: fn::Item>>(self, matcher: M) -> impl Iterator::Item> +function libtmux::query::QueryIteratorExt::one_or_none: fn(self) -> Result::Item>, libtmux::query::MultipleItemsError> function libtmux::query::TextField::contains: fn(self, value: impl Into) -> libtmux::query::FilterExpr function libtmux::query::TextField::contains_ignore_case: fn(self, value: impl Into) -> libtmux::query::FilterExpr function libtmux::query::TextField::ends_with: fn(self, value: impl Into) -> libtmux::query::FilterExpr @@ -1648,7 +1649,6 @@ impl libtmux::query::Filterable for libtmux::Session impl libtmux::query::Filterable for libtmux::SessionTree impl libtmux::query::Filterable for libtmux::Window impl libtmux::query::Filterable for libtmux::WindowTree -impl<'a, T: 'a, I> libtmux::query::QueryIteratorExt<'a, T> for I where I: Iterator + Sized impl<'de, T: libtmux::query::Filterable> Deserialize<'de> for libtmux::query::FilterExpr impl<'de, T> Deserialize<'de> for libtmux::plan::Slot impl<'de> Deserialize<'de> for libtmux::plan::CapturePane @@ -1686,6 +1686,7 @@ impl Eq for libtmux::query::ManyRelation impl Eq for libtmux::query::OneRelation impl PartialEq for libtmux::query::ManyRelation impl PartialEq for libtmux::query::OneRelation +impl libtmux::query::QueryIteratorExt for I impl Clone for libtmux::query::EnumField impl Copy for libtmux::query::EnumField impl Debug for libtmux::query::EnumField @@ -2119,7 +2120,7 @@ trait libtmux::query::FilterEnum trait libtmux::query::FilterSchema: libtmux::query::Filterable trait libtmux::query::Filterable: Sized trait libtmux::query::Matcher -trait libtmux::query::QueryIteratorExt<'a, T: 'a>: Iterator + Sized +trait libtmux::query::QueryIteratorExt: Iterator + Sized type_alias libtmux::IndexedHooks = libtmux::SparseValues variant libtmux::AccessMode::ReadOnly variant libtmux::AccessMode::Write diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index f0c6a003..f44a9223 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -195,13 +195,39 @@ //! # } //! ``` //! -//! Query extensions intentionally apply only to borrowed iterators: +//! Use `matching_owned` to move selected items out of their collection: //! -//! ```compile_fail +//! ``` //! use libtmux::query::QueryIteratorExt; //! //! let values = vec![1, 2, 3]; -//! let _ = values.into_iter().matching(|candidate: &i32| *candidate > 1); +//! let selected = values +//! .into_iter() +//! .matching_owned(|candidate: &i32| *candidate > 1) +//! .collect::>(); +//! assert_eq!(selected, [2, 3]); +//! ``` +//! +//! Borrowed results cannot outlive their collection: +//! +//! ```compile_fail +//! use libtmux::query::QueryIteratorExt; +//! +//! let selected = { +//! let values = vec![String::from("only")]; +//! values.iter().exactly_one().unwrap() +//! }; +//! println!("{selected}"); +//! ``` +//! +//! Consuming a collection transfers ownership: +//! +//! ```compile_fail +//! use libtmux::query::QueryIteratorExt; +//! +//! let values = vec![String::from("only")]; +//! let selected = values.into_iter().one_or_none().unwrap(); +//! println!("{values:?} {selected:?}"); //! ``` #![cfg_attr( feature = "control-mode", diff --git a/crates/libtmux/src/query.rs b/crates/libtmux/src/query.rs index 4fa1f0be..54ccb795 100644 --- a/crates/libtmux/src/query.rs +++ b/crates/libtmux/src/query.rs @@ -1,4 +1,4 @@ -//! Predicates and cardinality helpers for borrowed iterators. +//! Predicates and cardinality helpers for native iterators. //! //! Start with a replayable collection, borrow it with `.iter()`, and keep //! inline closures on native [`Iterator::filter`]. Use @@ -25,7 +25,9 @@ //! //! [`QueryIteratorExt::exactly_one`] distinguishes zero from multiple items; //! [`QueryIteratorExt::one_or_none`] permits zero but rejects multiple items. -//! Both return borrowed values and pull at most two items. +//! Both preserve the iterator's item type and pull at most two items. +//! To take ownership, use `.into_iter()` with +//! [`QueryIteratorExt::matching_owned`]; selected values need not be `Clone`. //! //! Portable expressions are owned, inert local values. With `derive`, typed //! handles can be generated for downstream data without exposing the hidden @@ -483,7 +485,11 @@ impl fmt::Display for MultipleItemsError { impl std::error::Error for MultipleItemsError {} -/// Cardinality and named-predicate operations for borrowed iterators. +/// Cardinality and named-predicate operations for native iterators. +/// +/// [`Self::matching`] borrows candidates from a borrowed iterator; +/// [`Self::matching_owned`] moves each selected item from its iterator. +/// Cardinality methods preserve the iterator's item type in either case. /// /// # Examples /// @@ -494,7 +500,7 @@ impl std::error::Error for MultipleItemsError {} /// assert_eq!(values.iter().exactly_one(), Err(libtmux::query::ExactlyOneError::MultipleItems)); /// ``` #[allow(clippy::module_name_repetitions)] -pub trait QueryIteratorExt<'a, T: 'a>: Iterator + Sized { +pub trait QueryIteratorExt: Iterator + Sized { /// Lazily yield candidates accepted by `matcher`. /// /// # Examples @@ -514,10 +520,40 @@ pub trait QueryIteratorExt<'a, T: 'a>: Iterator + Sized { /// let selected = values.iter().matching(IsEven).copied().collect::>(); /// assert_eq!(selected, [2, 4]); /// ``` - fn matching>(self, matcher: M) -> impl Iterator { + fn matching<'a, T: 'a, M: Matcher>(self, matcher: M) -> impl Iterator + where + Self: Iterator, + { self.filter(move |candidate| matcher.matches(*candidate)) } + /// Lazily move items accepted by `matcher`, without cloning them. + /// + /// The matcher borrows each item for the comparison. Use + /// [`Self::matching`] with `iter()` to retain a borrow of each item. + /// Ordinary [`Iterator::filter`] also accepts closures and infers their + /// argument types; closures passed here need an explicit argument type. + /// + /// # Examples + /// + /// ``` + /// use libtmux::query::QueryIteratorExt; + /// + /// let names = vec![String::from("build"), String::from("test")]; + /// let selected = names + /// .into_iter() + /// .matching_owned(|name: &String| name.starts_with('b')) + /// .exactly_one()?; + /// assert_eq!(selected, "build"); + /// # Ok::<(), libtmux::query::ExactlyOneError>(()) + /// ``` + fn matching_owned>( + self, + matcher: M, + ) -> impl Iterator { + self.filter(move |candidate| matcher.matches(candidate)) + } + /// Return the only item, or an error for zero or multiple items. /// /// At most two items are pulled from the iterator. @@ -535,7 +571,7 @@ pub trait QueryIteratorExt<'a, T: 'a>: Iterator + Sized { /// let values = [7]; /// assert_eq!(values.iter().exactly_one(), Ok(&values[0])); /// ``` - fn exactly_one(mut self) -> Result<&'a T, ExactlyOneError> { + fn exactly_one(mut self) -> Result { let Some(item) = self.next() else { return Err(ExactlyOneError::NoItems); }; @@ -566,7 +602,7 @@ pub trait QueryIteratorExt<'a, T: 'a>: Iterator + Sized { /// let values = [7]; /// assert_eq!(values.iter().one_or_none(), Ok(Some(&values[0]))); /// ``` - fn one_or_none(mut self) -> Result, MultipleItemsError> { + fn one_or_none(mut self) -> Result, MultipleItemsError> { let item = self.next(); if item.is_some() && self.next().is_some() { Err(MultipleItemsError) @@ -576,7 +612,7 @@ pub trait QueryIteratorExt<'a, T: 'a>: Iterator + Sized { } } -impl<'a, T: 'a, I> QueryIteratorExt<'a, T> for I where I: Iterator + Sized {} +impl QueryIteratorExt for I {} /// The category of an invalid portable filter expression. /// diff --git a/crates/libtmux/tests/query.rs b/crates/libtmux/tests/query.rs index 73c1f139..618e8e34 100644 --- a/crates/libtmux/tests/query.rs +++ b/crates/libtmux/tests/query.rs @@ -1,4 +1,4 @@ -//! Public contract tests for borrowed query iterator extensions. +//! Public contract tests for query iterator composition. #![cfg(feature = "query")] @@ -1077,6 +1077,80 @@ fn named_matchers_filter_borrowed_items_in_order() { assert_eq!(selected, [4, 2, 6]); } +#[test] +fn owned_matching_moves_non_clone_values_through_adapters() { + let kept = Rc::new(()); + let values = vec![Rc::new(()), Rc::clone(&kept)]; + let selected = values + .into_iter() + .map(NoTraits) + .matching_owned(|candidate: &NoTraits| Rc::ptr_eq(&candidate.0, &kept)) + .exactly_one() + .expect("one owned value matches"); + assert!(Rc::ptr_eq(&selected.0, &kept)); + assert_eq!(Rc::strong_count(&kept), 2); +} + +#[test] +fn matcher_inference_is_unambiguous_for_both_ownership_paths() { + struct Any; + impl Matcher for Any { + fn matches(&self, _: &T) -> bool { + true + } + } + + let values = [1, 2, 3, 4]; + assert_eq!(values.iter().skip(1).matching(Any).count(), 3); + assert_eq!(values.into_iter().skip(1).matching_owned(Any).count(), 3); + assert_eq!( + values + .into_iter() + .matching_owned(IsEven) + .collect::>(), + [2, 4] + ); + + let fields = ScalarCandidate::filter_fields(); + let expression = fields.u8_value.eq(u8::MIN); + let candidates = [ + ScalarCandidate::extrema(false), + ScalarCandidate::extrema(true), + ]; + let borrowed = candidates + .iter() + .matching(&expression) + .exactly_one() + .expect("one borrowed candidate"); + assert!(std::ptr::eq(borrowed, &raw const candidates[0])); + let owned = candidates + .into_iter() + .matching_owned(&expression) + .one_or_none() + .expect("at most one owned candidate") + .expect("one candidate"); + assert_eq!(owned.u8_value, u8::MIN); +} + +#[test] +fn owned_cardinality_covers_empty_multiple_and_bounded_consumption() { + let empty = std::iter::empty::(); + assert!(matches!(empty.exactly_one(), Err(ExactlyOneError::NoItems))); + assert!( + std::iter::empty::() + .one_or_none() + .expect("no items") + .is_none() + ); + let visits = Cell::new(0); + let values = (0..).inspect(|_| visits.set(visits.get() + 1)); + assert_eq!(values.exactly_one(), Err(ExactlyOneError::MultipleItems)); + assert_eq!(visits.get(), 2); + let values = (0..).inspect(|_| visits.set(visits.get() + 1)); + assert_eq!(values.one_or_none(), Err(MultipleItemsError)); + assert_eq!(visits.get(), 4); +} + #[test] fn explicitly_typed_closures_are_matchers() { let values = [1, 2, 3, 4]; From 84fe07bc55aaf6b88aaba3247157e6573f659bd5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 19:54:11 -0500 Subject: [PATCH 003/117] Scope(fix): Preserve operation and cleanup errors why: Cleanup used to replace the caller's error when both failed, losing its type and value at the point where both diagnostics matter. what: - Return ScopeError from session, window and pane scopes - Retain both errors and mark cleanup as an AfterEffect - Keep generic operation values recoverable without formatting them - Document source chaining for creation and cleanup; generic operation values remain in their variants, including boxed and non-Error values - Verify real double failures, ownership, redaction and boxed consumers --- crates/libtmux/docs/design.md | 4 + crates/libtmux/docs/public-api.txt | 19 +++- crates/libtmux/src/error.rs | 3 + crates/libtmux/src/error/scoped.rs | 89 +++++++++++++++ crates/libtmux/src/internal/scoped.rs | 35 ++++-- crates/libtmux/src/lib.rs | 12 +- crates/libtmux/src/server.rs | 23 ++-- crates/libtmux/src/session.rs | 23 ++-- crates/libtmux/src/window.rs | 23 ++-- crates/libtmux/tests/commands.rs | 19 +--- crates/libtmux/tests/scoped.rs | 154 ++++++++++++++++++++++++++ 11 files changed, 327 insertions(+), 77 deletions(-) create mode 100644 crates/libtmux/src/error/scoped.rs create mode 100644 crates/libtmux/tests/scoped.rs diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 09ea0d9b..272f1882 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -683,6 +683,10 @@ arms cleanup before handing the object to the caller, and keeps cleanup running after cancellation. Cleanup needs the Tokio runtime to remain active; ordinary cloneable handles remain non-destructive. +`ScopeError` separates creation, operation and cleanup failures. Combined +failures retain both the caller's generic error and the cleanup `Error`; +cleanup errors carry `AfterEffect` because creation already succeeded. + If tmux creates an object but the command fails before yielding a decodable handle, the scope has no identity to target and cannot compensate for it. diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index b8db009e..3bdbd781 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -264,6 +264,7 @@ enum libtmux::PromptKind enum libtmux::ReplaceMode enum libtmux::ResizeDirection enum libtmux::Rotation +enum libtmux::ScopeError enum libtmux::ServerConfigurationErrorKind enum libtmux::ServerGoneKind enum libtmux::SessionNameError @@ -561,7 +562,7 @@ function libtmux::Server::wait_for_channel: async fn(&self, channel: &str, withi function libtmux::Server::window_by_id: async fn(&self, id: &libtmux::WindowId) -> Result, libtmux::Error> function libtmux::Server::windows: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::windows_or_empty: async fn(&self) -> Vec -function libtmux::Server::with_session: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Session) -> Result) -> Result where E: From +function libtmux::Server::with_session: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Session) -> Result) -> Result> function libtmux::ServerBuilder::build: fn(self) -> Result function libtmux::ServerBuilder::colors: const fn(self, colors: u16) -> Self function libtmux::ServerBuilder::config_file: fn(self, path: impl Into) -> Self @@ -623,7 +624,7 @@ function libtmux::Session::window_at: async fn(&self, index: i32) -> Result u32 function libtmux::Session::windows: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::windows_or_empty: async fn(&self) -> Vec -function libtmux::Session::with_window: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Window) -> Result) -> Result where E: From +function libtmux::Session::with_window: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Window) -> Result) -> Result> function libtmux::SessionName::as_str: fn(&self) -> &str function libtmux::SessionName::new: fn(name: impl Into) -> Result function libtmux::SparseValues::first: fn(&self) -> Option<&T> @@ -712,7 +713,7 @@ function libtmux::Window::unlink: async fn(self) -> Result<(), libtmux::Error> function libtmux::Window::unset_hook: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Window::unset_option: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Window::width: fn(&self) -> u32 -function libtmux::Window::with_pane: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Pane) -> Result) -> Result where E: From +function libtmux::Window::with_pane: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Pane) -> Result) -> Result> function libtmux::blocking::Runtime::new: fn() -> Result function libtmux::blocking::Runtime::run: fn(&self, future: F) -> ::Output function libtmux::blocking::Runtime::try_run: fn(&self, future: F) -> Result<::Output, libtmux::Error> @@ -1676,6 +1677,9 @@ impl<'de> Deserialize<'de> for libtmux::plan::SplitWindow impl<'de> Deserialize<'de> for libtmux::plan::WindowSlot impl<'de> Deserialize<'de> for libtmux::plan::WindowTarget impl<'values, T> IntoIterator for &'values libtmux::SparseValues +impl Debug for libtmux::ScopeError +impl Display for libtmux::ScopeError +impl Error for libtmux::ScopeError impl Clone for libtmux::query::ManyRelation impl Clone for libtmux::query::OneRelation impl Copy for libtmux::query::ManyRelation @@ -2004,6 +2008,11 @@ struct_field libtmux::PaneFields::wrap_flag: libtmux::query::BoolField struct_field libtmux::PaneSize::Cells::0: u32 struct_field libtmux::PaneSize::Percent::0: u32 struct_field libtmux::PaneTarget::Id::0: libtmux::PaneId +struct_field libtmux::ScopeError::Cleanup::0: libtmux::Error +struct_field libtmux::ScopeError::Creation::0: libtmux::Error +struct_field libtmux::ScopeError::Operation::0: E +struct_field libtmux::ScopeError::OperationAndCleanup::cleanup: libtmux::Error +struct_field libtmux::ScopeError::OperationAndCleanup::operation: E struct_field libtmux::SessionFields::session_activity: libtmux::query::IntegerField struct_field libtmux::SessionFields::session_attached: libtmux::query::IntegerField struct_field libtmux::SessionFields::session_created: libtmux::query::IntegerField @@ -2242,6 +2251,10 @@ variant libtmux::ResizeDirection::Right variant libtmux::ResizeDirection::Up variant libtmux::Rotation::Down variant libtmux::Rotation::Up +variant libtmux::ScopeError::Cleanup +variant libtmux::ScopeError::Creation +variant libtmux::ScopeError::Operation +variant libtmux::ScopeError::OperationAndCleanup variant libtmux::ServerConfigurationErrorKind::ConflictingSocketSelectors variant libtmux::ServerConfigurationErrorKind::InvalidColorMode variant libtmux::ServerConfigurationErrorKind::InvalidConfigPath diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index 84e61141..d059dd8f 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -9,6 +9,9 @@ use crate::version::{ReleaseVersion, TmuxVersion}; mod classification; mod refusal; +mod scoped; + +pub use scoped::ScopeError; /// The category of an invalid [`crate::ServerBuilder`] configuration. /// diff --git a/crates/libtmux/src/error/scoped.rs b/crates/libtmux/src/error/scoped.rs new file mode 100644 index 00000000..dd59bb94 --- /dev/null +++ b/crates/libtmux/src/error/scoped.rs @@ -0,0 +1,89 @@ +use std::fmt; + +use super::Error; + +/// A scoped resource's creation, operation, or cleanup failure. +/// +/// Returned by [`crate::Server::with_session`], +/// [`crate::Session::with_window`], and [`crate::Window::with_pane`]. The +/// operation error keeps its original type and value, with no `From` +/// requirement. Cleanup failures retain [`Error::AfterEffect`] because +/// creation succeeded; an operation error alone makes no replay guarantee. +/// +/// `Debug` and `Display` withhold the operation error's contents. Inspect its +/// variant to retrieve it. [`std::error::Error::source`] exposes creation or +/// cleanup errors. The operation value is available through its variant, +/// since its generic type need not implement [`std::error::Error`]. +/// +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; +/// # runtime.block_on(async { +/// use libtmux::ScopeError; +/// +/// let guard = libtmux::test::TestServer::new().await?; +/// let outcome = guard.server().with_session("work", async |_session| { +/// Err::<(), _>("operation failed") +/// }).await; +/// assert!(matches!(outcome, Err(ScopeError::Operation("operation failed")))); +/// guard.shutdown().await?; +/// # Ok::<(), Box>(()) +/// # })?; +/// # Ok(()) +/// # } +/// ``` +pub enum ScopeError { + /// The resource could not be created; the operation did not run. + Creation(Error), + /// The operation failed and cleanup succeeded. + Operation(E), + /// The operation succeeded, but cleanup failed after creation. + Cleanup(Error), + /// The operation and cleanup both failed. + OperationAndCleanup { + /// The caller's original operation error. + operation: E, + /// The cleanup error, marked as [`Error::AfterEffect`]. + cleanup: Error, + }, +} + +impl fmt::Debug for ScopeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Creation(error) => formatter.debug_tuple("Creation").field(error).finish(), + Self::Operation(_) => formatter.write_str("Operation()"), + Self::Cleanup(error) => formatter.debug_tuple("Cleanup").field(error).finish(), + Self::OperationAndCleanup { cleanup, .. } => formatter + .debug_struct("OperationAndCleanup") + .field("operation", &"") + .field("cleanup", cleanup) + .finish(), + } + } +} + +impl fmt::Display for ScopeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Creation(error) => write!(formatter, "scoped resource creation failed: {error}"), + Self::Operation(_) => formatter.write_str("scoped operation failed"), + Self::Cleanup(error) => write!(formatter, "scoped resource cleanup failed: {error}"), + Self::OperationAndCleanup { cleanup, .. } => { + write!(formatter, "scoped operation and cleanup failed: {cleanup}") + } + } + } +} + +impl std::error::Error for ScopeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Creation(error) | Self::Cleanup(error) => Some(error), + Self::Operation(_) => None, + Self::OperationAndCleanup { cleanup, .. } => Some(cleanup), + } + } +} diff --git a/crates/libtmux/src/internal/scoped.rs b/crates/libtmux/src/internal/scoped.rs index 5ffce914..d47a7343 100644 --- a/crates/libtmux/src/internal/scoped.rs +++ b/crates/libtmux/src/internal/scoped.rs @@ -3,7 +3,7 @@ use std::future::Future; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use crate::Error; +use crate::{Error, ScopeError}; #[cfg(feature = "tracing")] use tracing::instrument::WithSubscriber as _; @@ -13,22 +13,26 @@ pub(crate) async fn run( create: Create, cleanup: Cleanup, operation: Operation, -) -> Result +) -> Result> where R: Clone + Send + 'static, Create: Future> + Send + 'static, Cleanup: FnOnce(R) -> CleanupFuture + Send + 'static, CleanupFuture: Future> + Send + 'static, Operation: AsyncFnOnce(&R) -> Result, - E: From, { - let (created, cleanup) = acquire(create, cleanup).await.map_err(E::from)?; + let (created, cleanup) = acquire(create, cleanup) + .await + .map_err(ScopeError::Creation)?; let outcome = operation(&created).await; match (outcome, cleanup.finish().await) { - (outcome, Ok(())) => outcome, - (Ok(_), Err(error)) => Err(error.after_effect(operation_name).into()), - (Err(_), Err(cleanup)) => Err(cleanup.after_effect(operation_name).into()), + (outcome, Ok(())) => outcome.map_err(ScopeError::Operation), + (Ok(_), Err(error)) => Err(ScopeError::Cleanup(error.after_effect(operation_name))), + (Err(operation), Err(cleanup)) => Err(ScopeError::OperationAndCleanup { + operation, + cleanup: cleanup.after_effect(operation_name), + }), } } @@ -140,7 +144,7 @@ mod tests { use tokio::sync::Notify; - use crate::{Command, Error, ErrorKind, ObjectKind}; + use crate::{Command, Error, ErrorKind, ObjectKind, ScopeError}; #[cfg(feature = "tracing")] use tracing::subscriber::Subscriber; @@ -177,6 +181,9 @@ mod tests { .await .expect_err("cleanup fails after the scoped operation succeeded"); + let ScopeError::Cleanup(error) = error else { + panic!("cleanup failed after the operation succeeded"); + }; assert_eq!(error.kind(), ErrorKind::PartialEffect); assert!( matches!( @@ -214,8 +221,18 @@ mod tests { .await .expect_err("both operation and cleanup fail"); + let ScopeError::OperationAndCleanup { operation, cleanup } = error else { + panic!("operation and cleanup both failed"); + }; + assert!(matches!( + operation, + Error::ObjectGone { + kind: ObjectKind::Window, + .. + } + )); assert!(matches!( - error, + cleanup, Error::AfterEffect { operation: "with-window", source } if matches!(*source, Error::Overloaded { .. }) )); diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index f44a9223..6cfacd58 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -60,7 +60,7 @@ //! `Drop` is deliberately non-destructive. //! //! ```no_run -//! # async fn scoped(server: &libtmux::Server) -> Result<(), libtmux::Error> { +//! # async fn scoped(server: &libtmux::Server) -> Result<(), libtmux::ScopeError> { //! let id = server //! .with_session("throwaway", async |session| { //! session.new_window("build").await?; @@ -72,10 +72,10 @@ //! # } //! ``` //! -//! Setup and teardown failures convert into the operation's own error type, -//! so there is one `?` rather than two. Once creation succeeds, a cleanup -//! failure is returned as an after-effect; it owns the replay guidance even -//! when the operation also failed. +//! [`ScopeError`] distinguishes creation, operation and cleanup failures. +//! When both operation and cleanup fail, it retains both errors without +//! converting the operation's error type. Cleanup errors carry +//! [`Error::AfterEffect`] because creation already succeeded. //! //! ## Options carry types //! @@ -322,7 +322,7 @@ pub use command::{Command, CommandChain, CommandResult, CommandSummary}; #[cfg(feature = "control-mode")] pub use error::ControlModeErrorKind; pub use error::{ - Error, ErrorKind, IdParseError, ListingDecodeError, ObjectKind, OptionErrorKind, + Error, ErrorKind, IdParseError, ListingDecodeError, ObjectKind, OptionErrorKind, ScopeError, ServerConfigurationErrorKind, ServerGoneKind, }; pub use formats::TmuxText; diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index c2fb7e3e..374f4ce3 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1255,28 +1255,21 @@ impl Server { /// session whose creation yields a handle is killed while the Tokio /// runtime remains active. Ordinary handle `Drop` remains non-destructive. /// - /// Setup and teardown failures convert into the operation's own error - /// type, so a caller writes one `?` rather than unwrapping twice. When - /// both the operation and cleanup fail, the cleanup error is returned as - /// [`Error::AfterEffect`], because tmux had already accepted the scope's - /// creation; the operation error is discarded. When the operation fails - /// and cleanup succeeds, its generic error is returned unchanged: the - /// scope cannot certify replay safety for arbitrary callback work. - /// A canceled caller cannot receive a cleanup error, so tracing is its - /// only report. + /// [`crate::ScopeError`] retains creation, operation and cleanup failures + /// separately. If operation and cleanup both fail, both original errors + /// are returned. Cleanup errors carry [`Error::AfterEffect`] because + /// creation succeeded. A canceled caller cannot receive a cleanup error; + /// the `tracing` feature records that failure while the runtime is active. /// /// # Errors /// - /// Returns the operation's error, or a converted [`Error`] when the - /// session could not be created or could not be killed after creation. + /// Returns [`crate::ScopeError`] when creation, the operation, or cleanup + /// fails. The operation's error needs no conversion into [`Error`]. pub async fn with_session( &self, options: impl Into, operation: impl AsyncFnOnce(&Session) -> Result, - ) -> Result - where - E: From, - { + ) -> Result> { let server = self.clone(); let options = options.into(); scoped::run( diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index 36bcd5ae..0de3be61 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -793,28 +793,21 @@ impl Session { /// window whose creation yields a handle is killed while the Tokio runtime /// remains active. Ordinary handle `Drop` remains non-destructive. /// - /// Setup and teardown failures convert into the operation's own error - /// type, so a caller writes one `?` rather than unwrapping twice. When - /// both the operation and cleanup fail, the cleanup error is returned as - /// [`Error::AfterEffect`], because tmux had already accepted the scope's - /// creation; the operation error is discarded. When the operation fails - /// and cleanup succeeds, its generic error is returned unchanged: the - /// scope cannot certify replay safety for arbitrary callback work. - /// A canceled caller cannot receive a cleanup error, so tracing is its - /// only report. + /// [`crate::ScopeError`] retains creation, operation and cleanup failures + /// separately. If operation and cleanup both fail, both original errors + /// are returned. Cleanup errors carry [`Error::AfterEffect`] because + /// creation succeeded. A canceled caller cannot receive a cleanup error; + /// the `tracing` feature records that failure while the runtime is active. /// /// # Errors /// - /// Returns the operation's error, or a converted [`Error`] when the - /// window could not be created or could not be killed after creation. + /// Returns [`crate::ScopeError`] when creation, the operation, or cleanup + /// fails. The operation's error needs no conversion into [`Error`]. pub async fn with_window( &self, options: impl Into, operation: impl AsyncFnOnce(&Window) -> Result, - ) -> Result - where - E: From, - { + ) -> Result> { let session = self.clone(); let options = options.into(); scoped::run( diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 6f5504e5..15328798 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -784,28 +784,21 @@ impl Window { /// pane whose creation yields a handle is killed while the Tokio runtime /// remains active. Ordinary handle `Drop` remains non-destructive. /// - /// Setup and teardown failures convert into the operation's own error - /// type, so a caller writes one `?` rather than unwrapping twice. When - /// both the operation and cleanup fail, the cleanup error is returned as - /// [`Error::AfterEffect`], because tmux had already accepted the scope's - /// creation; the operation error is discarded. When the operation fails - /// and cleanup succeeds, its generic error is returned unchanged: the - /// scope cannot certify replay safety for arbitrary callback work. - /// A canceled caller cannot receive a cleanup error, so tracing is its - /// only report. + /// [`crate::ScopeError`] retains creation, operation and cleanup failures + /// separately. If operation and cleanup both fail, both original errors + /// are returned. Cleanup errors carry [`Error::AfterEffect`] because + /// creation succeeded. A canceled caller cannot receive a cleanup error; + /// the `tracing` feature records that failure while the runtime is active. /// /// # Errors /// - /// Returns the operation's error, or a converted [`Error`] when the - /// pane could not be created or could not be killed after creation. + /// Returns [`crate::ScopeError`] when creation, the operation, or cleanup + /// fails. The operation's error needs no conversion into [`Error`]. pub async fn with_pane( &self, options: impl Into, operation: impl AsyncFnOnce(&Pane) -> Result, - ) -> Result - where - E: From, - { + ) -> Result> { let window = self.clone(); let options = options.into(); scoped::run( diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index b222080b..423678d2 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -171,20 +171,10 @@ async fn sourcing_a_file_applies_its_commands() { guard.shutdown().await.expect("tmux fixture shuts down"); } -/// A caller's own error type, carrying `From`. -/// -/// That conversion is what lets a scope's setup and teardown failures join -/// the same channel as the operation's own. +/// A caller's own error type, without a libtmux conversion. #[derive(Debug, PartialEq)] enum Failure { Deliberate, - Tmux(String), -} - -impl From for Failure { - fn from(error: libtmux::Error) -> Self { - Self::Tmux(error.to_string()) - } } #[tokio::test] @@ -202,14 +192,15 @@ async fn scoped_operations_clean_up_after_success_and_failure() { assert!(seen.starts_with('$')); assert!(server.sessions().await.expect("sessions").is_empty()); - // Failure: the operation's error comes back, and cleanup still ran. - // The operation's error comes back directly: one `?`, not two. let outcome = server .with_session("failing", async |_session| { Err::<(), Failure>(Failure::Deliberate) }) .await; - assert_eq!(outcome, Err(Failure::Deliberate)); + assert!(matches!( + outcome, + Err(libtmux::ScopeError::Operation(Failure::Deliberate)) + )); assert!( server.sessions().await.expect("sessions").is_empty(), "cleanup runs even when the operation failed", diff --git a/crates/libtmux/tests/scoped.rs b/crates/libtmux/tests/scoped.rs new file mode 100644 index 00000000..a91c2b6c --- /dev/null +++ b/crates/libtmux/tests/scoped.rs @@ -0,0 +1,154 @@ +//! Scoped operations retain the caller's error when cleanup also fails. + +#![cfg(feature = "test-support")] + +use std::rc::Rc; + +use libtmux::test::TestServer; +use libtmux::{Error, ErrorKind, NewWindowOptions, ScopeError, SplitDirection, SplitOptions}; + +struct OperationFailure(Rc<()>); + +#[tokio::test] +async fn cleanup_failure_retains_the_owned_operation_error() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let witness = Rc::new(()); + let result = guard + .server() + .with_session("scope-errors", async |session| { + session.clone().kill().await.expect("session is killed"); + Err::<(), _>(OperationFailure(Rc::clone(&witness))) + }) + .await; + guard.shutdown().await.expect("tmux fixture shuts down"); + assert_eq!( + Rc::strong_count(&witness), + 2, + "the result must retain the original operation error after cleanup fails" + ); + assert!(format!("{result:?}").contains("")); + assert_combined(result.expect_err("operation and cleanup fail"), &witness); + assert_eq!(Rc::strong_count(&witness), 1); +} + +#[allow(clippy::panic, reason = "test assertion helper")] +fn assert_combined(error: ScopeError, witness: &Rc<()>) { + let ScopeError::OperationAndCleanup { operation, cleanup } = error else { + panic!("both errors must be preserved"); + }; + assert!(Rc::ptr_eq(&operation.0, witness)); + assert_eq!(cleanup.kind(), ErrorKind::PartialEffect); + assert!(matches!(cleanup, Error::AfterEffect { source, .. } if source.is_object_gone())); +} + +#[tokio::test] +async fn window_and_pane_scopes_preserve_both_errors() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard.session("scopes").await.expect("session"); + let witness = Rc::new(()); + let error = session + .with_window( + NewWindowOptions::new("temporary").command("sleep 300"), + async |window| { + window.clone().kill().await.expect("window is killed"); + Err::<(), _>(OperationFailure(Rc::clone(&witness))) + }, + ) + .await + .expect_err("operation and cleanup fail"); + assert_combined(error, &witness); + + let window = session + .active_window() + .await + .expect("window lookup") + .expect("window"); + let error = window + .with_pane( + SplitOptions::new(SplitDirection::Below).command("sleep 300"), + async |pane| { + pane.clone().kill().await.expect("pane is killed"); + Err::<(), _>(OperationFailure(Rc::clone(&witness))) + }, + ) + .await + .expect_err("operation and cleanup fail"); + assert_combined(error, &witness); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn creation_failure_does_not_run_the_operation_or_adopt_an_existing_session() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let original = guard.session("existing").await.expect("session"); + let called = std::cell::Cell::new(false); + let error = guard + .server() + .with_session("existing", async |_session| { + called.set(true); + Ok::<(), OperationFailure>(()) + }) + .await + .expect_err("the existing session cannot be created twice"); + assert!(matches!(error, ScopeError::Creation(_))); + assert!(!called.get()); + assert_eq!( + guard.server().sessions().await.expect("sessions"), + [original] + ); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn cleanup_failure_after_success_is_a_partial_effect() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let error = guard + .server() + .with_session("cleanup", async |session| { + session.clone().kill().await.expect("session is killed"); + Ok::<(), OperationFailure>(()) + }) + .await + .expect_err("cleanup fails"); + assert!(matches!( + error, + ScopeError::Cleanup(Error::AfterEffect { .. }) + )); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn combined_errors_keep_typed_sources_and_redact_operation_details() { + use std::error::Error as _; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let error = guard + .server() + .with_session("redaction", async |session| { + session.clone().kill().await.expect("session is killed"); + Err::<(), _>(std::io::Error::other("operation-secret")) + }) + .await + .expect_err("operation and cleanup fail"); + guard.shutdown().await.expect("tmux fixture shuts down"); + assert!(!format!("{error:?} {error}").contains("operation-secret")); + let cleanup_source = error.source().expect("cleanup source"); + assert!(cleanup_source.is::()); + let ScopeError::OperationAndCleanup { operation, cleanup } = &error else { + panic!("combined error"); + }; + assert_eq!(operation.to_string(), "operation-secret"); + assert!(std::ptr::eq( + cleanup_source + .downcast_ref::() + .expect("typed cleanup"), + cleanup + )); + + let error = ScopeError::Operation(std::io::Error::other("operation-secret")); + assert!(!format!("{error:?} {error}").contains("operation-secret")); + assert!(error.source().is_none()); + assert!( + matches!(error, ScopeError::Operation(operation) if operation.to_string() == "operation-secret") + ); +} From 2dbf17fe539754ce4065e23f72ddbcde4ce08f8f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:11:42 -0500 Subject: [PATCH 004/117] Control(fix): Yield terminal stream errors why: A fallible connection must report its terminal diagnostic during ordinary iteration, without requiring a separate shutdown to discover it. poll_next and shutdown also mapped every join failure to control_mode_closed, including a panic in the connection task; nothing else aborts that task, so a join failure can only be a panic, and a supervisor that reconnects on control_mode_closed would retry into the same panic with no trace of it reaching a log. what: - Yield Result events and consume one terminal error after buffered events - Wait for cleanup before EOF and retain explicit early shutdown - Distinguish tmux's normal exit from an unexpected EOF - Resume a connection task's panic in the caller's task in both poll_next and shutdown, matching the existing pattern in internal/scoped.rs for the same join-failure-is-always-a-panic case - Preserve cancellation and document forced-shutdown delivery limits - Exercise real consumers and prove terminal-error suppression fails - Publish compiled migration examples and include them in packages --- crates/libtmux/Cargo.toml | 1 + crates/libtmux/README.md | 4 +- crates/libtmux/docs/design.md | 5 +- crates/libtmux/docs/migration.md | 93 ++++++++++++++ crates/libtmux/docs/public-api.txt | 4 +- crates/libtmux/examples/watch.rs | 6 +- crates/libtmux/src/control.rs | 113 ++++++++++++------ crates/libtmux/src/control/actor.rs | 5 +- crates/libtmux/src/control/lifecycle_tests.rs | 34 ++++-- crates/libtmux/src/control/tests.rs | 39 +++++- crates/libtmux/src/lib.rs | 4 + crates/libtmux/tests/control.rs | 52 +++++++- 12 files changed, 298 insertions(+), 62 deletions(-) create mode 100644 crates/libtmux/docs/migration.md diff --git a/crates/libtmux/Cargo.toml b/crates/libtmux/Cargo.toml index 86b14b33..6c9f4f4b 100644 --- a/crates/libtmux/Cargo.toml +++ b/crates/libtmux/Cargo.toml @@ -17,6 +17,7 @@ include = [ "/LICENSE-MIT", "/README.md", "/docs/design.md", + "/docs/migration.md", "/docs/parity.md", "/benches/**/*.rs", "/examples/**/*.rs", diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index caaf3ae5..31878daa 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -18,6 +18,8 @@ and panes.** > requirement does not pick this up: depend on the exact version below, and > expect to edit it. +See the [migration notes](docs/migration.md) when upgrading from alpha.11. + ```rust use libtmux::test::TestServer; @@ -229,7 +231,7 @@ async fn main() -> Result<(), Box> { // subscription reads the value as well as watching it. let mut reports = 0; while let Some(event) = events.next_event().await { - if let Event::SubscriptionChanged { name, value, .. } = event { + if let Event::SubscriptionChanged { name, value, .. } = event? { println!("{} = {}", name.to_string_lossy(), value.to_string_lossy()); reports += 1; if reports == 1 { diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 272f1882..45549dbf 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -758,7 +758,10 @@ protocol evidence supports that attribution. The `control-mode` feature opens one tmux connection and keeps it. A task owns the pipes; callers hold a `ControlSender` and a `ControlEvents`, which is a -`Stream`. +`Stream>`. Buffered notifications precede one +terminal error. A clean `%exit` is an event; EOF without it is a connection +error. Exhaustion waits for cleanup. Explicit `shutdown` closes early and +returns any terminal error that iteration has not already delivered. That task is an actor, and this document previously argued against one on the grounds that a caller-driven connection buffers and drops nothing out of sight. diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md new file mode 100644 index 00000000..1c01f60a --- /dev/null +++ b/crates/libtmux/docs/migration.md @@ -0,0 +1,93 @@ +# Migrating from 0.1.0-alpha.11 + +## Control events + +`ControlEvents` yields `Result`. `ControlEvents::next_event` +and `ControlMode::next_event` return `Option>`. Change +`match event` to `match event?` in a fallible consumer: + +```no_run +# async fn watch(mut events: libtmux::control::ControlEvents) -> Result<(), libtmux::Error> { +use libtmux::control::Event; + +while let Some(event) = events.next_event().await { + match event? { + Event::Output { pane, bytes } => println!("{pane}: {} bytes", bytes.len()), + Event::Exit { .. } => break, + _ => {} + } +} +events.shutdown().await?; +# Ok(()) +# } +``` + +A terminal failure arrives once, after buffered notifications. Later polls +return `None`. Normal `%exit` produces `Event::Exit`; EOF without it produces +`ControlModeErrorKind::Closed`. Exhaustion waits for cleanup. Explicit +`shutdown` discards unread notifications, closes the connection and returns +any terminal error not already delivered by iteration. After an error has +been delivered, `shutdown` succeeds. `Server::shutdown` may discard pending +notifications so an unread stream cannot block executor shutdown. + +`PaneOutput` keeps its infallible byte-stream contract. Its `None` combines +normal completion and failure; call `shutdown` to observe a connection error. + +## Scoped errors + +`with_session`, `with_window` and `with_pane` return `Result>` +instead of `Result`. Remove `E: From` from caller error +types when it was needed only for these helpers. Functions propagating a +scope's result can return `ScopeError` or wrap it in their application +error. Nested scopes retain nested error types. + +Match `ScopeError::Creation`, `Operation`, `Cleanup` or +`OperationAndCleanup`. The combined variant retains both original values: + +``` +use libtmux::{Error, ScopeError}; + +fn both(error: &ScopeError) -> Option<(&E, &Error)> { + match error { + ScopeError::OperationAndCleanup { operation, cleanup } => Some((operation, cleanup)), + _ => None, + } +} + +let error = ScopeError::Operation("application error"); +assert!(both(&error).is_none()); +``` + +Cleanup errors carry `Error::AfterEffect` because resource creation succeeded. +`Debug` and `Display` redact generic operation values. The standard error +source is the creation or cleanup `Error`; inspect the operation variant to +access the generic operation value or its own source chain. This keeps +`ScopeError` usable with boxed errors and values that do not implement +`std::error::Error`. + +## Owned queries and names + +Borrowed `.iter().matching(...)` calls retain their result and inference +behaviour. Use `.into_iter().matching_owned(...)` to move selected items +without cloning. `exactly_one` and `one_or_none` return the iterator's item +type, including owned values: + +``` +use libtmux::query::QueryIteratorExt; + +let names = vec![String::from("build"), String::from("test")]; +let selected = names.into_iter() + .matching_owned(|name: &String| name.starts_with('b')) + .exactly_one()?; +assert_eq!(selected, "build"); +# Ok::<(), libtmux::query::ExactlyOneError>(()) +``` + +Replace generic bounds such as `I: QueryIteratorExt<'a, T>` with +`I: Iterator + QueryIteratorExt`. The trait no longer has type +or lifetime parameters. Ordinary `Iterator::filter` remains available for +closures with inferred arguments. + +`TmuxText` implements `AsRef<[u8]>`. Use `server.session(session.name())` +and `session.window(window.name())` directly. The conversion borrows the +original bytes and never decodes them lossily. diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 3bdbd781..75d9d695 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -721,11 +721,11 @@ function libtmux::control::BlockResult::number: const fn(&self) -> u64 function libtmux::control::BlockResult::output: fn(&self) -> &[libtmux::TmuxText] function libtmux::control::BlockResult::refusal_for: fn(&self, operation: &'static str) -> Option function libtmux::control::BlockResult::succeeded: const fn(&self) -> bool -function libtmux::control::ControlEvents::next_event: async fn(&mut self) -> Option +function libtmux::control::ControlEvents::next_event: async fn(&mut self) -> Option> function libtmux::control::ControlEvents::shutdown: async fn(self) -> Result<(), libtmux::Error> function libtmux::control::ControlMode::attach: async fn(server: &libtmux::Server, session: &libtmux::SessionId) -> Result function libtmux::control::ControlMode::attach_with_limits: async fn(server: &libtmux::Server, session: &libtmux::SessionId, limits: libtmux::ControlLimits) -> Result -function libtmux::control::ControlMode::next_event: async fn(&mut self) -> Option +function libtmux::control::ControlMode::next_event: async fn(&mut self) -> Option> function libtmux::control::ControlMode::reply_timeout: fn(self, timeout: Duration) -> Self function libtmux::control::ControlMode::send: async fn(&self, command: libtmux::Command) -> Result function libtmux::control::ControlMode::shutdown: async fn(self) -> Result<(), libtmux::Error> diff --git a/crates/libtmux/examples/watch.rs b/crates/libtmux/examples/watch.rs index 79b40bd2..d9c71ee9 100644 --- a/crates/libtmux/examples/watch.rs +++ b/crates/libtmux/examples/watch.rs @@ -36,7 +36,7 @@ async fn main() -> Result<(), Box> { let watcher = tokio::spawn(async move { let mut seen = Vec::new(); while let Some(event) = events.next_event().await { - match event { + match event? { Event::WindowAdded { window } => { println!(" <- window {window} appeared"); seen.push(window.to_string()); @@ -54,7 +54,7 @@ async fn main() -> Result<(), Box> { break; } } - (seen, events) + Ok::<_, libtmux::Error>((seen, events)) }); // Meanwhile, drive the server down the same connection. These spawn no @@ -73,7 +73,7 @@ async fn main() -> Result<(), Box> { ) .await?; - let (seen, events) = tokio::time::timeout(Duration::from_secs(10), watcher).await??; + let (seen, events) = tokio::time::timeout(Duration::from_secs(10), watcher).await???; println!( "{} events arrived while those commands were being sent, on the same socket", seen.len() diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index b6fa15c8..7cb811ba 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -23,14 +23,14 @@ //! // is waiting on the connection it stopped reading. //! let watcher = tokio::spawn(async move { //! while let Some(event) = events.next_event().await { -//! match event { +//! match event? { //! Event::Output { pane, bytes } => println!("{pane}: {} bytes", bytes.len()), //! Event::Exit { .. } => break, //! other => println!("{other:?}"), //! } //! } //! -//! // The stream ending says the connection is over; this says why. +//! // Explicit shutdown also closes a connection before stream exhaustion. //! events.shutdown().await //! }); //! @@ -46,6 +46,7 @@ //! `examples/watch.rs` is this as a program that runs, against a server it //! starts and cleans up. +use std::future::{Future as _, poll_fn}; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; @@ -492,7 +493,7 @@ impl ControlMode { events: ControlEvents { events, stop, - connection, + connection: Some(connection), }, }) } @@ -528,8 +529,10 @@ impl ControlMode { self.sender.send(command).await } - /// Return the next notification, or `None` once the connection closes. - pub async fn next_event(&mut self) -> Option { + /// Return the next notification or terminal error, then `None`. + /// + /// See [`ControlEvents::next_event`] for termination and cancellation. + pub async fn next_event(&mut self) -> Option> { self.events.next_event().await } @@ -537,7 +540,8 @@ impl ControlMode { /// /// # Errors /// - /// Returns an error when the connection failed before it was closed. + /// Returns a connection error that was not already delivered by + /// [`Self::next_event`]. pub async fn shutdown(self) -> Result<(), Error> { drop(self.sender); self.events.shutdown().await @@ -831,7 +835,7 @@ impl ControlSender { /// // The first report arrives without anything having changed, which is /// // what makes a subscription usable for reading the value as well. /// while let Some(event) = events.next_event().await { - /// if let Event::SubscriptionChanged { name, value, .. } = event { + /// if let Event::SubscriptionChanged { name, value, .. } = event? { /// assert_eq!(name.as_str()?, "title"); /// assert_eq!(value.as_str()?, "watched"); /// break; @@ -992,21 +996,30 @@ enum Delivery { Boundary(Boundary), } -/// Receives what tmux reports without being asked. +/// Receives tmux notifications and terminal connection errors. +/// +/// This is a [`Stream>`](Stream). Notifications +/// arrive in order. A connection failure follows all buffered notifications +/// as one `Err`; subsequent polls return `None`. A normal tmux `%exit` arrives +/// as [`Event::Exit`] and is followed by `None` after cleanup succeeds. EOF +/// without `%exit` is [`crate::ControlModeErrorKind::Closed`]. +/// [`Server::shutdown`] may discard notifications still waiting for delivery +/// so an unread stream cannot prevent executor shutdown. /// -/// This is a [`Stream`], so it composes with `select!`, timeouts, and the rest -/// of the async ecosystem rather than demanding a loop of its own. +/// Exhaustion waits for connection cleanup. [`Self::shutdown`] closes early +/// and returns any terminal error the stream has not already delivered. /// /// Events are buffered, and a consumer that stops reading eventually stops the /// connection reading from tmux, which is the backpressure tmux already -/// expects from a slow client. Nothing is dropped; commands wait instead. Drop -/// this handle to opt out of events entirely and the connection runs on. +/// expects from a slow client. During normal operation nothing is dropped; +/// commands wait instead. Drop this handle to opt out of events entirely and +/// the connection runs on. #[derive(Debug)] pub struct ControlEvents { events: mpsc::Receiver, - /// Ends the connection when this handle asks, or when it is dropped. + /// Requests closure; dropping it leaves remaining senders working. stop: watch::Sender<()>, - connection: tokio::task::JoinHandle>, + connection: Option>>, } impl ControlEvents { @@ -1014,27 +1027,33 @@ impl ControlEvents { self.events.recv().await } - /// Return the next notification, or `None` once the connection closes. - pub async fn next_event(&mut self) -> Option { - loop { - match self.next_delivery().await? { - Delivery::Event(event) => return Some(event), - Delivery::Boundary(_) => {} - } - } + /// Return the next notification or terminal error, then `None`. + /// + /// Cancelling a pending call consumes neither an event nor a terminal + /// diagnostic. Once `None` is returned, subsequent calls also return + /// `None`. See [`ControlEvents`] for the EOF and cleanup contract. + /// + /// # Errors + /// + /// Yields one terminal error for transport failure, unexpected EOF, a + /// frame budget or command deadline being exceeded, executor shutdown, + /// or failed connection cleanup. A panic in the connection task resumes + /// here instead, since nothing else would report it. + pub async fn next_event(&mut self) -> Option> { + poll_fn(|context| Pin::new(&mut *self).poll_next(context)).await } /// End the connection and report how it went. /// - /// The stream running out says only that the connection is over. This says - /// why, which is the difference between a session that ended and a pipe - /// that broke. It ends the connection outright rather than waiting for the - /// senders, so it is the same call whether the connection is still healthy - /// or tmux hung up an hour ago. + /// Stops the connection even while command senders remain alive. Unread + /// notifications are discarded so cleanup cannot wait on a full buffer. + /// If the stream already delivered its terminal error, this succeeds; + /// that error is not delivered twice. /// /// # Errors /// - /// Returns an error when the connection failed before it was closed. + /// Returns a connection or cleanup error not already delivered by the + /// stream. Caller-requested closure succeeds when cleanup succeeds. pub async fn shutdown(mut self) -> Result<(), Error> { let _ = self.stop.send(()); // Draining releases a connection that is parked handing over an event, @@ -1043,23 +1062,44 @@ impl ControlEvents { self.events.close(); while self.events.recv().await.is_some() {} - self.connection - .await - .map_err(|_| Error::control_mode_closed())? + match self.connection.take() { + Some(connection) => match connection.await { + Ok(outcome) => outcome, + // Nothing aborts this task, so a join failure is a panic in + // the connection, not a cancellation. Resuming it here, in + // the caller's own task, keeps the panic visible instead of + // reporting the actor's crash as an ordinary closed + // connection a supervisor would retry into a repeat panic. + Err(error) => std::panic::resume_unwind(error.into_panic()), + }, + None => Ok(()), + } } } impl Stream for ControlEvents { - type Item = Event; + type Item = Result; - fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { loop { match std::task::ready!(self.events.poll_recv(context)) { - Some(Delivery::Event(event)) => return Poll::Ready(Some(event)), + Some(Delivery::Event(event)) => return Poll::Ready(Some(Ok(event))), Some(Delivery::Boundary(_)) => {} - None => return Poll::Ready(None), + None => break, } } + let Some(connection) = self.connection.as_mut() else { + return Poll::Ready(None); + }; + let outcome = std::task::ready!(Pin::new(connection).poll(context)); + self.connection = None; + Poll::Ready(match outcome { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(Err(error)), + // As in `Self::shutdown`: a join failure here can only be a + // panic, so it resumes rather than reading as an ordinary close. + Err(error) => std::panic::resume_unwind(error.into_panic()), + }) } } @@ -1071,6 +1111,9 @@ const NARROW_DIRTY: u8 = 2; /// /// Built by [`crate::Pane::stream_output`]. This is a [`Stream`] of the bytes /// that pane produced, in order. +/// Its infallible items combine normal termination and connection failure +/// into `None`. Call [`Self::shutdown`] to observe any connection error, or +/// use [`ControlEvents`] to receive errors during iteration. /// /// tmux is told to send this connection nothing but the watched pane. A /// neighbouring pane running `yes` otherwise moves tens of megabytes a second diff --git a/crates/libtmux/src/control/actor.rs b/crates/libtmux/src/control/actor.rs index db2e7e27..475ed69e 100644 --- a/crates/libtmux/src/control/actor.rs +++ b/crates/libtmux/src/control/actor.rs @@ -422,9 +422,8 @@ impl Connection { match step { Step::Read(Err(error)) => return Err(error), - // tmux hung up, or the watcher asked to stop. Either ends the - // connection whatever the other half is doing. - Step::Read(Ok(None)) => return Ok(()), + // A clean remote close is announced by %exit before EOF. + Step::Read(Ok(None)) => return Err(Error::control_mode_closed()), Step::Unwatched { asked: true } => { return Ok(()); } diff --git a/crates/libtmux/src/control/lifecycle_tests.rs b/crates/libtmux/src/control/lifecycle_tests.rs index 07c26266..6ad91e5c 100644 --- a/crates/libtmux/src/control/lifecycle_tests.rs +++ b/crates/libtmux/src/control/lifecycle_tests.rs @@ -227,7 +227,7 @@ async fn attach_uses_the_cores_captured_launch_context() { let executable = write_script( fixture.path(), &format!( - "{{\n pwd\n printf '%s\\n' \"$PATH\"\n for argument in \"$@\"; do printf '<%s>\\n' \"$argument\"; done\n}} > {}\n{}", + "{{\n pwd\n printf '%s\\n' \"$PATH\"\n for argument in \"$@\"; do printf '<%s>\\n' \"$argument\"; done\n}} > {}\n{}\nprintf '%%exit done\\n'", shell_quote(&record), opening_success(), ), @@ -714,11 +714,19 @@ async fn terminal_notifications_drain_after_exit_and_eof() { let mut sessions_changed = 0; let mut exits = 0; + let mut errors = 0; tokio::time::timeout(TEST_TIMEOUT, async { while let Some(event) = events.next_event().await { match event { - Event::SessionsChanged => sessions_changed += 1, - Event::Exit { .. } => exits += 1, + Ok(Event::SessionsChanged) => sessions_changed += 1, + Ok(Event::Exit { .. }) => exits += 1, + Err(Error::ControlMode { + kind: ControlModeErrorKind::Closed, + .. + }) => { + assert_eq!(sessions_changed, EVENT_QUEUE + 1); + errors += 1; + } other => panic!("unexpected terminal fixture event: {other:?}"), } } @@ -728,6 +736,8 @@ async fn terminal_notifications_drain_after_exit_and_eof() { assert_eq!(sessions_changed, EVENT_QUEUE + 1); assert_eq!(exits, expected_exits); + assert_eq!(errors, usize::from(expected_exits == 0)); + assert!(events.next_event().await.is_none()); events.shutdown().await.expect("connection shuts down"); server.shutdown().await.expect("server shuts down"); } @@ -763,9 +773,14 @@ async fn terminal_notifications_drain_after_eof_inside_a_reply() { )); let mut sessions_changed = 0; + let mut terminal_error = None; while let Some(event) = events.next_event().await { match event { - Event::SessionsChanged => sessions_changed += 1, + Ok(Event::SessionsChanged) => sessions_changed += 1, + Err(error) => { + assert!(terminal_error.replace(error).is_none()); + assert_eq!(sessions_changed, EVENT_QUEUE + 1); + } other => panic!("unexpected terminal fixture event: {other:?}"), } } @@ -775,10 +790,7 @@ async fn terminal_notifications_drain_after_eof_inside_a_reply() { "a malformed final reply must not discard already parsed notifications" ); - let error = events - .shutdown() - .await - .expect_err("the incomplete reply remains the terminal cause"); + let error = terminal_error.expect("the incomplete reply remains the terminal cause"); assert!(matches!( error, Error::ControlMode { @@ -786,6 +798,10 @@ async fn terminal_notifications_drain_after_eof_inside_a_reply() { .. } )); + events + .shutdown() + .await + .expect("the terminal error was delivered"); server.shutdown().await.expect("server shuts down"); } @@ -848,7 +864,7 @@ async fn pane_snapshot_separates_output_at_the_capture_block() { let executable = write_script( fixture.path(), &format!( - "{}\nIFS= read -r _command\nprintf '%%output %%1 before\\n'\nprintf '%%begin 0 2 0\\nvisible\\n%%end 0 2 0\\n'\nprintf '%%output %%1 after\\n'", + "{}\nIFS= read -r _command\nprintf '%%output %%1 before\\n'\nprintf '%%begin 0 2 0\\nvisible\\n%%end 0 2 0\\n'\nprintf '%%output %%1 after\\n'\nprintf '%%exit done\\n'", opening_success(), ), ); diff --git a/crates/libtmux/src/control/tests.rs b/crates/libtmux/src/control/tests.rs index bdebd778..fc3384f0 100644 --- a/crates/libtmux/src/control/tests.rs +++ b/crates/libtmux/src/control/tests.rs @@ -24,6 +24,37 @@ fn reply(number: u64) -> BlockResult { } } +#[tokio::test] +async fn cancelling_a_pending_next_preserves_the_terminal_error() { + let (deliveries, received) = mpsc::channel(1); + let (stop, _stopped) = watch::channel(()); + let (release, released) = oneshot::channel(); + let connection = tokio::spawn(async move { + released.await.expect("cleanup is released"); + Err(Error::control_mode_timeout()) + }); + let mut events = ControlEvents { + events: received, + stop, + connection: Some(connection), + }; + drop(deliveries); + tokio::select! { + biased; + _ = events.next_event() => panic!("EOF must wait for connection cleanup"), + () = std::future::ready(()) => {} + } + release.send(()).expect("cleanup is waiting"); + let error = events + .next_event() + .await + .expect("terminal diagnostic") + .expect_err("timeout"); + assert_eq!(error.kind(), ErrorKind::Timeout); + assert!(events.next_event().await.is_none()); + events.shutdown().await.expect("error already delivered"); +} + fn request() -> (Request, oneshot::Receiver>) { let (result, answer) = oneshot::channel(); let (commit, _commitment) = oneshot::channel(); @@ -179,7 +210,7 @@ async fn dirty_narrowing_reruns_after_an_in_flight_failure() { ControlEvents { events: received, stop, - connection, + connection: Some(connection), }, sender, ); @@ -216,7 +247,7 @@ async fn cancelling_a_snapshot_leaves_consumed_output_in_the_callers_sink() { ControlEvents { events: received, stop, - connection, + connection: Some(connection), }, sender, ); @@ -292,7 +323,7 @@ async fn a_snapshot_streams_a_flood_into_caller_owned_storage() { ControlEvents { events: received, stop, - connection, + connection: Some(connection), }, sender, ); @@ -365,7 +396,7 @@ async fn a_snapshot_rejected_before_writing_does_not_wait_for_a_boundary() { ControlEvents { events: received, stop, - connection, + connection: Some(connection), }, sender, ); diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 6cfacd58..515759fe 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -410,3 +410,7 @@ pub use libtmux_macros::Filterable; #[cfg(doctest)] #[doc = include_str!("../../../README.md")] pub struct WorkspaceReadme; + +#[cfg(all(doctest, feature = "query", feature = "control-mode"))] +#[doc = include_str!("../docs/migration.md")] +pub struct MigrationGuide; diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index dd663e54..b2b1c491 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -34,6 +34,7 @@ fn is_a_new_window(event: &Event) -> bool { /// Control mode reports plenty that a given test did not ask about, and the /// exact set differs between tmux releases, so a test names what it wants /// rather than asserting on the next event to arrive. +#[allow(clippy::panic, reason = "test assertion helper")] async fn wait_for( events: &mut ControlEvents, mut wanted: impl FnMut(&Event) -> bool, @@ -41,8 +42,9 @@ async fn wait_for( 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(Some(Ok(event))) if wanted(&event) => return Some(event), + Ok(Some(Ok(_))) => {} + Ok(Some(Err(error))) => panic!("the watched connection failed: {error:?}"), Ok(None) | Err(_) => return None, } } @@ -81,6 +83,39 @@ async fn commands_travel_down_one_connection() { guard.shutdown().await.expect("tmux fixture shuts down"); } +#[tokio::test] +async fn stream_reports_server_shutdown_once_before_eof() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard.session("terminal-error").await.expect("session"); + let (commands, mut events) = ControlMode::attach(guard.server(), session.id()) + .await + .expect("control mode attaches") + .split(); + guard.server().shutdown().await.expect("client shuts down"); + let error = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let item: Result = events + .next() + .await + .expect("shutdown must be reported before EOF"); + if let Err(error) = item { + break error; + } + } + }) + .await + .expect("the terminal diagnostic arrives"); + assert!(matches!(error, libtmux::Error::ExecutorShutdown { .. })); + assert!(commands.is_closed()); + assert!(events.next_event().await.is_none()); + assert!(events.next().await.is_none()); + events + .shutdown() + .await + .expect("the error was already delivered"); + 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"); @@ -226,9 +261,15 @@ async fn events_compose_with_the_async_ecosystem() { // Events are a Stream, so the ecosystem's combinators apply and no loop // of this crate's own design is required. The pin is tokio's timer's // requirement, not this crate's: ControlEvents is Unpin on its own. + // + // A per-item timeout is a slow tick to tolerate; a connection error is + // what this test exercises, so it still fails. let named = events .timeout(Duration::from_secs(10)) - .filter_map(Result::ok) + .filter_map(|event| match event { + Ok(event) => Some(event.expect("healthy stream")), + Err(_elapsed) => None, + }) .filter(is_a_new_window); let mut named = std::pin::pin!(named); @@ -660,7 +701,7 @@ async fn a_muted_pane_never_reaches_this_connection() { let Ok(Some(event)) = tokio::time::timeout_at(deadline, events.next_event()).await else { break; }; - if let Event::Output { pane, bytes } = &event { + if let Event::Output { pane, bytes } = &event.expect("healthy stream") { assert_ne!( pane, noisy.id(), @@ -788,6 +829,7 @@ async fn a_block_carrying_pane_ids_keeps_them_as_output() { // The rows must not have been reported as notifications instead. let stray = tokio::time::timeout(Duration::from_millis(200), events.next_event()).await; if let Ok(Some(event)) = stray { + let event = event.expect("healthy stream"); assert!( !matches!(&event, Event::Other { name, .. } if name.chars().all(char::is_numeric)), "a pane id was reported as a notification: {event:?}", @@ -1150,12 +1192,14 @@ async fn real_tmux_compat_muting_a_producing_pane_leaves_the_server_up() { /// /// tmux coalesces reports to at most once a second, so a caller watching for /// one is waiting on that interval rather than on the change itself. +#[allow(clippy::expect_used, reason = "test assertion helper")] async fn next_report(events: &mut ControlEvents, name: &str, within: Duration) -> Option { let deadline = tokio::time::Instant::now() + within; loop { let event = tokio::time::timeout_at(deadline, events.next_event()) .await .ok()??; + let event = event.expect("subscription connection remains healthy"); // Two ifs rather than a let-chain: this crate's floor is 1.85, and // let-chains landed in 1.88. if let Event::SubscriptionChanged { From 848352bdbec8a69888c56d71892a1da8ccacded2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 12 Sep 2026 20:19:43 -0500 Subject: [PATCH 005/117] Query(docs): Describe owned iterator composition why: The design and parity notes still limited queries to borrowed items. what: - Describe borrowed and owned matching and cardinality. - Align the iterator plan signatures with the public trait. --- crates/libtmux/docs/design.md | 15 +++++---- crates/libtmux/docs/parity.md | 6 ++-- .../plans/02-formats-snapshots-filtering.md | 32 +++++++++++-------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 45549dbf..9668cee0 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -438,16 +438,17 @@ let first = pending.clone().next(); let collected = pending.collect::>(); ``` -`QueryIteratorExt` is implemented only for iterators whose item is `&T`. -`matching()` is lazy and preserves order; `vec.iter().matching(expr)` works, -while `vec.into_iter().matching(expr)` intentionally does not. `Matcher` -has a blanket implementation for `Fn(&T) -> bool`, but inline closures use -native `.filter()` because the blanket bound cannot infer an untyped closure -parameter on the MSRV. +`QueryIteratorExt` is implemented for all iterators. `matching()` filters +borrowed items, while `matching_owned()` borrows each item for the predicate +and yields the original owned item without requiring `Clone`. Both methods +are lazy and preserve order. `Matcher` has a blanket implementation for +`Fn(&T) -> bool`, but inline closures use native `.filter()` because the +blanket bound cannot infer an untyped closure parameter on the MSRV. `exactly_one()` inspects at most two items and returns `ExactlyOneError` with distinct zero and multiple variants. `one_or_none()` returns `None`, one -borrowed item, or `MultipleItemsError`. Neither method counts, collects, or +item, or `MultipleItemsError`. Both methods return the iterator's item type, +whether borrowed or owned. Neither method counts, collects, or exhausts a potentially infinite iterator. Importing both this extension trait and `itertools::Itertools` makes the shared `exactly_one` method name ambiguous; callers in that uncommon case use trait-qualified syntax. diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 4f166671..c6f5c4f4 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -115,7 +115,7 @@ Source: `src/libtmux/exc.py`. Query behavior is also exercised in | No direct Python equivalent | Raw execution has no built-in deadline, shutdown registry, duplicate-request guard, or typed supervisor failure. | `Error::Timeout`, `Error::ExecutorShutdown`, `Error::DuplicateRequest`, and `Error::SupervisorLost`; covered by [`src/internal/subprocess.rs`](../src/internal/subprocess.rs). | Foundation | `implemented` | | `TmuxSessionExists` | Requested Session name collides with a live Session. | `Error::SessionExists`, classified from tmux's refusal rather than checked beforehand: a name can be taken between a check and a create, so tmux refusing is the only answer that cannot be stale. Reachable from a plan step too, through `StepOutcome::refusal`. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | `NotInsideTmux` | Optional environment-variable and reason context. | `ServerConfigurationErrorKind::NotInsideTmux` for an absent `TMUX`, and `MalformedTmuxVariable` for one that is present and not tmux's triple. Split because they are different situations: the first is an ordinary state a caller branches on, the second is a rewritten environment. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `ObjectDoesNotExist`, `MultipleObjectsReturned` | Zero and multiple local matches. The first exposes `query`; the second exposes `count` and `query`. A default suppresses only the zero-match case. | Borrowed `QueryIteratorExt::exactly_one` and `one_or_none` use source-less `ExactlyOneError` and `MultipleItemsError`, retain no query, and inspect at most two items; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | +| `ObjectDoesNotExist`, `MultipleObjectsReturned` | Zero and multiple local matches. The first exposes `query`; the second exposes `count` and `query`. A default suppresses only the zero-match case. | `QueryIteratorExt::exactly_one` and `one_or_none` for borrowed and owned items use source-less `ExactlyOneError` and `MultipleItemsError`, retain no query, and inspect at most two items; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | `TmuxObjectDoesNotExist` | A live target was absent from tmux output. | `Error::ObjectGone { kind, id }`, naming which object and which identity rather than the fact alone. Classified from tmux's own "can't find …" wording, pinned against every supported release. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `VersionTooLow` during connection | tmux is below the library floor. | `Error::UnsupportedTmuxVersion`; construction and diagnostics are covered by [`tests/version.rs`](../tests/version.rs). | Foundation | `implemented` | | `VersionTooLow` for an optional operation | A requested operation or flag is unavailable on the detected tmux. | `Error::UnsupportedCapability`, checked before dispatch. Verified refusing on tmux 3.2a and accepting on 3.3a and 3.7b; covered by [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | @@ -459,7 +459,7 @@ path, but public APIs expose their behavior. | `QueryList.get` | Exactly one item. Zero raises `ObjectDoesNotExist` unless a default was supplied; multiple always raises `MultipleObjectsReturned`. | `query::QueryIteratorExt::exactly_one` and `query::QueryIteratorExt::one_or_none` inspect at most two items and return source-less errors; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | `QueryList.items` | Intended primary-key pairs, but normal returned lists never initialize `pk_key`, so access raises `AttributeError`. | Omit the broken accessor; future object values use typed IDs and ordinary iteration. | Documentation, compatibility, and parity closure | `excluded` | | `QuerySet`-equivalent native search behavior | Python `search_*` methods send raw tmux `-f`; malformed expressions can look empty. | `FilterExpr` is the local reference value. Future loud `server.query_*` methods may add validated native `-f` pushdown, residual evaluation, ordering, and limits. | Discovery, traversal, refresh, and environment resolution | `planned` | -| `Matcher` and borrowed `QueryIteratorExt` | Public accessors expose ordered local collection behavior through `QueryList`. | `Matcher`, borrowed-only `QueryIteratorExt`, lazy ordered `matching`, and source-less exact cardinality are implemented over ordinary collections; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | +| `Matcher` and `QueryIteratorExt` | Public accessors expose ordered local collection behavior through `QueryList`. | `Matcher`, borrowed and owned `QueryIteratorExt`, lazy ordered `matching` and `matching_owned`, and source-less exact cardinality are implemented over ordinary collections; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | Typed scalar `FilterExpr` | `QueryList.filter` accepts dynamic values and suffix strings, with Python `re` and lowercase-based comparisons. | `query::FilterExpr` and typed field handles build validated expressions; `query::TextField::not_in` still validates candidate UTF-8 before an empty-set result; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | Portable relation expressions | Nested attribute lookup reads values already present on each Python object. | `ManyRelation::{any, all, none}` and `OneRelation::is` inspect only explicitly hydrated candidate data and perform no I/O; their empty and absent truth tables are covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | Optional downstream `Filterable` derive | Python discovers dynamic fields from object attributes. | `query::Filterable` is the derive target for stable typed scalar and relation handles; covered by [`tests/filter_derive.rs`](../tests/filter_derive.rs) and proc-macro UI tests. | Formats, snapshots, winlinks, and queries | `verified` | @@ -723,7 +723,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | A selector-free raw command reads the live process `TMUX` value, and tmux splits it at the first comma. | `Server` captures the effective endpoint once, right-splits inherited `TMUX`, and dispatches that captured path explicitly; covered by private tests in [`src/target.rs`](../src/target.rs) and [`src/internal/core.rs`](../src/internal/core.rs). | Foundation | `implemented` | | Synchronous hierarchy API. | `Server::sessions`, `Session::windows`, and `Window::panes` perform live I/O asynchronously; snapshot getters remain synchronous. | Discovery, traversal, refresh, and environment resolution | `implemented` | | Exceptions and stringly typed command targets. | Foundation `Error`, validated IDs, and scope-specific command targets; covered by [`tests/version.rs`](../tests/version.rs) and [`tests/target.rs`](../tests/target.rs). | Foundation | `implemented` | -| Dynamic local query cardinality exceptions. | Borrowed `QueryIteratorExt::exactly_one` and `one_or_none` inspect at most two items and return source-less `ExactlyOneError` or `MultipleItemsError`; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | +| Dynamic local query cardinality exceptions. | `QueryIteratorExt::exactly_one` and `one_or_none` for borrowed and owned items inspect at most two items and return source-less `ExactlyOneError` or `MultipleItemsError`; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | Overlapping mutation options. | `NewSessionOptions`, `NewWindowOptions`, and `SplitOptions` reject overlapping mutation choices through their types. | Object mutations and interactions | `implemented` | | UTF-8 text command results with replacement escapes and normalized lines. | Public `CommandResult` preserves authoritative raw bytes and makes decoding explicit; covered by [`tests/server_command.rs`](../tests/server_command.rs) and private tests in [`src/command.rs`](../src/command.rs). | Foundation | `implemented` | | Version comparison strips suffixes and invents ordering for `master` and an OpenBSD fallback. | `TmuxVersion` retains release suffix ordering and treats development identifiers as minimum-capable rather than invented releases; covered by [`tests/version.rs`](../tests/version.rs). | Foundation | `implemented` | diff --git a/crates/libtmux/docs/plans/02-formats-snapshots-filtering.md b/crates/libtmux/docs/plans/02-formats-snapshots-filtering.md index e645590f..27056bff 100644 --- a/crates/libtmux/docs/plans/02-formats-snapshots-filtering.md +++ b/crates/libtmux/docs/plans/02-formats-snapshots-filtering.md @@ -31,7 +31,8 @@ slice gives them a public consumer. - Listing results are ordered `Vec` snapshots. No collection wrapper is exported. -- `QueryIteratorExt` applies only to `Iterator`. +- `QueryIteratorExt` applies to borrowed and owned iterators. `matching()` + filters borrowed items; `matching_owned()` moves items without cloning. - `Matcher` has a zero-boxing blanket implementation for `Fn(&T) -> bool`. Documentation sends inline closures to native `.filter()` because the MSRV cannot infer an untyped closure through the blanket matcher bound. @@ -140,19 +141,24 @@ pub trait Matcher { fn matches(&self, candidate: &T) -> bool; } -pub trait QueryIteratorExt<'a, T: 'a>: - Iterator + Sized -{ - fn matching>( +pub trait QueryIteratorExt: Iterator + Sized { + fn matching<'a, T: 'a, M: Matcher>( + self, + matcher: M, + ) -> impl Iterator + where + Self: Iterator; + + fn matching_owned>( self, matcher: M, - ) -> impl Iterator; + ) -> impl Iterator; - fn exactly_one(self) -> Result<&'a T, ExactlyOneError>; + fn exactly_one(self) -> Result; fn one_or_none( self, - ) -> Result, MultipleItemsError>; + ) -> Result, MultipleItemsError>; } ``` @@ -712,7 +718,7 @@ $ cargo test \ --test query ``` -## Task 2: Implement the borrowed iterator kernel +## Task 2: Implement the iterator kernel **Files:** @@ -723,16 +729,16 @@ Implement: - `Matcher::matches(&self, &T) -> bool`; - the blanket `Fn(&T) -> bool` implementation; -- `QueryIteratorExt<'a, T>` for `Iterator + Sized`; +- `QueryIteratorExt` for every `Iterator`; - an opaque `impl Iterator` from `matching()`; +- an opaque `impl Iterator` from `matching_owned()`; - `ExactlyOneError::{NoItems, MultipleItems}`; - exhaustive, source-less `Copy + Eq + Display + Error` implementations for both cardinality errors; - at-most-two-pull cardinality algorithms. -Do not add a closure-specific matching method, an owned-iterator -implementation, a prelude, `IntoIterator` bounds, collection methods, or an -itertools dependency. +Do not add a closure-specific matching method, a prelude, `IntoIterator` +bounds, collection methods, or an itertools dependency. Every public method gets an ordinary executable doctest. Run the focused test, doctests, Rust 1.85 check, Clippy, and formatting before continuing. From 22eb2ab748feff4290ee6e2259e8fe888dc1146c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 09:27:43 -0500 Subject: [PATCH 006/117] CI(chore[cache]): Pin rust-cache to v2.9.2 by SHA why: ci.yml and release.yml floated Swatinem/rust-cache@v2, so a tag move changes what both workflows run without a diff here to review. release.yml is the part that matters: a publish should not rest on a moving reference. v2.9.2 is 6323deb1, which is what the floating @v2 already resolves to, so pinning it runs the same bundle master runs today while making the reference immutable. Nested cache cleanup returning before nested target directories finish, and emitting unhandled missing-directory errors during restore and save, is a real defect that reaches this crate: cleanProfileTarget takes the affected branch only when the profile directory is named "tests", which exists here because crates/libtmux-macros dev-depends on trybuild; inside it, cleanTargetDir(target/tests/target) is called without await, and this crate uses no kaos or macrotest, so that directory is absent and the ENOENT escapes the enclosing try as an unhandled rejection. It is not worth a supply-chain dependency on an unreviewed branch to fix now: the fix lives only on Swatinem/rust-cache PR 387, still open on the maintainer's personal fork branch, and save.ts installs a process-level uncaughtException handler that logs and returns, so the rejection surfaces as two error annotations and the save proceeds and exits zero. Reproduced against that handler shape to confirm the save continues rather than aborting. what: - Pin CI and release caches to Swatinem/rust-cache@6323deb1 (v2.9.2), not the floating @v2 tag. - Preserve compiled dependency reuse and workspace invalidation. Revisit when PR 387 lands in a release. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829324b1..230ce452 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: with: tool: just,cargo-hack,cargo-deny,uv - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: just check @@ -81,7 +81,7 @@ jobs: - uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # `--no-fail-fast` because this lane runs only on master and bills at ten # times a Linux runner. Stopping at the first failing test binary hides @@ -107,7 +107,7 @@ jobs: - uses: taiki-e/install-action@v2 with: tool: just - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: just api-check # Same toolchain and the same rustdoc JSON, so it rides along here # rather than paying for a second nightly build. @@ -136,7 +136,7 @@ jobs: - uses: taiki-e/install-action@v2 with: tool: cargo-fuzz - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Fuzz ${{ matrix.target }} env: TARGET: ${{ matrix.target }} @@ -180,6 +180,6 @@ jobs: - uses: taiki-e/install-action@v2 with: tool: just - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: just compat diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcd5622c..67a63dc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: with: tool: just,cargo-hack,cargo-deny,uv - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: just check @@ -90,7 +90,7 @@ jobs: - uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # The tag is an assertion about the tree, and an assertion is worth # checking: a tag naming a version the manifest does not carry would From f39ae5cfe14b6e99929f20edcc907427527c06a2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:47:11 -0500 Subject: [PATCH 007/117] Control(fix[actor]): Order the stop signal ahead of a pending EOF why: The serve loop's select was unbiased when EOF and Ok(()) meant the same thing, so the race never mattered. It now returns Err on EOF, so whichever branch the executor happened to poll first decided whether a caller-requested shutdown raced against a pending EOF saw Ok(()) or Err(Closed), contradicting the documented "caller-requested closure succeeds" contract. what: - Mark the select biased and check the stop and executor-shutdown signals before the read, so either one wins deterministically over a simultaneous EOF --- crates/libtmux/src/control/actor.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/libtmux/src/control/actor.rs b/crates/libtmux/src/control/actor.rs index 475ed69e..2917b2a2 100644 --- a/crates/libtmux/src/control/actor.rs +++ b/crates/libtmux/src/control/actor.rs @@ -408,15 +408,19 @@ impl Connection { let held_back = !self.awaiting.has_live() && self.pending.len() >= EVENT_QUEUE; let reply_deadline = self.awaiting.earliest_deadline(); + // Biased: the stop and executor-shutdown signals are checked + // before the read, so either wins a race against a pending EOF + // instead of the outcome depending on poll order. let step = tokio::select! { - line = read_line(&mut self.stdout, &mut self.line, self.limits.max_line_bytes), - if !held_back => Step::Read(line), - room = self.events.reserve(), if !self.pending.is_empty() => Step::Deliver(room.is_ok()), - request = self.commands.recv(), if sending => Step::Send(request), + biased; asked = self.stopped.changed(), if watching => Step::Unwatched { asked: asked.is_ok(), }, () = cancellation_requested(&mut self.core_stopped) => Step::CoreStopped, + line = read_line(&mut self.stdout, &mut self.line, self.limits.max_line_bytes), + if !held_back => Step::Read(line), + room = self.events.reserve(), if !self.pending.is_empty() => Step::Deliver(room.is_ok()), + request = self.commands.recv(), if sending => Step::Send(request), () = deadline_elapsed(reply_deadline), if self.awaiting.has_slots() => Step::TimedOut, }; From fec481a652278d5db0c6ea0c2ae5a25ba90cc12b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:53:22 -0500 Subject: [PATCH 008/117] MCP(fix[wait]): Do not discard a built view on an ordinary close why: libtmux now reports EOF without %exit as Error::ControlMode with ControlModeErrorKind::Closed rather than Ok(()), a runtime-only change this crate never opted into. wait_for_text's closing shutdown().await? turned that into a hard error, discarding the WaitView already built for a pane that simply closed (WaitOutcome::PaneClosed) or that this loop had already stopped reading after a match. what: - Tolerate only ControlModeErrorKind::Closed from the closing shutdown and still propagate every other terminal error (frame budget, timeout, executor shutdown), matching Error::ControlMode's own documented Closed idiom --- crates/tmux-mcp/src/exec.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index ada39900..ffbda261 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -9,7 +9,7 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; -use libtmux::{CaptureOptions, Error, Pane}; +use libtmux::{CaptureOptions, ControlModeErrorKind, Error, Pane}; use regex::bytes::Regex; use serde::Serialize; @@ -297,7 +297,20 @@ pub(crate) async fn wait_for_text( } let pane_id = output.pane().to_string(); - output.shutdown().await?; + // Ordinary EOF (`Closed`) is tolerated: the pane stopped being read, so + // that alone is not a failure. Any other shutdown error -- frame budget, + // timeout, executor shutdown -- is real and discards the view above. + if let Err(error) = output.shutdown().await + && !matches!( + error, + Error::ControlMode { + kind: ControlModeErrorKind::Closed, + .. + } + ) + { + return Err(error); + } Ok(WaitView { pane: pane_id, From 03606106ef67812fa39541d98759e1153d3e3ee9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 16:56:35 -0500 Subject: [PATCH 009/117] Scope(fix): Show the operation cause instead of redacting it why: Debug and Display hand-wrote unconditional impls for any E to keep ScopeError working for operation values with no trait bounds at all, and paid for that by never printing the operation value: Debug said Operation(), Display said "scoped operation failed", and source() returned None even from OperationAndCleanup's own operation field. A caller propagating ScopeError with `?` into Box, as both examples do, lost the actual cause from stdout and from the error chain -- the opposite of what a PR titled "preserve failures" should do. what: - Bound Debug and Display each on the one trait they need from E, and Error on both, since it requires them as supertraits; each now shows the operation value when E provides it, and still compiles for an E that provides neither - Leave source() returning None for a lone Operation: E is still not required to implement std::error::Error, so there is no &(dyn Error + 'static) to hand back even when Display can show it - Update the doc comment, the migration guide, and the test that asserted redaction to match Verified scratch.rs (E = Box, not itself Error) and orchestrate.rs (E = libtmux::Error) both still compile: the bound this drops is Error, not Debug or Display, so neither example needed it. --- crates/libtmux/docs/migration.md | 13 +++++---- crates/libtmux/docs/public-api.txt | 6 ++--- crates/libtmux/src/error/scoped.rs | 42 ++++++++++++++++++++---------- crates/libtmux/tests/scoped.rs | 17 ++++++++---- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index 1c01f60a..9303846b 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -59,11 +59,14 @@ assert!(both(&error).is_none()); ``` Cleanup errors carry `Error::AfterEffect` because resource creation succeeded. -`Debug` and `Display` redact generic operation values. The standard error -source is the creation or cleanup `Error`; inspect the operation variant to -access the generic operation value or its own source chain. This keeps -`ScopeError` usable with boxed errors and values that do not implement -`std::error::Error`. +`Debug` and `Display` show the operation value when `E` implements the +matching trait, and `std::error::Error` needs both, since it requires them as +supertraits; a caller whose `E` implements neither still gets a working +scope, with the value reachable by matching the variant. The standard error +source is the creation or cleanup `Error`, never the operation value: `E` +need not implement `std::error::Error` at all, so match the operation variant +to reach its value or its own source chain. This keeps `ScopeError` usable +with boxed errors and values that do not implement `std::error::Error`. ## Owned queries and names diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 75d9d695..d4fce61b 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -1677,9 +1677,9 @@ impl<'de> Deserialize<'de> for libtmux::plan::SplitWindow impl<'de> Deserialize<'de> for libtmux::plan::WindowSlot impl<'de> Deserialize<'de> for libtmux::plan::WindowTarget impl<'values, T> IntoIterator for &'values libtmux::SparseValues -impl Debug for libtmux::ScopeError -impl Display for libtmux::ScopeError -impl Error for libtmux::ScopeError +impl Error for libtmux::ScopeError +impl Debug for libtmux::ScopeError +impl Display for libtmux::ScopeError impl Clone for libtmux::query::ManyRelation impl Clone for libtmux::query::OneRelation impl Copy for libtmux::query::ManyRelation diff --git a/crates/libtmux/src/error/scoped.rs b/crates/libtmux/src/error/scoped.rs index dd59bb94..9e97f1af 100644 --- a/crates/libtmux/src/error/scoped.rs +++ b/crates/libtmux/src/error/scoped.rs @@ -10,10 +10,20 @@ use super::Error; /// requirement. Cleanup failures retain [`Error::AfterEffect`] because /// creation succeeded; an operation error alone makes no replay guarantee. /// -/// `Debug` and `Display` withhold the operation error's contents. Inspect its -/// variant to retrieve it. [`std::error::Error::source`] exposes creation or -/// cleanup errors. The operation value is available through its variant, -/// since its generic type need not implement [`std::error::Error`]. +/// `Debug`, `Display`, and [`std::error::Error`] are implemented for every +/// `E`, but each only shows the operation value when `E` itself supports it: +/// `Debug` needs `E: Debug`, `Display` needs `E: Display`, and `Error` needs +/// both, since it requires them as supertraits. A caller whose `E` has +/// neither still gets a working scope: the value remains reachable by +/// matching the variant, and creation and cleanup failures format and chain +/// regardless. +/// +/// [`std::error::Error::source`] exposes the cleanup error in +/// [`Self::Cleanup`] and [`Self::OperationAndCleanup`], and the creation +/// error in [`Self::Creation`]. It never exposes the operation error: `E` +/// need not implement [`std::error::Error`] at all, so there is no +/// `&(dyn Error + 'static)` to hand back even when `Display` can show it. +/// Match the variant to reach it directly. /// /// # Examples /// @@ -50,38 +60,42 @@ pub enum ScopeError { }, } -impl fmt::Debug for ScopeError { +impl fmt::Debug for ScopeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Creation(error) => formatter.debug_tuple("Creation").field(error).finish(), - Self::Operation(_) => formatter.write_str("Operation()"), + Self::Operation(error) => formatter.debug_tuple("Operation").field(error).finish(), Self::Cleanup(error) => formatter.debug_tuple("Cleanup").field(error).finish(), - Self::OperationAndCleanup { cleanup, .. } => formatter + Self::OperationAndCleanup { operation, cleanup } => formatter .debug_struct("OperationAndCleanup") - .field("operation", &"") + .field("operation", operation) .field("cleanup", cleanup) .finish(), } } } -impl fmt::Display for ScopeError { +impl fmt::Display for ScopeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Creation(error) => write!(formatter, "scoped resource creation failed: {error}"), - Self::Operation(_) => formatter.write_str("scoped operation failed"), + Self::Operation(error) => write!(formatter, "scoped operation failed: {error}"), Self::Cleanup(error) => write!(formatter, "scoped resource cleanup failed: {error}"), - Self::OperationAndCleanup { cleanup, .. } => { - write!(formatter, "scoped operation and cleanup failed: {cleanup}") - } + Self::OperationAndCleanup { operation, cleanup } => write!( + formatter, + "scoped operation failed: {operation}; cleanup also failed: {cleanup}" + ), } } } -impl std::error::Error for ScopeError { +impl std::error::Error for ScopeError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Creation(error) | Self::Cleanup(error) => Some(error), + // `E` is not required to implement `Error`, so there is no + // `&(dyn Error + 'static)` to return here even though `Debug` + // and `Display` can show it. Self::Operation(_) => None, Self::OperationAndCleanup { cleanup, .. } => Some(cleanup), } diff --git a/crates/libtmux/tests/scoped.rs b/crates/libtmux/tests/scoped.rs index a91c2b6c..5ef0e2da 100644 --- a/crates/libtmux/tests/scoped.rs +++ b/crates/libtmux/tests/scoped.rs @@ -7,6 +7,7 @@ use std::rc::Rc; use libtmux::test::TestServer; use libtmux::{Error, ErrorKind, NewWindowOptions, ScopeError, SplitDirection, SplitOptions}; +#[derive(Debug)] struct OperationFailure(Rc<()>); #[tokio::test] @@ -26,7 +27,7 @@ async fn cleanup_failure_retains_the_owned_operation_error() { 2, "the result must retain the original operation error after cleanup fails" ); - assert!(format!("{result:?}").contains("")); + assert!(format!("{result:?}").contains("OperationAndCleanup")); assert_combined(result.expect_err("operation and cleanup fail"), &witness); assert_eq!(Rc::strong_count(&witness), 1); } @@ -118,20 +119,23 @@ async fn cleanup_failure_after_success_is_a_partial_effect() { } #[tokio::test] -async fn combined_errors_keep_typed_sources_and_redact_operation_details() { +async fn combined_errors_keep_typed_sources_and_show_operation_details() { use std::error::Error as _; let guard = TestServer::builder().start().await.expect("tmux starts"); let error = guard .server() - .with_session("redaction", async |session| { + .with_session("visible-cause", async |session| { session.clone().kill().await.expect("session is killed"); Err::<(), _>(std::io::Error::other("operation-secret")) }) .await .expect_err("operation and cleanup fail"); guard.shutdown().await.expect("tmux fixture shuts down"); - assert!(!format!("{error:?} {error}").contains("operation-secret")); + // `std::io::Error` is `Debug` and `Display`, so both surface the cause a + // caller propagating `ScopeError` with `?` would otherwise lose. + assert!(format!("{error:?}").contains("operation-secret")); + assert!(format!("{error}").contains("operation-secret")); let cleanup_source = error.source().expect("cleanup source"); assert!(cleanup_source.is::()); let ScopeError::OperationAndCleanup { operation, cleanup } = &error else { @@ -146,7 +150,10 @@ async fn combined_errors_keep_typed_sources_and_redact_operation_details() { )); let error = ScopeError::Operation(std::io::Error::other("operation-secret")); - assert!(!format!("{error:?} {error}").contains("operation-secret")); + assert!(format!("{error:?} {error}").contains("operation-secret")); + // `source` still withholds the operation error: its generic type is not + // required to implement `std::error::Error`, so there is nothing to + // return even though `Display` can show it. assert!(error.source().is_none()); assert!( matches!(error, ScopeError::Operation(operation) if operation.to_string() == "operation-secret") From 8b1b80ecee944f952f98714c8719c3215aed87f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:06:55 -0500 Subject: [PATCH 010/117] Scope(fix): Retain the operation's result when only cleanup fails why: ScopeError::Cleanup(Error) discarded T on the one path where the caller had already paid for it: the operation succeeded, cleanup failed afterward, and the computed value was thrown away with no way back to it. The PR that made cleanup and operation errors mutually retrievable still lost a result the caller already has in hand. what: - Add a T parameter to ScopeError, unreleased so this is free: Cleanup becomes a struct variant carrying both the operation's value and the cleanup Error, alongside OperationAndCleanup's existing shape - Thread T through with_session, with_window, with_pane, and the internal scoped::run that builds ScopeError - Bound Debug on T: Debug alongside E: Debug, matching Cleanup's new field; Display and Error need no T bound, since neither ever prints a successful result - Update the crate doc, design and migration docs, and the test that matched Cleanup's old tuple shape; add a value-carrying assertion cleanup_failure_after_success_retains_the_computed_value replaces --- crates/libtmux/docs/design.md | 8 +++-- crates/libtmux/docs/migration.md | 36 +++++++++++--------- crates/libtmux/docs/public-api.txt | 17 +++++----- crates/libtmux/src/error/scoped.rs | 47 ++++++++++++++++++--------- crates/libtmux/src/internal/scoped.rs | 9 +++-- crates/libtmux/src/lib.rs | 2 +- crates/libtmux/src/server.rs | 2 +- crates/libtmux/src/session.rs | 2 +- crates/libtmux/src/window.rs | 2 +- crates/libtmux/tests/scoped.rs | 20 +++++++----- 10 files changed, 88 insertions(+), 57 deletions(-) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 9668cee0..9339a0f2 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -684,9 +684,11 @@ arms cleanup before handing the object to the caller, and keeps cleanup running after cancellation. Cleanup needs the Tokio runtime to remain active; ordinary cloneable handles remain non-destructive. -`ScopeError` separates creation, operation and cleanup failures. Combined -failures retain both the caller's generic error and the cleanup `Error`; -cleanup errors carry `AfterEffect` because creation already succeeded. +`ScopeError` separates creation, operation and cleanup failures. +Combined failures retain both the caller's generic error and the cleanup +`Error`; a cleanup failure alone retains the operation's own successful +result instead of discarding it. Cleanup errors carry `AfterEffect` because +creation already succeeded. If tmux creates an object but the command fails before yielding a decodable handle, the scope has no identity to target and cannot compensate for it. diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index 9303846b..6e1e7f81 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -35,38 +35,42 @@ normal completion and failure; call `shutdown` to observe a connection error. ## Scoped errors -`with_session`, `with_window` and `with_pane` return `Result>` -instead of `Result`. Remove `E: From` from caller error -types when it was needed only for these helpers. Functions propagating a -scope's result can return `ScopeError` or wrap it in their application -error. Nested scopes retain nested error types. +`with_session`, `with_window` and `with_pane` return +`Result>` instead of `Result`. Remove +`E: From` from caller error types when it was needed only for +these helpers. Functions propagating a scope's result can return +`ScopeError` or wrap it in their application error. Nested scopes +retain nested error types. Match `ScopeError::Creation`, `Operation`, `Cleanup` or -`OperationAndCleanup`. The combined variant retains both original values: +`OperationAndCleanup`. The combined variant retains both original values, and +`Cleanup` retains the operation's own successful result, since cleanup +failing is the only way that result would otherwise be lost: ``` use libtmux::{Error, ScopeError}; -fn both(error: &ScopeError) -> Option<(&E, &Error)> { +fn both(error: &ScopeError) -> Option<(&E, &Error)> { match error { ScopeError::OperationAndCleanup { operation, cleanup } => Some((operation, cleanup)), _ => None, } } -let error = ScopeError::Operation("application error"); +let error: ScopeError<(), _> = ScopeError::Operation("application error"); assert!(both(&error).is_none()); ``` Cleanup errors carry `Error::AfterEffect` because resource creation succeeded. -`Debug` and `Display` show the operation value when `E` implements the -matching trait, and `std::error::Error` needs both, since it requires them as -supertraits; a caller whose `E` implements neither still gets a working -scope, with the value reachable by matching the variant. The standard error -source is the creation or cleanup `Error`, never the operation value: `E` -need not implement `std::error::Error` at all, so match the operation variant -to reach its value or its own source chain. This keeps `ScopeError` usable -with boxed errors and values that do not implement `std::error::Error`. +`Debug` and `Display` show the operation and `Cleanup` values when `T` and +`E` implement the matching trait, and `std::error::Error` needs both, since +it requires them as supertraits; a caller whose types implement neither still +gets a working scope, with both values reachable by matching the variant. +The standard error source is the creation or cleanup `Error`, never the +operation value: `E` need not implement `std::error::Error` at all, so match +the operation variant to reach its value or its own source chain. This keeps +`ScopeError` usable with boxed errors and values that do not implement +`std::error::Error`. ## Owned queries and names diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index d4fce61b..2510a4b4 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -264,7 +264,7 @@ enum libtmux::PromptKind enum libtmux::ReplaceMode enum libtmux::ResizeDirection enum libtmux::Rotation -enum libtmux::ScopeError +enum libtmux::ScopeError enum libtmux::ServerConfigurationErrorKind enum libtmux::ServerGoneKind enum libtmux::SessionNameError @@ -562,7 +562,7 @@ function libtmux::Server::wait_for_channel: async fn(&self, channel: &str, withi function libtmux::Server::window_by_id: async fn(&self, id: &libtmux::WindowId) -> Result, libtmux::Error> function libtmux::Server::windows: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::windows_or_empty: async fn(&self) -> Vec -function libtmux::Server::with_session: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Session) -> Result) -> Result> +function libtmux::Server::with_session: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Session) -> Result) -> Result> function libtmux::ServerBuilder::build: fn(self) -> Result function libtmux::ServerBuilder::colors: const fn(self, colors: u16) -> Self function libtmux::ServerBuilder::config_file: fn(self, path: impl Into) -> Self @@ -624,7 +624,7 @@ function libtmux::Session::window_at: async fn(&self, index: i32) -> Result u32 function libtmux::Session::windows: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::windows_or_empty: async fn(&self) -> Vec -function libtmux::Session::with_window: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Window) -> Result) -> Result> +function libtmux::Session::with_window: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Window) -> Result) -> Result> function libtmux::SessionName::as_str: fn(&self) -> &str function libtmux::SessionName::new: fn(name: impl Into) -> Result function libtmux::SparseValues::first: fn(&self) -> Option<&T> @@ -713,7 +713,7 @@ function libtmux::Window::unlink: async fn(self) -> Result<(), libtmux::Error> function libtmux::Window::unset_hook: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Window::unset_option: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Window::width: fn(&self) -> u32 -function libtmux::Window::with_pane: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Pane) -> Result) -> Result> +function libtmux::Window::with_pane: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Pane) -> Result) -> Result> function libtmux::blocking::Runtime::new: fn() -> Result function libtmux::blocking::Runtime::run: fn(&self, future: F) -> ::Output function libtmux::blocking::Runtime::try_run: fn(&self, future: F) -> Result<::Output, libtmux::Error> @@ -1677,9 +1677,6 @@ impl<'de> Deserialize<'de> for libtmux::plan::SplitWindow impl<'de> Deserialize<'de> for libtmux::plan::WindowSlot impl<'de> Deserialize<'de> for libtmux::plan::WindowTarget impl<'values, T> IntoIterator for &'values libtmux::SparseValues -impl Error for libtmux::ScopeError -impl Debug for libtmux::ScopeError -impl Display for libtmux::ScopeError impl Clone for libtmux::query::ManyRelation impl Clone for libtmux::query::OneRelation impl Copy for libtmux::query::ManyRelation @@ -1691,6 +1688,7 @@ impl Eq for libtmux::query::OneRelation impl PartialEq for libtmux::query::ManyRelation impl PartialEq for libtmux::query::OneRelation impl libtmux::query::QueryIteratorExt for I +impl Display for libtmux::ScopeError impl Clone for libtmux::query::EnumField impl Copy for libtmux::query::EnumField impl Debug for libtmux::query::EnumField @@ -1709,6 +1707,8 @@ impl Eq for libtmux::SparseValues impl> From for libtmux::NewSessionOptions impl> From for libtmux::NewWindowOptions impl PartialEq for libtmux::SparseValues +impl Error for libtmux::ScopeError +impl Debug for libtmux::ScopeError impl JsonSchema for libtmux::query::FilterExpr impl Serialize for libtmux::query::FilterExpr impl libtmux::query::Matcher for &libtmux::query::FilterExpr @@ -2008,7 +2008,8 @@ struct_field libtmux::PaneFields::wrap_flag: libtmux::query::BoolField struct_field libtmux::PaneSize::Cells::0: u32 struct_field libtmux::PaneSize::Percent::0: u32 struct_field libtmux::PaneTarget::Id::0: libtmux::PaneId -struct_field libtmux::ScopeError::Cleanup::0: libtmux::Error +struct_field libtmux::ScopeError::Cleanup::cleanup: libtmux::Error +struct_field libtmux::ScopeError::Cleanup::value: T struct_field libtmux::ScopeError::Creation::0: libtmux::Error struct_field libtmux::ScopeError::Operation::0: E struct_field libtmux::ScopeError::OperationAndCleanup::cleanup: libtmux::Error diff --git a/crates/libtmux/src/error/scoped.rs b/crates/libtmux/src/error/scoped.rs index 9e97f1af..60888ebb 100644 --- a/crates/libtmux/src/error/scoped.rs +++ b/crates/libtmux/src/error/scoped.rs @@ -9,14 +9,17 @@ use super::Error; /// operation error keeps its original type and value, with no `From` /// requirement. Cleanup failures retain [`Error::AfterEffect`] because /// creation succeeded; an operation error alone makes no replay guarantee. +/// [`Self::Cleanup`] retains the operation's own result too: it already +/// computed `T` when cleanup failed, and matching the variant is the only way +/// to reach it, since the outer `Result` is `Err` either way. /// /// `Debug`, `Display`, and [`std::error::Error`] are implemented for every -/// `E`, but each only shows the operation value when `E` itself supports it: -/// `Debug` needs `E: Debug`, `Display` needs `E: Display`, and `Error` needs -/// both, since it requires them as supertraits. A caller whose `E` has -/// neither still gets a working scope: the value remains reachable by -/// matching the variant, and creation and cleanup failures format and chain -/// regardless. +/// `T` and `E`, but each only shows a value when its type supports it: +/// `Debug` needs `T: Debug` and `E: Debug`, `Display` needs `E: Display`, and +/// `Error` needs both, since it requires them as supertraits. A caller whose +/// types have neither still gets a working scope: both values remain +/// reachable by matching the variant, and creation and cleanup failures +/// format and chain regardless. /// /// [`std::error::Error::source`] exposes the cleanup error in /// [`Self::Cleanup`] and [`Self::OperationAndCleanup`], and the creation @@ -44,13 +47,19 @@ use super::Error; /// # Ok(()) /// # } /// ``` -pub enum ScopeError { +pub enum ScopeError { /// The resource could not be created; the operation did not run. Creation(Error), /// The operation failed and cleanup succeeded. Operation(E), /// The operation succeeded, but cleanup failed after creation. - Cleanup(Error), + Cleanup { + /// The operation's own result, since the caller cannot reach it any + /// other way once cleanup fails. + value: T, + /// The cleanup error, marked as [`Error::AfterEffect`]. + cleanup: Error, + }, /// The operation and cleanup both failed. OperationAndCleanup { /// The caller's original operation error. @@ -60,12 +69,16 @@ pub enum ScopeError { }, } -impl fmt::Debug for ScopeError { +impl fmt::Debug for ScopeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Creation(error) => formatter.debug_tuple("Creation").field(error).finish(), Self::Operation(error) => formatter.debug_tuple("Operation").field(error).finish(), - Self::Cleanup(error) => formatter.debug_tuple("Cleanup").field(error).finish(), + Self::Cleanup { value, cleanup } => formatter + .debug_struct("Cleanup") + .field("value", value) + .field("cleanup", cleanup) + .finish(), Self::OperationAndCleanup { operation, cleanup } => formatter .debug_struct("OperationAndCleanup") .field("operation", operation) @@ -75,12 +88,14 @@ impl fmt::Debug for ScopeError { } } -impl fmt::Display for ScopeError { +impl fmt::Display for ScopeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Creation(error) => write!(formatter, "scoped resource creation failed: {error}"), Self::Operation(error) => write!(formatter, "scoped operation failed: {error}"), - Self::Cleanup(error) => write!(formatter, "scoped resource cleanup failed: {error}"), + Self::Cleanup { cleanup, .. } => { + write!(formatter, "scoped resource cleanup failed: {cleanup}") + } Self::OperationAndCleanup { operation, cleanup } => write!( formatter, "scoped operation failed: {operation}; cleanup also failed: {cleanup}" @@ -89,15 +104,17 @@ impl fmt::Display for ScopeError { } } -impl std::error::Error for ScopeError { +impl std::error::Error for ScopeError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Creation(error) | Self::Cleanup(error) => Some(error), + Self::Creation(error) => Some(error), + Self::Cleanup { cleanup, .. } | Self::OperationAndCleanup { cleanup, .. } => { + Some(cleanup) + } // `E` is not required to implement `Error`, so there is no // `&(dyn Error + 'static)` to return here even though `Debug` // and `Display` can show it. Self::Operation(_) => None, - Self::OperationAndCleanup { cleanup, .. } => Some(cleanup), } } } diff --git a/crates/libtmux/src/internal/scoped.rs b/crates/libtmux/src/internal/scoped.rs index d47a7343..f0eaba7a 100644 --- a/crates/libtmux/src/internal/scoped.rs +++ b/crates/libtmux/src/internal/scoped.rs @@ -13,7 +13,7 @@ pub(crate) async fn run( create: Create, cleanup: Cleanup, operation: Operation, -) -> Result> +) -> Result> where R: Clone + Send + 'static, Create: Future> + Send + 'static, @@ -28,7 +28,10 @@ where match (outcome, cleanup.finish().await) { (outcome, Ok(())) => outcome.map_err(ScopeError::Operation), - (Ok(_), Err(error)) => Err(ScopeError::Cleanup(error.after_effect(operation_name))), + (Ok(value), Err(error)) => Err(ScopeError::Cleanup { + value, + cleanup: error.after_effect(operation_name), + }), (Err(operation), Err(cleanup)) => Err(ScopeError::OperationAndCleanup { operation, cleanup: cleanup.after_effect(operation_name), @@ -181,7 +184,7 @@ mod tests { .await .expect_err("cleanup fails after the scoped operation succeeded"); - let ScopeError::Cleanup(error) = error else { + let ScopeError::Cleanup { value: (), cleanup: error } = error else { panic!("cleanup failed after the operation succeeded"); }; assert_eq!(error.kind(), ErrorKind::PartialEffect); diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 515759fe..3f8e29f1 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -60,7 +60,7 @@ //! `Drop` is deliberately non-destructive. //! //! ```no_run -//! # async fn scoped(server: &libtmux::Server) -> Result<(), libtmux::ScopeError> { +//! # async fn scoped(server: &libtmux::Server) -> Result<(), libtmux::ScopeError> { //! let id = server //! .with_session("throwaway", async |session| { //! session.new_window("build").await?; diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 374f4ce3..7310d5ce 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1269,7 +1269,7 @@ impl Server { &self, options: impl Into, operation: impl AsyncFnOnce(&Session) -> Result, - ) -> Result> { + ) -> Result> { let server = self.clone(); let options = options.into(); scoped::run( diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index 0de3be61..31d405f0 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -807,7 +807,7 @@ impl Session { &self, options: impl Into, operation: impl AsyncFnOnce(&Window) -> Result, - ) -> Result> { + ) -> Result> { let session = self.clone(); let options = options.into(); scoped::run( diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 15328798..b02a33c1 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -798,7 +798,7 @@ impl Window { &self, options: impl Into, operation: impl AsyncFnOnce(&Pane) -> Result, - ) -> Result> { + ) -> Result> { let window = self.clone(); let options = options.into(); scoped::run( diff --git a/crates/libtmux/tests/scoped.rs b/crates/libtmux/tests/scoped.rs index 5ef0e2da..5f44539a 100644 --- a/crates/libtmux/tests/scoped.rs +++ b/crates/libtmux/tests/scoped.rs @@ -33,7 +33,7 @@ async fn cleanup_failure_retains_the_owned_operation_error() { } #[allow(clippy::panic, reason = "test assertion helper")] -fn assert_combined(error: ScopeError, witness: &Rc<()>) { +fn assert_combined(error: ScopeError<(), OperationFailure>, witness: &Rc<()>) { let ScopeError::OperationAndCleanup { operation, cleanup } = error else { panic!("both errors must be preserved"); }; @@ -101,20 +101,24 @@ async fn creation_failure_does_not_run_the_operation_or_adopt_an_existing_sessio } #[tokio::test] -async fn cleanup_failure_after_success_is_a_partial_effect() { +async fn cleanup_failure_after_success_retains_the_computed_value() { let guard = TestServer::builder().start().await.expect("tmux starts"); let error = guard .server() .with_session("cleanup", async |session| { session.clone().kill().await.expect("session is killed"); - Ok::<(), OperationFailure>(()) + // A value the caller could not recompute after the fact, so + // retaining it is the difference this test exists to check. + Ok::(42) }) .await .expect_err("cleanup fails"); - assert!(matches!( - error, - ScopeError::Cleanup(Error::AfterEffect { .. }) - )); + let ScopeError::Cleanup { value, cleanup } = error else { + panic!("cleanup failed after the operation succeeded"); + }; + assert_eq!(value, 42, "the operation's own result must survive"); + assert_eq!(cleanup.kind(), ErrorKind::PartialEffect); + assert!(matches!(cleanup, Error::AfterEffect { .. })); guard.shutdown().await.expect("tmux fixture shuts down"); } @@ -149,7 +153,7 @@ async fn combined_errors_keep_typed_sources_and_show_operation_details() { cleanup )); - let error = ScopeError::Operation(std::io::Error::other("operation-secret")); + let error: ScopeError<(), _> = ScopeError::Operation(std::io::Error::other("operation-secret")); assert!(format!("{error:?} {error}").contains("operation-secret")); // `source` still withholds the operation error: its generic type is not // required to implement `std::error::Error`, so there is nothing to From fca055c6b0e7dad026cc2ee42d796f68d48a7387 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:09:34 -0500 Subject: [PATCH 011/117] Examples(fix[watch]): Run teardown even when the watcher errors why: next_event() now yields a terminal error instead of ending the stream silently, so the watcher's event? can end the task with Err. main propagated that with tokio::time::timeout(...).await???, which returned before server.kill(), server.shutdown(), and remove_file(&socket) ran -- leaking a tmux server and a socket file under /tmp/libtmux-rs-dev per failed run. what: - Collect the watcher's outcome in an inner block instead of propagating it immediately, so kill/shutdown/remove_file always run before the error (if any) is returned - Keep events.shutdown() ahead of server.kill() on the success path, unchanged from before Verified by temporarily forcing the watcher's first iteration to return Err: before this change the run left both the socket file and the tmux server behind; after, neither remains. --- crates/libtmux/examples/watch.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/libtmux/examples/watch.rs b/crates/libtmux/examples/watch.rs index d9c71ee9..3d9afe51 100644 --- a/crates/libtmux/examples/watch.rs +++ b/crates/libtmux/examples/watch.rs @@ -73,19 +73,30 @@ async fn main() -> Result<(), Box> { ) .await?; - let (seen, events) = tokio::time::timeout(Duration::from_secs(10), watcher).await???; - println!( - "{} events arrived while those commands were being sent, on the same socket", - seen.len() - ); + // The watcher's own `event?` can end this early -- a connection error is + // as reachable here as tmux exiting cleanly. Collecting its outcome + // first, rather than propagating it immediately with `?`, means the + // teardown below still runs on that path instead of leaking the + // throwaway server and its socket file. + let outcome: Result> = async { + let (seen, events) = tokio::time::timeout(Duration::from_secs(10), watcher).await???; + println!( + "{} events arrived while those commands were being sent, on the same socket", + seen.len() + ); + drop(commands); + events.shutdown().await?; + Ok(seen.len()) + } + .await; - drop(commands); - events.shutdown().await?; server.kill().await?; server.shutdown().await?; // tmux does not unlink its socket when the server exits, so whatever named // one owns removing it. std::fs::remove_file(&socket)?; + + outcome?; Ok(()) } From 61782c6b9f446003820534ae018eacc2b036fd5b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:14:01 -0500 Subject: [PATCH 012/117] Docs(fix[doctest]): Pin compile_fail examples and restore lost coverage why: The two compile_fail blocks added for matching_owned's exactly_one and one_or_none examples name no error code, so an unrelated typo would make either pass for the wrong reason. Replacing the old into_iter().matching(...) example with matching_owned's working one also dropped the only test that a borrowed-only matching() still refuses an owned iterator, with nothing added back in its place. what: - Pin E0597 (borrow does not live long enough) and E0382 (use of moved value) on the two existing compile_fail blocks - Restore an into_iter().matching(...) compile_fail example, pinned to E0271 (the associated-type mismatch it now produces) Verified each E-code against the actual diagnostic by compiling the three snippets directly. Also verified, by pinning one to a wrong code and rerunning `cargo test --doc`, that stable rustdoc's compile_fail does not check the code against the pin: the mismatched run still passed. The pins document the intended failure for a reader; they are not an enforced gate on this toolchain. --- crates/libtmux/src/internal/scoped.rs | 6 +++++- crates/libtmux/src/lib.rs | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/libtmux/src/internal/scoped.rs b/crates/libtmux/src/internal/scoped.rs index f0eaba7a..17a7bcac 100644 --- a/crates/libtmux/src/internal/scoped.rs +++ b/crates/libtmux/src/internal/scoped.rs @@ -184,7 +184,11 @@ mod tests { .await .expect_err("cleanup fails after the scoped operation succeeded"); - let ScopeError::Cleanup { value: (), cleanup: error } = error else { + let ScopeError::Cleanup { + value: (), + cleanup: error, + } = error + else { panic!("cleanup failed after the operation succeeded"); }; assert_eq!(error.kind(), ErrorKind::PartialEffect); diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 3f8e29f1..2ff8c049 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -208,9 +208,19 @@ //! assert_eq!(selected, [2, 3]); //! ``` //! +//! `matching` still needs a borrowed iterator; `into_iter()` does not satisfy +//! it: +//! +//! ```compile_fail,E0271 +//! use libtmux::query::QueryIteratorExt; +//! +//! let values = vec![1, 2, 3]; +//! let _ = values.into_iter().matching(|candidate: &i32| *candidate > 1); +//! ``` +//! //! Borrowed results cannot outlive their collection: //! -//! ```compile_fail +//! ```compile_fail,E0597 //! use libtmux::query::QueryIteratorExt; //! //! let selected = { @@ -222,7 +232,7 @@ //! //! Consuming a collection transfers ownership: //! -//! ```compile_fail +//! ```compile_fail,E0382 //! use libtmux::query::QueryIteratorExt; //! //! let values = vec![String::from("only")]; From 8f02daddd63e9acbe0a1590c76c477ef1a603195 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 13 Sep 2026 17:17:40 -0500 Subject: [PATCH 013/117] Test(add[control]): Cover PaneOutput::shutdown's specific terminal error why: A review finding claimed PaneOutput collapses every terminal error into a generic closed/None, same as its own Stream and next_chunk do by design. It does not: shutdown() never touches ControlEvents::poll_next, so the connection's JoinHandle is still intact when shutdown awaits it, and the specific error comes back whichever one it was. Nothing exercised that path. what: - Add pane_output_shutdown_reports_the_specific_terminal_error: a mock connection resolves to ControlModeFrameTooLarge, next_chunk still ends quietly (the documented infallible-stream contract), and shutdown returns the frame error rather than a generic one Verified the test bites: temporarily made shutdown's Ok(outcome) arm discard outcome (`Ok(_outcome) => Ok(())`), reran, watched it fail on the expect_err, then reverted. --- crates/libtmux/src/control/tests.rs | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/libtmux/src/control/tests.rs b/crates/libtmux/src/control/tests.rs index fc3384f0..79996302 100644 --- a/crates/libtmux/src/control/tests.rs +++ b/crates/libtmux/src/control/tests.rs @@ -198,6 +198,41 @@ async fn watch_only_refuses_a_failed_listing_before_muting_any_pane() { assert!(requests.try_recv().is_err(), "no mute was dispatched"); } +#[tokio::test] +async fn pane_output_shutdown_reports_the_specific_terminal_error() { + // PaneOutput's own Stream and next_chunk stay infallible by design (see + // its doc comment): whatever ends the connection collapses into `None`. + // shutdown() is where a caller who needs to tell "frame too large" from + // "pane finished" looks -- it never touches ControlEvents::poll_next, so + // the connection's own JoinHandle is still there to consult when asked. + let (commands, _requests) = mpsc::channel(1); + let sender = sender(commands, Duration::from_secs(5)); + let (deliveries, received) = mpsc::channel(1); + let (stop, _stopped) = watch::channel(()); + let connection = + tokio::spawn(async { Err(Error::control_mode_frame_too_large("test-frame", 42)) }); + let mut output = PaneOutput::new( + "%1".parse().expect("a pane id"), + ControlEvents { + events: received, + stop, + connection: Some(connection), + }, + sender, + ); + drop(deliveries); + + assert!( + output.next_chunk().await.is_none(), + "the stream ends quietly" + ); + let error = output + .shutdown() + .await + .expect_err("the frame-too-large diagnostic survives to shutdown"); + assert!(matches!(error, Error::ControlModeFrameTooLarge { .. })); +} + #[tokio::test] async fn dirty_narrowing_reruns_after_an_in_flight_failure() { let (commands, mut requests) = mpsc::channel(4); From 471cdada5ca86059bacc45cd40d4b4da1d55cd2b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 18:58:49 -0500 Subject: [PATCH 014/117] Formats(fix[codec]): Accept next-3.9's widened #{q:} escape set why: The unreleased tree reporting itself as next-3.9 has tmux escaping `{`, `}` and a raw newline under `#{q:}`, which no release through 3.7c does. QUOTE_SHELL_SPECIALS is a closed allow-list, so decode_escape rejected every one of the three with InvalidEscape. window_layout is the field almost every listing carries, and on that tree its value is JSON -- built from braces -- so the failure reached nearly every Window listing: new-session, new-window, and any query that lists or resolves a window. Verified directly against real next-3.9 output for `#{q:session_name}` (`##{version}` now decodes as `\#\{version\}`, versus `\#{version}` through 3.7b) and for a raw newline in a set option's value. Accepting the three bytes is a strict superset: no supported release emits them, so nothing already decoding correctly can start decoding differently. No `since::` gate is needed, matching how this crate already treats other tmux-emitted-bytes sets. Kept the allow-list rather than switching decode_escape to "a backslash always makes the next byte literal". decode_escape already uses QUOTE_SHELL_SPECIALS to catch a dialect mismatch loudly -- Vis output read as RawQ, or the reverse -- instead of quietly decoding into different bytes (see its own doc comment). A generic rule would also misread a Vis three-digit octal escape as three separate one-byte escapes under RawQ. The pre-existing format_codec_rejects_escapes_tmux_never_emits test (probes `\:`, `\]`, `\z`, none newly accepted) still passes unmodified, so strict rejection of a genuinely unknown escape is preserved. what: - Add `{`, `}` and `\n` to QUOTE_SHELL_SPECIALS - Extend the escape-set-equality test with the three additions and add a dedicated round-trip test for them; verified both fail with InvalidEscape on the prior set and pass after, then reverted the fixture to confirm the pre-existing rejection test needed no change - Confirmed against live next-3.9: a previously-InvalidEscape listing (crates/libtmux/tests/commands.rs a_global_window_option_is_read_globally) now passes, and the adversarial-transport compat test's decode-and-round-trip step now succeeds (its separate raw-wire-byte fixture still needs its own fix, tracked apart from this one) - CHANGELOG entry under Unreleased --- crates/libtmux/src/formats/row.rs | 6 +++++- crates/libtmux/src/formats/tests.rs | 27 ++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/libtmux/src/formats/row.rs b/crates/libtmux/src/formats/row.rs index cdd80225..e242125c 100644 --- a/crates/libtmux/src/formats/row.rs +++ b/crates/libtmux/src/formats/row.rs @@ -10,7 +10,11 @@ use super::{DecoderKind, FormatDescriptor, ListProfile}; /// This is tmux's `format_quote_shell` set. None of these bytes is an octal /// digit or one of the letters `vis` emits, so the two escaping layers below /// compose into one unambiguous grammar. -pub(super) const QUOTE_SHELL_SPECIALS: &[u8] = b"|&;<>()$`\\\"'*?[# =%"; +/// +/// `{`, `}` and `\n` joined the set on the unreleased tree reporting as +/// `next-3.9`. No release through 3.7c escapes them, so accepting them here +/// is a safe superset on every version -- no `since::` gate needed. +pub(super) const QUOTE_SHELL_SPECIALS: &[u8] = b"|&;<>()$`\\\"'*?[# =%{}\n"; /// The field separator a format plan's template renders between values. /// diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index 66d5016a..b22c05e9 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -39,6 +39,8 @@ const Q_SHELL_ESCAPED: [u8; 19] = [ 0x7c, 0x26, 0x3b, 0x3c, 0x3e, 0x28, 0x29, 0x24, 0x60, 0x5c, 0x22, 0x27, 0x2a, 0x3f, 0x5b, 0x23, 0x20, 0x3d, 0x25, ]; +/// `{`, `}` and a raw newline: what `next-3.9` added to the set above. +const Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS: [u8; 3] = *b"{}\n"; const SHORT_SENTINEL: &str = "zot-private"; const LONG_SENTINEL: &str = "quartz-private-payload-with-a-distinct-and-deliberately-long-shape"; const CONTROL_SENTINEL: [u8; 3] = [0x02, 0x03, 0x04]; @@ -679,7 +681,12 @@ fn format_codec_rejects_escapes_tmux_never_emits() { #[test] fn production_q_escape_set_matches_the_documented_tmux_set() { - assert_eq!(QUOTE_SHELL_SPECIALS, Q_SHELL_ESCAPED); + let documented: Vec = Q_SHELL_ESCAPED + .iter() + .chain(Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS.iter()) + .copied() + .collect(); + assert_eq!(QUOTE_SHELL_SPECIALS, documented); } #[test] @@ -704,6 +711,24 @@ fn format_codec_tmux_3_2a_q_escape_set_round_trips_exactly() { assert_eq!(slot.as_bytes(), Q_SHELL_ESCAPED); } +#[test] +fn format_codec_next_3_9_q_escape_additions_round_trip_exactly() { + // Before QUOTE_SHELL_SPECIALS grew these three bytes, this failed with + // InvalidEscape at the first backslash -- the exact failure real + // next-3.9 output produces for any value containing a brace or a raw + // newline, such as `#{q:buffer_mode_format}` or `#{q:window_layout}`. + let mut stdout = Vec::with_capacity(Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS.len() * 2 + 2); + for byte in Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS { + stdout.extend_from_slice(&[b'\\', byte]); + } + stdout.extend_from_slice(b"=\n"); + + let plan = plan(vec![&FIRST]); + let parsed = rows(&plan, &stdout); + let slot = parsed[0].slots().next().expect("one slot exists"); + assert_eq!(slot.as_bytes(), Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS); +} + #[test] fn format_codec_multiple_rows_and_fields_preserve_plan_order() { let plan = plan(vec![&FIRST, &SECOND]); From 3fa864cf8bfdb315082458f200f9a6bd0d78f9b6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:05:36 -0500 Subject: [PATCH 015/117] Server(fix[access]): Decode server-access group ACL markers why: tmux's group-ACL change (upstream commit 4d1ab1ba, "Allow ACLs to use groups as well as users") marks every `server-access -l` row `(U,R)`/`(G,W)` instead of the bare `(R)`/`(W)` this crate expected -- owner row included. The old decoder matched only the exact strings "(R)" and "(W)", so it silently dropped every row on a release with this change, and `access_rules()` came back empty even though the server was reachable and owned. Verified against a tmux built from master-e880cf63 (next-3.9) and confirmed the grammar against tmux's cmd-server-access.c, server-acl.c and the tmux.1 diff in commit 4d1ab1ba. what: - Add `Principal::{User, Group}` and `AccessRule::principal`. Rename `AccessRule::user` to `AccessRule::name`, since a row can now name either. - Decode the marker from the line itself, not from the detected tmux version: a legacy line with no comma is always a user, a compound line reads its `U`/`G` letter. This is a capability probe on the text tmux actually printed, not a version predicate. - Add `Error::UnreadableAccessRule`, naming the unrecognized marker, for a line that matches neither grammar -- replacing the silent drop. - Unit tests for both grammars and for the new error, plus updating the real-tmux integration test to assert `Principal::User` for the owner; ran it against both the default tmux 3.7d and the next-3.9 probe binary. - CHANGELOG entries under Unreleased; regenerated public-api.txt. --- crates/libtmux/docs/public-api.txt | 13 +- crates/libtmux/src/error.rs | 25 ++++ crates/libtmux/src/error/classification.rs | 6 +- crates/libtmux/src/lib.rs | 2 +- crates/libtmux/src/server.rs | 149 +++++++++++++++++---- crates/libtmux/tests/commands.rs | 5 +- 6 files changed, 171 insertions(+), 29 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 2510a4b4..c7b5e41a 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -260,6 +260,7 @@ enum libtmux::PaneProgressState enum libtmux::PaneSize enum libtmux::PaneTarget enum libtmux::PaneWait +enum libtmux::Principal enum libtmux::PromptKind enum libtmux::ReplaceMode enum libtmux::ResizeDirection @@ -292,7 +293,8 @@ enum libtmux::query::FilterExpressionErrorKind enum libtmux::test::DaemonState enum libtmux::test::TestServerErrorKind function libtmux::AccessRule::mode: const fn(&self) -> libtmux::AccessMode -function libtmux::AccessRule::user: fn(&self) -> &str +function libtmux::AccessRule::name: fn(&self) -> &str +function libtmux::AccessRule::principal: const fn(&self) -> libtmux::Principal function libtmux::CaptureOptions::end: const fn(self, line: i32) -> Self function libtmux::CaptureOptions::escape_sequences: const fn(self) -> Self function libtmux::CaptureOptions::history: const fn() -> Self @@ -1006,6 +1008,7 @@ impl Clone for libtmux::PaneProgressState impl Clone for libtmux::PaneSize impl Clone for libtmux::PaneTarget impl Clone for libtmux::PaneWait +impl Clone for libtmux::Principal impl Clone for libtmux::PromptKind impl Clone for libtmux::ReleaseSuffix impl Clone for libtmux::ReleaseVersion @@ -1102,6 +1105,7 @@ impl Copy for libtmux::PaneDirection impl Copy for libtmux::PaneProgressState impl Copy for libtmux::PaneSize impl Copy for libtmux::PaneWait +impl Copy for libtmux::Principal impl Copy for libtmux::PromptKind impl Copy for libtmux::ReleaseSuffix impl Copy for libtmux::ReleaseVersion @@ -1174,6 +1178,7 @@ impl Debug for libtmux::PaneProgressState impl Debug for libtmux::PaneSize impl Debug for libtmux::PaneTarget impl Debug for libtmux::PaneWait +impl Debug for libtmux::Principal impl Debug for libtmux::PromptKind impl Debug for libtmux::ReleaseSuffix impl Debug for libtmux::ReleaseVersion @@ -1328,6 +1333,7 @@ impl Eq for libtmux::PaneProgressState impl Eq for libtmux::PaneSize impl Eq for libtmux::PaneTarget impl Eq for libtmux::PaneWait +impl Eq for libtmux::Principal impl Eq for libtmux::PromptKind impl Eq for libtmux::ReleaseSuffix impl Eq for libtmux::ReleaseVersion @@ -1520,6 +1526,7 @@ impl PartialEq for libtmux::PaneProgressState impl PartialEq for libtmux::PaneSize impl PartialEq for libtmux::PaneTarget impl PartialEq for libtmux::PaneWait +impl PartialEq for libtmux::Principal impl PartialEq for libtmux::PromptKind impl PartialEq for libtmux::ReleaseSuffix impl PartialEq for libtmux::ReleaseVersion @@ -1917,6 +1924,7 @@ struct_field libtmux::Error::SupervisorLost::request_id: u64 struct_field libtmux::Error::Timeout::command: libtmux::CommandSummary struct_field libtmux::Error::Timeout::request_id: u64 struct_field libtmux::Error::Timeout::timeout: std::time::Duration +struct_field libtmux::Error::UnreadableAccessRule::marker: String struct_field libtmux::Error::UnreadableFormatValue::detail: libtmux::IdParseError struct_field libtmux::Error::UnreadableFormatValue::format: &'static str struct_field libtmux::Error::UnsupportedCapability::capability: &'static str @@ -2180,6 +2188,7 @@ variant libtmux::Error::SessionExists variant libtmux::Error::Spawn variant libtmux::Error::SupervisorLost variant libtmux::Error::Timeout +variant libtmux::Error::UnreadableAccessRule variant libtmux::Error::UnreadableFormatValue variant libtmux::Error::UnsupportedCapability variant libtmux::Error::UnsupportedTmuxVersion @@ -2240,6 +2249,8 @@ variant libtmux::PaneTarget::Id variant libtmux::PaneWait::Arrived variant libtmux::PaneWait::Dead variant libtmux::PaneWait::TimedOut +variant libtmux::Principal::Group +variant libtmux::Principal::User variant libtmux::PromptKind::Command variant libtmux::PromptKind::Search variant libtmux::PromptKind::Target diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index d059dd8f..a5bd8db6 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -926,6 +926,21 @@ pub enum Error { /// Payload-free decoding metadata. detail: ListingDecodeError, }, + + /// A `server-access -l` line matched neither grammar tmux is known to + /// print. + /// + /// [`crate::Server::access_rules`] decodes the legacy `name (R)`/`name + /// (W)` grammar and the compound `name (U,R)`/`name (G,W)` grammar a + /// release with group ACLs uses, by inspecting each line rather than the + /// detected tmux version. This is the third case: a line this crate + /// cannot place in either grammar, reported rather than dropped. + #[non_exhaustive] + #[error("server-access -l printed an entry this crate does not recognize: {marker}")] + UnreadableAccessRule { + /// The unrecognized trailing marker, without the name it followed. + marker: String, + }, } /// The kind of tmux object a failure refers to. @@ -1139,6 +1154,12 @@ impl Error { Self::InvalidVersionOutput { output_len } } + pub(crate) fn unreadable_access_rule(marker: &str) -> Self { + Self::UnreadableAccessRule { + marker: marker.to_owned(), + } + } + pub(crate) fn unsupported_tmux_version(found: TmuxVersion, minimum: ReleaseVersion) -> Self { Self::UnsupportedTmuxVersion { found, minimum } } @@ -1541,6 +1562,10 @@ impl fmt::Debug for Error { .field("list_command", list_command) .field("detail", detail) .finish(), + Self::UnreadableAccessRule { marker } => formatter + .debug_struct("UnreadableAccessRule") + .field("marker", marker) + .finish(), } } } diff --git a/crates/libtmux/src/error/classification.rs b/crates/libtmux/src/error/classification.rs index e1acdb73..38f9fd1f 100644 --- a/crates/libtmux/src/error/classification.rs +++ b/crates/libtmux/src/error/classification.rs @@ -105,7 +105,8 @@ impl Error { | Self::SupervisorLost { .. } => ErrorKind::Transport, Self::InvalidVersionOutput { .. } | Self::DecodeListing { .. } - | Self::UnreadableFormatValue { .. } => ErrorKind::Decode, + | Self::UnreadableFormatValue { .. } + | Self::UnreadableAccessRule { .. } => ErrorKind::Decode, #[cfg(feature = "control-mode")] Self::ControlModeFrameTooLarge { .. } => ErrorKind::Decode, #[cfg(feature = "control-mode")] @@ -195,7 +196,8 @@ impl Error { .. } | Self::CommandFailed { .. } - | Self::DecodeListing { .. } => false, + | Self::DecodeListing { .. } + | Self::UnreadableAccessRule { .. } => false, #[cfg(feature = "plan")] Self::InvalidPlan { .. } => false, #[cfg(feature = "control-mode")] diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 2ff8c049..7ad088b8 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -345,7 +345,7 @@ pub use options::{ }; pub use pane::{CaptureOptions, CapturedLine, Pane, PaneWait}; pub use server::{ - AccessMode, AccessRule, ChannelWait, Chooser, NewSessionOptions, PromptKind, Server, + AccessMode, AccessRule, ChannelWait, Chooser, NewSessionOptions, Principal, PromptKind, Server, ServerBuilder, SessionTree, WindowTree, }; #[cfg(feature = "query")] diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 7310d5ce..a6d10ee0 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -64,6 +64,22 @@ pub enum AccessMode { Write, } +/// Whether an [`AccessRule`] names an operating-system user or group. +/// +/// tmux originally listed only users. A later release let the server owner +/// add a whole group to the access list, and marks each listed entry `U` or +/// `G` so a caller can tell them apart. A release before that mark existed +/// prints a bare `name (R)`/`name (W)` line with no marker at all -- every row +/// such a release can print names a user, so decoding one reports +/// [`Principal::User`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Principal { + /// The entry names an operating-system user. + User, + /// The entry names an operating-system group. + Group, +} + /// One entry of the server's access list. /// /// # Examples @@ -74,7 +90,7 @@ pub enum AccessMode { /// /// for rule in server.access_rules().await? { /// if rule.mode() == AccessMode::Write { -/// println!("{} can type", rule.user()); +/// println!("{} can type", rule.name()); /// } /// } /// # Ok(()) @@ -82,24 +98,71 @@ pub enum AccessMode { /// ``` #[derive(Clone, Debug, Eq, PartialEq)] pub struct AccessRule { - user: String, + name: String, + principal: Principal, mode: AccessMode, } impl AccessRule { - /// The user this entry names. + /// The user or group this entry names. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Whether [`Self::name`] is an operating-system user or group. #[must_use] - pub fn user(&self) -> &str { - &self.user + pub const fn principal(&self) -> Principal { + self.principal } - /// What that user may do. + /// What that principal may do. #[must_use] pub const fn mode(&self) -> AccessMode { self.mode } } +/// Decode one `server-access -l` line into an [`AccessRule`]. +/// +/// tmux prints either the legacy `name (R)`/`name (W)` (no principal marker, +/// every row a user) or the compound `name (U,R)`/`name (G,W)` grammar a +/// release with group ACLs uses for every row, owner included. Deciding which +/// grammar applies from the line itself, rather than from the detected tmux +/// version, is what lets this read a listing from either release without a +/// version predicate. +/// +/// # Errors +/// +/// Returns [`Error::UnreadableAccessRule`] when the trailing marker matches +/// neither grammar, naming the marker rather than silently dropping the row. +fn parse_access_rule(line: &str) -> Result { + let (name, marker) = line + .rsplit_once(' ') + .ok_or_else(|| Error::unreadable_access_rule(line))?; + let inner = marker + .strip_prefix('(') + .and_then(|marker| marker.strip_suffix(')')) + .ok_or_else(|| Error::unreadable_access_rule(marker))?; + let (principal, mode) = match inner.split_once(',') { + Some(("U", mode)) => (Principal::User, mode), + Some(("G", mode)) => (Principal::Group, mode), + Some(_) => return Err(Error::unreadable_access_rule(marker)), + // No comma: the legacy grammar, which lists only users. + None => (Principal::User, inner), + }; + let mode = match mode { + "R" => AccessMode::ReadOnly, + "W" => AccessMode::Write, + _ => return Err(Error::unreadable_access_rule(marker)), + }; + Ok(AccessRule { + name: name.to_owned(), + principal, + mode, + }) +} + /// The first tmux release that remembers prompt history. use crate::version::since::PROMPT_HISTORY as PROMPT_HISTORY_SINCE; @@ -1167,24 +1230,11 @@ impl Server { return Err(Error::from_refused_result("server-access", &result, None)); } - // tmux writes `name (R)` or `name (W)`, one per line. Split from the - // right because the flag is fixed width and a name is not. - Ok(result + result .stdout_lossy() .lines() - .filter_map(|line| { - let (user, flag) = line.rsplit_once(' ')?; - let mode = match flag { - "(R)" => AccessMode::ReadOnly, - "(W)" => AccessMode::Write, - _ => return None, - }; - Some(AccessRule { - user: user.to_owned(), - mode, - }) - }) - .collect()) + .map(parse_access_rule) + .collect() } /// Let a user attach to this server. @@ -1491,7 +1541,7 @@ mod tests { use tokio::sync::{Notify, watch}; - use super::{NewSessionOptions, Server}; + use super::{AccessMode, NewSessionOptions, Principal, Server, parse_access_rule}; use crate::command::{CommandRequest, CommandResult, ProcessStatus}; use crate::formats::{DecoderKind, FormatDescriptor, FormatPlan, ListProfile}; use crate::internal::executor::{DispatchFuture, Executor, ShutdownFuture}; @@ -1773,6 +1823,59 @@ mod tests { assert_eq!(summary.sensitive_argument_count(), 1); assert!(!summary.to_string().contains(secret)); } + + #[test] + fn access_rule_lines_decode_both_the_legacy_and_the_group_acl_grammar() { + // A release before group ACLs prints no principal marker at all, and + // every row it can print names a user. + let legacy_write = parse_access_rule("alice (W)").expect("legacy write row parses"); + assert_eq!(legacy_write.name(), "alice"); + assert_eq!(legacy_write.principal(), Principal::User); + assert_eq!(legacy_write.mode(), AccessMode::Write); + + let legacy_read = parse_access_rule("bob (R)").expect("legacy read-only row parses"); + assert_eq!(legacy_read.principal(), Principal::User); + assert_eq!(legacy_read.mode(), AccessMode::ReadOnly); + + // A release with group ACLs marks every row, owner included, so the + // decoder must not assume the legacy shape just because a row is + // read-write. + let user_row = parse_access_rule("carol (U,W)").expect("compound user row parses"); + assert_eq!(user_row.name(), "carol"); + assert_eq!(user_row.principal(), Principal::User); + assert_eq!(user_row.mode(), AccessMode::Write); + + let group_row = parse_access_rule("admins (G,R)").expect("compound group row parses"); + assert_eq!(group_row.name(), "admins"); + assert_eq!(group_row.principal(), Principal::Group); + assert_eq!(group_row.mode(), AccessMode::ReadOnly); + } + + /// An unrecognized `server-access -l` marker is reported through + /// `Error::UnreadableAccessRule` rather than silently dropped: a + /// listing with only such a row used to come back empty. + #[test] + fn an_unrecognized_access_rule_marker_is_reported_not_dropped() { + let bad_principal = parse_access_rule("mallory (X,W)") + .expect_err("an unknown principal letter is rejected"); + assert!( + matches!(&bad_principal, Error::UnreadableAccessRule { marker } if marker == "(X,W)"), + "the refusal names the marker rather than silently dropping the row: \ + {bad_principal:?}", + ); + assert_eq!(bad_principal.kind(), ErrorKind::Decode); + + let bad_mode = + parse_access_rule("mallory (U,X)").expect_err("an unknown mode letter is rejected"); + assert!(matches!( + &bad_mode, + Error::UnreadableAccessRule { marker } if marker == "(U,X)" + )); + + let no_marker = + parse_access_rule("mallory").expect_err("a line with no trailing marker is rejected"); + assert!(matches!(no_marker, Error::UnreadableAccessRule { .. })); + } } /// Options for creating a session. diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index 423678d2..557105c8 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -1674,11 +1674,12 @@ async fn the_server_access_list_names_its_owner_and_refuses_to_unseat_them() { let owner = rules.first().expect("the owner is listed"); assert_eq!(rules.len(), 1); assert_eq!(owner.mode(), libtmux::AccessMode::Write); - assert!(!owner.user().is_empty()); + assert_eq!(owner.principal(), libtmux::Principal::User); + assert!(!owner.name().is_empty()); // tmux refuses to change the owner's own entry, so a caller cannot lock // itself out of the server it just started. - let user = owner.user().to_owned(); + let user = owner.name().to_owned(); for attempt in [ server .grant_access(&user, libtmux::AccessMode::ReadOnly) From 87b3d0936c4a07a8cb9765bb201f33190d1937cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:20:13 -0500 Subject: [PATCH 016/117] Server(fix[docs]): Give Principal a runnable example why: `just example-coverage-check` (CI's "public API surface" job) requires every crate-root type to carry a runnable example, and Principal shipped in the previous commit with prose only. CI on "Server(fix[access]): Decode server-access group ACL markers" caught it: `just example-coverage-check` failed while `just api-check` passed. what: - Add a doctest to `Principal` matching both variants through `AccessRule::principal`. - Verified locally: crate-root types with a runnable example: 77/77 (100%), up from 76/77. --- crates/libtmux/src/server.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index a6d10ee0..687db148 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -72,6 +72,22 @@ pub enum AccessMode { /// prints a bare `name (R)`/`name (W)` line with no marker at all -- every row /// such a release can print names a user, so decoding one reports /// [`Principal::User`]. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { +/// use libtmux::Principal; +/// +/// for rule in server.access_rules().await? { +/// match rule.principal() { +/// Principal::User => println!("{} (user)", rule.name()), +/// Principal::Group => println!("{} (group)", rule.name()), +/// } +/// } +/// # Ok(()) +/// # } +/// ``` #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Principal { /// The entry names an operating-system user. From 5b9c318f7a2ee95c9288569dffc2f3cedd6541a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:24:32 -0500 Subject: [PATCH 017/117] Tests(fix[version]): Predict require-gated branches with require's rule why: Four tests gate on the same capabilities Server::require refuses with, but predicted the branch using TmuxVersion::meets instead -- a rule meets's own doc comment says clamps every development identifier ("master" and "next-X.Y" alike) to "no more than the crate's minimum supported release", refusing any requirement above that floor regardless of the build's own next-release number. require, through the private behavior_release, reads next-X.Y as the real release and takes a bare master at its word. Verified the divergence is not hypothetical: reverting one test to meets and running it against this workspace's own next-3.9 tmux-matrix probe binary fails it outright, panicking on the "older tmux" branch while the real dispatch had already succeeded. what: - Add TmuxVersion::has_behavior, gated behind test-support: the boolean form of the rule require refuses with, factored into a shared behavior_satisfies so require and has_behavior cannot drift apart the way two independently written rules could. - Switch dispatch_only_commands_are_accepted, a_suspended_client_is_not_reported_gone, trimming_blank_cells_is_refused_below_the_release_that_has_it, and real_tmux_compat_capture_line_flags_mark_prompts_when_the_shell_emits_them from meets to has_behavior. The fourth was not named in the original finding but has the identical shape (gated by Server::require through CAPTURE_LINE_FLAGS) and would have reappeared as "a fourth" had it been left. - Add has_behavior_and_meets_disagree_above_the_supported_floor, pinning the general rule (any development identifier, any requirement above the floor) rather than only the master case, and naming next-3.9 -- the probe binary's actual version string -- directly. - Ran the full set against both the default tmux 3.7d and the next-3.9 probe; regenerated public-api.txt. --- crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/version.rs | 40 +++++++++++++++++++++--- crates/libtmux/tests/commands.rs | 8 ++--- crates/libtmux/tests/version.rs | 50 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index c7b5e41a..19a2d166 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -652,6 +652,7 @@ function libtmux::TmuxText::from_bytes: fn(bytes: impl Into>) -> Self function libtmux::TmuxText::parse: fn(&self) -> Option function libtmux::TmuxText::to_string_lossy: fn(&self) -> Cow<'_, str> function libtmux::TmuxVersion::ensure_supported: fn(&self) -> Result<(), libtmux::Error> +function libtmux::TmuxVersion::has_behavior: fn(&self, needs: &libtmux::ReleaseVersion) -> bool function libtmux::TmuxVersion::is_development: const fn(&self) -> bool function libtmux::TmuxVersion::meets: fn(&self, required: &libtmux::ReleaseVersion) -> bool function libtmux::TmuxVersion::parse_output: fn(output: &[u8]) -> Result diff --git a/crates/libtmux/src/version.rs b/crates/libtmux/src/version.rs index 5435d584..983a09d0 100644 --- a/crates/libtmux/src/version.rs +++ b/crates/libtmux/src/version.rs @@ -450,6 +450,41 @@ impl TmuxVersion { } } + /// Report whether this version's tree carries a capability's behavior. + /// + /// The shared rule behind `Self::require`: a development identifier + /// asks its `behavior_release`, never clamped to the crate's minimum + /// supported release. Both this and `require` call it, so they cannot + /// drift apart the way two independently written rules could. + fn behavior_satisfies(&self, needs: ReleaseVersion) -> bool { + self.behavior_release() + .is_none_or(|release| release >= needs) + } + + /// Report whether this version's tree carries a capability's behavior. + /// + /// This is `Self::require`'s refusal rule in boolean form, reachable so + /// a test predicting which branch `require` takes calls the same rule + /// `require` does, rather than an independently derived one that happens + /// to agree today. [`Self::meets`] is a different, publicly documented + /// rule: it clamps *every* development identifier -- `next-X.Y` included, + /// not only a bare `master` -- to "no more than the crate's minimum + /// supported release", so it refuses any requirement above that floor + /// regardless of what the build's own next-release number is. This reads + /// `next-X.Y` as the real release `X.Y` instead, so the two disagree for + /// any development build checked against a requirement above the floor: + /// [`crate::since`] holds several, and this crate's own tmux-matrix + /// probe self-reports `next-3.9`. + /// + /// Gated behind `test-support` because it exists for that reachability, + /// not as a second public capability check callers should choose + /// between. + #[cfg(feature = "test-support")] + #[must_use] + pub fn has_behavior(&self, needs: &ReleaseVersion) -> bool { + self.behavior_satisfies(*needs) + } + /// Refuse a capability this release is too old for. /// /// tmux usually accepts an unknown flag and ignores it, so without this @@ -466,10 +501,7 @@ impl TmuxVersion { capability: &'static str, needs: ReleaseVersion, ) -> Result<(), Error> { - if self - .behavior_release() - .is_some_and(|release| release < needs) - { + if !self.behavior_satisfies(needs) { return Err(Error::UnsupportedCapability { capability, needs, diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index 557105c8..ee94fffe 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -1060,7 +1060,7 @@ async fn dispatch_only_commands_are_accepted() { .await .expect("capabilities") .tmux_version() - .meets(&since::PROMPT_HISTORY) + .has_behavior(&since::PROMPT_HISTORY) { cleared.expect("the prompt history is cleared"); } else { @@ -1716,7 +1716,7 @@ async fn real_tmux_compat_capture_line_flags_mark_prompts_when_the_shell_emits_t .await .expect("capabilities") .tmux_version() - .meets(&since::CAPTURE_LINE_FLAGS); + .has_behavior(&since::CAPTURE_LINE_FLAGS); if !supported { // Below 3.7 tmux accepts no `-F`, and saying so beats an empty answer. @@ -1811,7 +1811,7 @@ async fn a_suspended_client_is_not_reported_gone() { .await .expect("capabilities") .tmux_version() - .meets(&since::CLIENTS_HIDE_STOPPED); + .has_behavior(&since::CLIENTS_HIDE_STOPPED); let child = process::Command::new("tmux") .arg("-S") @@ -1971,7 +1971,7 @@ async fn trimming_blank_cells_is_refused_below_the_release_that_has_it() { .await .expect("capabilities") .tmux_version() - .meets(&since::CAPTURE_TRIM_BLANK_CELLS); + .has_behavior(&since::CAPTURE_TRIM_BLANK_CELLS); let asked = pane .capture_with(CaptureOptions::visible().trim_blank_cells()) diff --git a/crates/libtmux/tests/version.rs b/crates/libtmux/tests/version.rs index 7d2578af..58a919ff 100644 --- a/crates/libtmux/tests/version.rs +++ b/crates/libtmux/tests/version.rs @@ -226,6 +226,56 @@ fn enforces_the_minimum_without_promoting_development_versions() { assert!(minimum.ensure_supported().is_ok()); } +/// `meets` and `has_behavior` are two different, deliberately non-identical +/// rules -- documented at each -- so a test predicting a capability gate must +/// call `has_behavior`, the one [`TmuxVersion::require`] actually refuses +/// with. +/// +/// `meets`'s own doc comment says a development identifier "meets only +/// requirements at or below the crate's minimum supported release; they are +/// not promoted to an invented release" -- and that clamp fires for *every* +/// development identifier, `next-X.Y` included, not only a bare `master`. +/// `has_behavior` instead reads `next-X.Y` as the real release `X.Y`, and a +/// bare `master` (no release parses from it at all) as "not yet known to +/// lack this". So `next-3.9` -- the version string this workspace's own +/// tmux-matrix probe binary reports -- disagrees with `meets` for exactly +/// the capabilities this crate gates above the floor: `meets` refuses every +/// one of them, `has_behavior` grants whichever `next-3.9` numerically +/// contains. This is the rule three tests predicted a `require`-gated branch +/// with `meets` and, unnoticed, always took the "unsupported" branch against +/// that probe. +#[cfg(feature = "test-support")] +#[test] +fn has_behavior_and_meets_disagree_above_the_supported_floor() { + let master = TmuxVersion::parse_output(b"tmux master\n").unwrap(); + let next_3_9 = TmuxVersion::parse_output(b"tmux next-3.9\n").unwrap(); + let next_3_2 = TmuxVersion::parse_output(b"tmux next-3.2\n").unwrap(); + let numbered = TmuxVersion::parse_output(b"tmux 3.7b\n").unwrap(); + let prompt_history = ReleaseVersion::new(3, 3, ReleaseSuffix::FINAL); + + // `meets` clamps every development identifier to "no more than the + // floor", so it refuses a capability above 3.2a regardless of what the + // build's own next-release number is. + assert!(!master.meets(&prompt_history)); + assert!(!next_3_9.meets(&prompt_history)); + assert!(!next_3_2.meets(&prompt_history)); + + // `has_behavior` computes the real answer instead: `next-3.9` already + // contains 3.3's behavior, `next-3.2` does not yet, and a bare `master` + // is taken at its word rather than assumed to lack it. + assert!(master.has_behavior(&prompt_history)); + assert!(next_3_9.has_behavior(&prompt_history)); + assert!(!next_3_2.has_behavior(&prompt_history)); + + // A numbered release is the one shape where the two rules cannot + // disagree: both reduce to the same `release >= required` comparison. + assert_eq!( + numbered.meets(&prompt_history), + numbered.has_behavior(&prompt_history) + ); + assert!(numbered.has_behavior(&prompt_history)); +} + #[test] fn release_value_getters_preserve_components() { let suffix = ReleaseSuffix::patch('c').expect("c is a lowercase patch suffix"); From 7ae66d82b6dee1209d22a8ec1783ed0598e52d6e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:28:41 -0500 Subject: [PATCH 018/117] Formats(fix[codec]): Stop pinning next-3.9's wire bytes as a defect why: real_tmux_compat_format_q_matches_versioned_adversarial_option_transport pins the exact stdout bytes tmux prints for the RawQ and Vis dialects. next-3.9 widened #{q:}'s escape set (fixed for decode in "Formats(fix[codec]): Accept next-3.9's widened #{q:} escape set") to also cover a raw newline, and OPTION_BYTES contains one, so next-3.9 now escapes it as `\` + literal newline where every release through 3.7c left it bare. Both are "the RawQ dialect" -- TransportDialect::for_version does not change -- so this is not a dialect switch, it is the escape set moving under a frozen pin. Reproduced: reran the test against the tmux-matrix's master-e880cf63 build before this fix and it failed with exactly that byte difference ([...,0x5c,0x0a,...] vs the pinned [...,0x0a,...]); the same tmux still decodes the value correctly (the assertion this fix keeps unconditional). what: - Pin EXPECTED_RAW_STDOUT/EXPECTED_VIS_STDOUT only when version.release().is_some() -- a numbered release's wire is frozen forever, so a mismatch there is this crate's own regression, not tmux's. A development identifier's wire is not frozen, so only the decode_text(slot) == OPTION_BYTES claim -- the one made to callers -- is asserted for it. - Negative-test proof: reran against 3.7c with one byte of EXPECTED_RAW_STDOUT deliberately wrong -- fails, naming the mismatch, confirming the frozen-release pin still bites. The same corruption against the next-3.9 probe still passes, confirming the pin is skipped rather than vacuous there. Reverted before committing. - Verified against every locally available matrix binary: 3.4 and 3.7c (frozen, pinned) and next-3.9 (development, decode-only). --- crates/libtmux/src/formats/tests.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index b22c05e9..5529f98b 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -1871,22 +1871,30 @@ async fn real_tmux_compat_format_q_matches_versioned_adversarial_option_transpor .await .expect("tmux capabilities are detected") .tmux_version(); + // A numbered release's wire is frozen forever, so its exact bytes are + // worth pinning; a development identifier's is not -- its escape set + // can still widen, so only the decode claim below is asserted for it. + let frozen = version.release().is_some(); match TransportDialect::for_version(version) { TransportDialect::Vis => { - assert_eq!(result.stdout(), EXPECTED_VIS_STDOUT); + if frozen { + assert_eq!(result.stdout(), EXPECTED_VIS_STDOUT); + } // The visual encoding makes the transport valid UTF-8 even // though the underlying value is not. assert!(result.stdout_utf8().is_ok()); } TransportDialect::RawQ => { - assert_eq!(result.stdout(), EXPECTED_RAW_STDOUT); + if frozen { + assert_eq!(result.stdout(), EXPECTED_RAW_STDOUT); + } assert!(result.stdout_utf8().is_err()); } } - // The decoded value is the same on every dialect. This is the claim - // the crate makes to callers, so it is asserted on the live transport - // rather than only on the raw-q lane. + // The decoded value is the same on every dialect and every release, + // frozen or not. This is the claim the crate makes to callers, so it is + // the one assertion this test never conditions away. let versioned = FormatPlan::for_codec_test_at(vec![&RAW_FORMAT_BYTES], version) .ok() .expect("a plan exists for the detected version"); From 71a79e113daabb03bcdbbb129367e35356712ffa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 20:46:05 -0500 Subject: [PATCH 019/117] Pane(fix[pid]): Report a dead pane's pid as None, not a decode error why: tmux 3.8 reports #{pane_pid} as an empty string once a remain-on-exit pane's process has exited. PANE_PID was cataloged Required, so an empty value there was RequiredFieldEmpty: the whole pane listing failed rather than reporting the pane with no pid. Measured on 3.2a, 3.4, 3.6 and 3.7c: all four report the exited process's own (by then possibly reused) pid, never an empty value. tmux 3.8 is not breaking a contract, it is declining to keep reporting a pid that may no longer name that process, so a populated pid was never proof of liveness on any release -- a caller must check Pane::is_dead rather than infer it from pid. tmux-mcp hit this through respawn(Some("exit 0"), true): two tests failed at the identical byte offset, deterministically. libtmux-java fixed the same tmux change at its one call site rather than loosening its shared numeric parser; this does the same, since RequiredFieldEmpty must stay strict for every other numeric field. what: - Change PANE_PID's catalog empty policy from Required to Absent -- the same policy pane_pipe_pid and pane_dead_status already use for a numeric field tmux may not report. Its floor stays V3_2A, so it is never Unsupported or Unproven on any supported or development build, only Available or Absent. - Change Pane::pid from `u32` to `Option`; update its two `> 0` callers in hierarchy.rs and mutations.rs, and document that a populated value is not evidence the process is alive on any release. - Move pane_pid into the u32 evidence group in the stored-field-type compile check, and into parity.md's checked catalog table (required -> absent), updating the partition counts it feeds. - Negative test: snapshot_catalog_empty_policy_distinguishes_all_three_states now covers an empty pane_pid decoding to Absent, a populated one still decoding to Available, and (since the floor is V3_2A) that it is never Unsupported on an old numbered release or Unproven on a development build -- while pane_width in the same test still proves RequiredFieldEmpty is unchanged for a field that is genuinely required. - Added a_dead_panes_pid_is_absent_rather_than_a_decode_failure (mutations.rs): a real respawn(Some("exit 0"), true) fixture, matching tmux-mcp's repro. Branches on whichever pid shape the running tmux actually reports (every release through 3.7c retains the exited process's own pid; 3.8+ clears it) rather than assuming one, so it passes on the default tmux and was verified separately against the next-3.9 tmux-matrix probe binary, where reaching the assertions at all is the regression proof. - Reran tmux-mcp's two originally-failing tests (send_keys_refuses_modal_and_dead_configured_members_before_input, paste_text_is_target_only_and_guards_before_buffer_creation): both pass now without any tmux-mcp change, confirming the fix belongs in libtmux as the finding said. - Regenerated public-api.txt; format-coverage-check confirmed unaffected (no field added or removed). - Audited every production (non-test) read of pane_pid/Pane::pid across libtmux, tmux-mcp and tmux-workspace: none exists, so nothing in this workspace assumed liveness from a populated pid. --- crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 2 +- crates/libtmux/src/formats.rs | 2 +- crates/libtmux/src/formats/tests.rs | 2 +- crates/libtmux/src/pane.rs | 12 ++++- crates/libtmux/src/snapshot/tests.rs | 27 +++++++++- crates/libtmux/tests/hierarchy.rs | 2 +- crates/libtmux/tests/mutations.rs | 78 ++++++++++++++++++++++++---- 8 files changed, 109 insertions(+), 18 deletions(-) diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index c6f5c4f4..37356d1b 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -352,7 +352,7 @@ returns the global `window_client_mode.default_format`. | `pane_path` | pane | pane | all | 3.2a | text | available | pane-info | | `pane_pb_progress` | pane | pane | all | 3.7 | pane-progress | required | pane-info | | `pane_pb_state` | pane | pane | all | 3.7 | pane-progress-state | required | pane-info | -| `pane_pid` | pane | pane | all | 3.2a | u32 | required | pane-info | +| `pane_pid` | pane | pane | all | 3.2a | u32 | absent | pane-info | | `pane_pipe` | pane | pane | all | 3.2a | bool | required | pane-info | | `pane_pipe_pid` | pane | pane | all | 3.7 | u32 | absent | pane-info | | `pane_right` | pane | pane | all | 3.2a | i32 | required | pane-info | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 19a2d166..5bb4acaa 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -431,7 +431,7 @@ function libtmux::Pane::kill: async fn(self) -> Result<(), libtmux::Error> function libtmux::Pane::option_names: async fn(&self) -> Result, libtmux::Error> function libtmux::Pane::options: async fn(&self) -> Result, libtmux::Error> function libtmux::Pane::paste_buffer: async fn(&self, name: Option<&str>) -> Result<(), libtmux::Error> -function libtmux::Pane::pid: fn(&self) -> u32 +function libtmux::Pane::pid: fn(&self) -> Option function libtmux::Pane::pipe: async fn(&self, command: Option>) -> Result<(), libtmux::Error> function libtmux::Pane::refresh: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::refreshed: async fn(&self) -> Result diff --git a/crates/libtmux/src/formats.rs b/crates/libtmux/src/formats.rs index edacabfa..c55a7a16 100644 --- a/crates/libtmux/src/formats.rs +++ b/crates/libtmux/src/formats.rs @@ -362,7 +362,7 @@ macro_rules! format_catalog { (PANE_PATH, pane_path, "pane_path", Pane, Pane, All, V3_2A, Text, Available), (PANE_PB_PROGRESS, pane_pb_progress, "pane_pb_progress", Pane, Pane, All, V3_7, PaneProgress, Required), (PANE_PB_STATE, pane_pb_state, "pane_pb_state", Pane, Pane, All, V3_7, PaneProgressState, Required), - (PANE_PID, pane_pid, "pane_pid", Pane, Pane, All, V3_2A, U32, Required), + (PANE_PID, pane_pid, "pane_pid", Pane, Pane, All, V3_2A, U32, Absent), (PANE_PIPE, pane_pipe, "pane_pipe", Pane, Pane, All, V3_2A, Bool, Required), (PANE_PIPE_PID, pane_pipe_pid, "pane_pipe_pid", Pane, Pane, All, V3_7, U32, Absent), (PANE_RIGHT, pane_right, "pane_right", Pane, Pane, All, V3_2A, I32, Required), diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index 5529f98b..aaca24e8 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -1320,7 +1320,7 @@ fn format_catalog_checked_parity_partitions_are_exact() { ); assert_eq!( count_tokens(rows.iter().map(|row| row.empty)), - std::collections::BTreeMap::from([("absent", 30), ("available", 28), ("required", 121),]) + std::collections::BTreeMap::from([("absent", 31), ("available", 28), ("required", 120),]) ); assert_eq!( count_tokens(rows.iter().map(|row| row.placement)), diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 43ea6601..52d18a08 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -193,9 +193,17 @@ impl Pane { } /// Return the process id of the pane's foreground process. + /// + /// `None` for a pane with no process: tmux 3.8 reports `#{pane_pid}` as + /// an empty string once the pane's process has exited (`remain-on-exit` + /// keeps such a pane instead of closing it). A value is not evidence the + /// process is still alive, on any release: every release before 3.8 + /// keeps reporting the exited process's own pid rather than clearing the + /// field, and a pid can be reused once its process is gone. Check + /// [`Self::is_dead`] for liveness; do not infer it from this. #[must_use] - pub fn pid(&self) -> u32 { - *self.projection.pane().pane_pid() + pub fn pid(&self) -> Option { + self.projection.pane().pane_pid().available().copied() } /// Return the pane width in cells. diff --git a/crates/libtmux/src/snapshot/tests.rs b/crates/libtmux/src/snapshot/tests.rs index 77972726..df6703a7 100644 --- a/crates/libtmux/src/snapshot/tests.rs +++ b/crates/libtmux/src/snapshot/tests.rs @@ -610,13 +610,12 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { pane_height, pane_in_mode, pane_index, - pane_pid, pane_width, scroll_region_lower, scroll_region_upper ] ); - assert_stored_fields!(pane, u32, evidence, [pane_pipe_pid, pane_z]); + assert_stored_fields!(pane, u32, evidence, [pane_pid, pane_pipe_pid, pane_z]); assert_stored_fields!(pane, u64, flat, [history_bytes]); assert_stored_fields!(pane, i64, evidence, [pane_dead_time]); assert_stored_fields!( @@ -1212,6 +1211,30 @@ fn snapshot_catalog_empty_policy_distinguishes_all_three_states() { .expect("empty optional numeric hydrates"); assert_eq!(optional_numeric.pane_pipe_pid, Availability::Absent); + // tmux 3.8 reports `#{pane_pid}` as an empty string once a pane with + // `remain-on-exit` set has no process left; every release before it + // keeps reporting that process's own (by then possibly reused) pid + // rather than clearing the field. Unlike `pane_pipe_pid`, `pane_pid`'s + // floor is the crate's own minimum supported release, so it is never + // `Unsupported` or `Unproven`: every supported and development build can + // only report it present or absent. + let dead_pane_pid = pane_fixture(b"tmux 3.7\n", &[("pane_pid", b"")]) + .ok() + .expect("empty pane_pid hydrates rather than failing RequiredFieldEmpty"); + assert_eq!(dead_pane_pid.pane_pid, Availability::Absent); + + let running_pane_pid = pane_fixture(b"tmux 3.7\n", &[("pane_pid", b"4242")]) + .ok() + .expect("a numeric pane_pid still hydrates"); + assert_eq!(running_pane_pid.pane_pid, Availability::Available(4242)); + + for output in [b"tmux 3.2a\n".as_slice(), b"tmux master\n".as_slice()] { + let never_unsupported = pane_fixture(output, &[("pane_pid", b"")]) + .ok() + .expect("pane_pid hydrates at the floor and on development builds"); + assert_eq!(never_unsupported.pane_pid, Availability::Absent); + } + let optional_text = pane_fixture(b"tmux 3.7\n", &[("pane_mode", b"")]) .ok() .expect("empty optional text hydrates"); diff --git a/crates/libtmux/tests/hierarchy.rs b/crates/libtmux/tests/hierarchy.rs index f3a50a64..5dc69573 100644 --- a/crates/libtmux/tests/hierarchy.rs +++ b/crates/libtmux/tests/hierarchy.rs @@ -213,7 +213,7 @@ async fn panes_report_their_window_and_process_details() { for pane in &panes { assert_eq!(pane.window_id(), windows[0].id()); assert_eq!(pane.window_index(), windows[0].index()); - assert!(pane.pid() > 0); + assert!(pane.pid().expect("a running pane reports a pid") > 0); assert!(pane.width() > 0); assert!(pane.height() > 0); assert!(!pane.is_dead(), "a running pane is not dead"); diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index 6efad7f3..e5b2a554 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -5,7 +5,9 @@ // in-test exemptions, and these files have them. #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] -use libtmux::test::TestServer; +use std::time::Duration; + +use libtmux::test::{TestServer, retry_until}; use libtmux::{Layout, NewSessionOptions, NewWindowOptions}; use libtmux::{SplitDirection, SplitOptions, TmuxText}; @@ -22,7 +24,7 @@ async fn wait_for_prompt(pane: &libtmux::Pane) { { return; } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; + tokio::time::sleep(Duration::from_millis(25)).await; } panic!("the pane never drew a prompt"); } @@ -55,7 +57,7 @@ async fn creating_an_object_returns_it_hydrated_in_one_command() { .await .expect("pane is created"); assert_eq!(pane.window_id(), window.id()); - assert!(pane.pid() > 0); + assert!(pane.pid().expect("a running pane reports a pid") > 0); // The server agrees with what the creating commands reported. assert_eq!(server.sessions().await.expect("sessions").len(), 1); @@ -238,7 +240,7 @@ async fn flag_shaped_names_layouts_and_keys_stay_literal() { .await .expect("a flag-shaped line stays literal"); - libtmux::test::retry_until(std::time::Duration::from_secs(5), async || { + retry_until(Duration::from_secs(5), async || { pane.capture().await.is_ok_and(|lines| { let screen = lines .iter() @@ -438,7 +440,7 @@ async fn pane_input_and_capture_round_trip_through_a_shell() { .expect("keys are sent"); // Wait for the shell to produce the output rather than sleeping. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let deadline = std::time::Instant::now() + Duration::from_secs(5); let captured = loop { let lines = pane.capture().await.expect("capture succeeds"); if lines.iter().any(|line| { @@ -510,7 +512,7 @@ async fn cancelling_a_line_send_cannot_leave_enter_undispatched() { }); assert_eq!( server - .wait_for_channel(accepted, std::time::Duration::from_secs(5)) + .wait_for_channel(accepted, Duration::from_secs(5)) .await .expect("the send can signal"), libtmux::ChannelWait::Signalled, @@ -531,7 +533,7 @@ async fn cancelling_a_line_send_cannot_leave_enter_undispatched() { assert_eq!( server - .wait_for_channel(ran, std::time::Duration::from_secs(1)) + .wait_for_channel(ran, Duration::from_secs(1)) .await .expect("the command signal can be read"), libtmux::ChannelWait::Signalled, @@ -561,7 +563,7 @@ async fn a_line_send_preserves_adversarial_literal_text() { .await .expect("the reader is started"); assert_eq!( - pane.wait_for_text("reader-ready", std::time::Duration::from_secs(5)) + pane.wait_for_text("reader-ready", Duration::from_secs(5)) .await .expect("the reader can be watched"), libtmux::PaneWait::Arrived, @@ -575,7 +577,7 @@ async fn a_line_send_preserves_adversarial_literal_text() { pane.send_line(payload).await.expect("the line is sent"); let expected = format!("got:<{payload}>"); assert_eq!( - pane.wait_for_text(&expected, std::time::Duration::from_secs(5)) + pane.wait_for_text(&expected, Duration::from_secs(5)) .await .expect("the reader can be watched"), libtmux::PaneWait::Arrived, @@ -1516,6 +1518,64 @@ async fn respawning_and_locking_reach_every_level_tmux_offers() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A pane `remain-on-exit` keeps after its process ends still decodes. +/// +/// tmux 3.8 reports `#{pane_pid}` as an empty string once a pane's process +/// is gone; every release before it keeps reporting that process's own pid +/// rather than clearing the field. Before this fix, an empty value there was +/// `RequiredFieldEmpty`, which failed the whole listing outright rather than +/// reporting an absent pid -- reproduced against the real daemon rather than +/// a synthetic fixture, because this is the same shape `tmux-mcp`'s dead-pane +/// tests hit: `respawn(Some("exit 0"), true)` leaves a pane whose listing +/// used to fail this way. Which of the two shapes the running tmux actually +/// reports is asserted rather than assumed, so this passes identically on +/// every release from 3.2a through the `next-3.9` tmux-matrix probe. +#[tokio::test] +async fn a_dead_panes_pid_is_absent_rather_than_a_decode_failure() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("dead-pid").await.expect("session"); + let window = session + .active_window() + .await + .expect("windows") + .expect("a window"); + let mut pane = window.active_pane().await.expect("panes").expect("a pane"); + + let running_pid = pane.pid().expect("a running pane reports a pid"); + assert!(running_pid > 0); + + pane.set_option("remain-on-exit", "on") + .await + .expect("the dead pane is kept rather than closed"); + pane.respawn(Some("exit 0"), true) + .await + .expect("the command runs and exits"); + + retry_until(Duration::from_secs(5), async || { + pane.refreshed() + .await + .is_ok_and(|refreshed| refreshed.is_dead()) + }) + .await + .expect("the pane becomes dead"); + // The listing this refresh runs is exactly where `RequiredFieldEmpty` + // used to surface: reaching the assertions below at all is most of what + // this test proves. + pane.refresh().await.expect("the pane refreshes once dead"); + + assert!(pane.is_dead(), "the pane is kept, not closed"); + // Every release through 3.7c retains the exited process's own pid, so + // this arm runs there. tmux 3.8 and later clears it instead, and the + // `None` that leaves unchecked -- reaching it at all, rather than the + // `refresh` above returning `Err(RequiredFieldEmpty)`, is the fix. + if let Some(pid) = pane.pid() { + assert!(pid > 0, "a reported pid is never zero"); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// Renumber a session, leaving every index after the hole naming a different /// window than the handles cached. async fn renumber_after_dropping( From 85fbce8bcd04714e409643092aec697db5024283 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 21:01:51 -0500 Subject: [PATCH 020/117] MCP(add[wait]): Cover wait_for_text's non-Closed shutdown branch why: wait_for_text tolerates only ControlModeErrorKind::Closed from output.shutdown(); every other shutdown error is real and discards the WaitView being built. No test reached that branch, because Pane::stream_output() always attaches with ControlLimits::default(), and tmux-mcp had no way to inject a smaller budget. Exposing the budget as an MCP tool argument would leak a protocol-tuning detail into the tool's surface, so this makes limits injectable as real library API instead -- ControlMode::attach_with_limits already exists for the same reason -- rather than a test-only seam that cannot cross the crate boundary. The first version of the test sent one adversarial line a fixed 200ms after attaching, which flaked under load: dropping the sleep entirely produced the ordinary Closed outcome in 2 of 8 runs instead of the frame-budget error, a root cause not established at the time. A sleep that hides an unexplained failure is a flake vector, not a fix for one, so the test instead attaches synchronously first (pane.stream_output_with_limits), sends the adversarial line only once that call has returned, then calls the read loop directly -- no tokio::spawn, no sleep, no race. Attach is provably complete before the flood starts, so the only reachable path is the already-verified one: a post-attach oversized %output line -> ControlModeFrameTooLarge. The Closed-instead-of-FrameTooLarge sharp edge itself is reported, not chased further here. what: - Add Pane::stream_output_with_limits, the same default/explicit pair ControlMode::attach/attach_with_limits already is; stream_output now delegates to it with ControlLimits::default(). - Add exec::wait_for_text_with_limits in tmux-mcp, pub(crate); the one MCP tool call site keeps calling the unchanged wait_for_text. - Split wait_for_text_with_limits at the attach point: it now attaches and delegates to a new wait_on_output, which runs the read loop, present-at-entry check, and shutdown. wait_for_text is unaffected. - Add wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it: attach synchronously, send the adversarial line only once attach has returned, then call wait_on_output directly. Asserts the resulting ControlModeFrameTooLarge propagates rather than being read as an ordinary close. - Negative-test proof: widened the budget to a value the adversarial line cannot exceed and reran -- the wait matches the pattern instead of erroring, confirming the assertion exercises the budget rather than any error. Reverted before committing. Verified: 20 of 20 isolated runs and 3 of 3 full tmux-mcp --lib suite runs (143 tests) pass with no sleep at all. Also reran the existing wait_and_cursor_tools_observe_live_output integration test, which exercises wait_for_text (unsplit path) through the real MCP tool, to confirm the split changed nothing observable there. - Regenerated public-api.txt. --- crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/pane/observe.rs | 26 +++++++++-- crates/tmux-mcp/src/exec.rs | 45 +++++++++++++++++- crates/tmux-mcp/src/exec/tests.rs | 73 ++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 5bb4acaa..abe506c6 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -449,6 +449,7 @@ function libtmux::Pane::set_option: async fn(&self, name: &str, value: impl Into function libtmux::Pane::set_title: async fn(&mut self, title: impl Into) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::split: async fn(&self, options: impl Into) -> Result function libtmux::Pane::stream_output: async fn(&self) -> Result +function libtmux::Pane::stream_output_with_limits: async fn(&self, limits: libtmux::ControlLimits) -> Result function libtmux::Pane::swap_with: async fn(&mut self, other: &Self) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::title: fn(&self) -> &libtmux::TmuxText function libtmux::Pane::toggle_zoom: async fn(&mut self) -> Result<&mut Self, libtmux::Error> diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index 96f1c8b6..36afc10e 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -54,6 +54,25 @@ impl Pane { /// ``` #[cfg(feature = "control-mode")] pub async fn stream_output(&self) -> Result { + self.stream_output_with_limits(crate::ControlLimits::default()) + .await + } + + /// Like [`Self::stream_output`], with explicit frame budgets. + /// + /// The connection this opens is a whole `%begin`/`%end`-framed + /// control-mode session, not only this one pane's bytes, so a budget set + /// here bounds every frame that connection reads -- the same tradeoff + /// [`crate::control::ControlMode::attach_with_limits`] documents. + /// + /// # Errors + /// + /// Returns the same errors as [`Self::stream_output`]. + #[cfg(feature = "control-mode")] + pub async fn stream_output_with_limits( + &self, + limits: crate::ControlLimits, + ) -> Result { // tmux reports a pane only to a client attached to a session that // links its window, so attaching through this handle's cached session // would deliver silence after the pane was joined elsewhere. @@ -62,9 +81,10 @@ impl Pane { id: self.id().to_string(), })?; let server = crate::Server::from_core(Arc::clone(&self.core)); - let (sender, events) = crate::control::ControlMode::attach(&server, window.session_id()) - .await? - .split(); + let (sender, events) = + crate::control::ControlMode::attach_with_limits(&server, window.session_id(), limits) + .await? + .split(); // One session can hold many panes, and the connection carries all of // them, so narrowing happens before the caller reads. diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index ffbda261..9e303aee 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -9,7 +9,7 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; -use libtmux::{CaptureOptions, ControlModeErrorKind, Error, Pane}; +use libtmux::{CaptureOptions, ControlLimits, ControlModeErrorKind, Error, Pane}; use regex::bytes::Regex; use serde::Serialize; @@ -219,11 +219,52 @@ pub(crate) async fn wait_for_text( stops: &Patterns, timeout: Duration, cancelled: &CancellationToken, +) -> Result { + wait_for_text_with_limits( + pane, + patterns, + stops, + timeout, + cancelled, + ControlLimits::default(), + ) + .await +} + +/// Like [`wait_for_text`], with explicit control-mode frame budgets. +/// +/// Every real caller wants [`wait_for_text`]'s default: this exists so a test +/// can shrink the budget enough to force the frame-too-large shutdown error +/// [`wait_for_text`] propagates instead of tolerating -- a branch no MCP +/// tool argument can reach, since exposing a protocol-tuning knob to a +/// caller of the tool would leak an implementation detail into its surface. +/// +/// Split from [`wait_on_output`] at the attach point so a test driving a +/// tiny budget can send its adversarial output only once attaching has +/// provably finished, rather than racing a fixed delay against it. +pub(crate) async fn wait_for_text_with_limits( + pane: &Pane, + patterns: &Patterns, + stops: &Patterns, + timeout: Duration, + cancelled: &CancellationToken, + limits: ControlLimits, ) -> Result { // Attached first: a pattern that arrives while the screen is being read // must still be seen. - let mut output = pane.stream_output().await?; + let output = pane.stream_output_with_limits(limits).await?; + wait_on_output(pane, output, patterns, stops, timeout, cancelled).await +} +/// The read loop [`wait_for_text_with_limits`] runs once attached. +async fn wait_on_output( + pane: &Pane, + mut output: libtmux::control::PaneOutput, + patterns: &Patterns, + stops: &Patterns, + timeout: Duration, + cancelled: &CancellationToken, +) -> Result { // What is already on screen will never match, because a stream only // carries what comes next. Saying so is cheaper than a wasted deadline. let present_at_entry = match pane.capture_with(CaptureOptions::visible()).await { diff --git a/crates/tmux-mcp/src/exec/tests.rs b/crates/tmux-mcp/src/exec/tests.rs index 7a440509..d745bbc0 100644 --- a/crates/tmux-mcp/src/exec/tests.rs +++ b/crates/tmux-mcp/src/exec/tests.rs @@ -933,3 +933,76 @@ async fn a_staged_frame_removes_itself_and_refuses_an_occupied_path() { std::fs::remove_file(&path).expect("the frame is removed"); } + +/// A non-`Closed` shutdown error propagates rather than being tolerated. +/// +/// `wait_for_text` tolerates only `ControlModeErrorKind::Closed` from +/// `output.shutdown()`; every other shutdown error is real and must discard +/// the view being built. Nothing in the ordinary tool path can reach that +/// branch, since [`crate::exec::wait_for_text`] always attaches with +/// [`libtmux::ControlLimits::default`], which no ordinary pane output +/// exceeds -- this is why `wait_for_text_with_limits` exists. +/// +/// Attaches before sending the adversarial line, rather than sending it a +/// fixed delay after spawning a concurrent wait: attach-then-consume are +/// split at `wait_on_output` for exactly this, so this test can prove attach +/// is complete before the flood starts instead of racing it. A version that +/// flooded the pane concurrently with attaching, with no synchronization, +/// observed the ordinary `Closed` outcome in 2 of 8 runs instead of the +/// frame-budget error -- an unexplained sharp edge worth its own look, not +/// masked with a delay here. +#[tokio::test] +async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() { + use libtmux::ControlLimits; + use libtmux::test::TestServer; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("wait-frame-budget") + .await + .expect("session starts"); + let pane = session.panes().await.expect("panes list").remove(0); + + // Comfortably above the connection's own opening handshake and the + // narrowing command's reply, and comfortably below the one long line the + // pane is made to print once attached. + let tiny = ControlLimits::default().max_line_bytes(256); + let output = pane + .stream_output_with_limits(tiny) + .await + .expect("attaching on a quiet pane succeeds"); + + // Sent only now that attach and narrow have provably finished: the very + // next line tmux reports on this connection already exceeds the budget. + pane.send_line("printf '%s\\n' \"$(head -c 4096 /dev/zero | tr '\\0' A)\"") + .await + .expect("the adversarial line is sent"); + + // A run of 10 `A`s: absent from the echoed command line above, which + // types the letter only in isolation, so this never matches the echo. + // It also never arrives as a delivered chunk: tmux's own protocol line + // carrying it is what exceeds the budget, so the connection dies before + // that line becomes an `Event::Output` this stream could read. + let patterns = + Patterns::compile(&["AAAAAAAAAA".to_owned()], false, false).expect("pattern compiles"); + let stops = Patterns::compile(&[], false, false).expect("empty stop patterns compile"); + let cancelled = CancellationToken::new(); + + let error = wait_on_output( + &pane, + output, + &patterns, + &stops, + Duration::from_secs(5), + &cancelled, + ) + .await + .expect_err("a too-small frame budget is a real shutdown error, not a tolerated Closed"); + assert!( + matches!(error, Error::ControlModeFrameTooLarge { .. }), + "the frame-budget error surfaces rather than being swallowed: {error:?}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From 86c26a65d1b01b2bd2b7d4cc7897e2d6ecd537ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 21:36:08 -0500 Subject: [PATCH 021/117] Tests(fix[docs]): Drop two hard-coded counts from comments why: no counts in code comments -- they go in the commit message, where they are timestamped against the exact diff instead of drifting from it. Two comments violated this, one of them also wrong: version.rs said "three tests" reproduced the meets/ has_behavior divergence, but "Tests(fix[version]): Predict require-gated branches with require's rule" fixed four. Separately, exec/tests.rs's frame-budget test doc kept the "2 of 8 runs" measurement and the deliberation that led to the current design, both of which belong in "MCP(add[wait]): Cover wait_for_text's non-Closed shutdown branch"'s message, which already has them. what: - version.rs: name what the rule was reproducing (the commands.rs tests) instead of counting them. - exec/tests.rs: cut the measurement and the design deliberation, keeping only the constraint the comment needs: attach happens before the flood is sent. - Reran both edited tests; unaffected. just doc-blocks, parity-claims, fixture-root and example-tables all report clean. --- crates/libtmux/tests/version.rs | 6 +++--- crates/tmux-mcp/src/exec/tests.rs | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/libtmux/tests/version.rs b/crates/libtmux/tests/version.rs index 58a919ff..c6027dc8 100644 --- a/crates/libtmux/tests/version.rs +++ b/crates/libtmux/tests/version.rs @@ -241,9 +241,9 @@ fn enforces_the_minimum_without_promoting_development_versions() { /// tmux-matrix probe binary reports -- disagrees with `meets` for exactly /// the capabilities this crate gates above the floor: `meets` refuses every /// one of them, `has_behavior` grants whichever `next-3.9` numerically -/// contains. This is the rule three tests predicted a `require`-gated branch -/// with `meets` and, unnoticed, always took the "unsupported" branch against -/// that probe. +/// contains. This is the rule the `commands.rs` tests that predicted a +/// `require`-gated branch with `meets` were reproducing, unnoticed, always +/// taking the "unsupported" branch against that probe. #[cfg(feature = "test-support")] #[test] fn has_behavior_and_meets_disagree_above_the_supported_floor() { diff --git a/crates/tmux-mcp/src/exec/tests.rs b/crates/tmux-mcp/src/exec/tests.rs index d745bbc0..e9d7be50 100644 --- a/crates/tmux-mcp/src/exec/tests.rs +++ b/crates/tmux-mcp/src/exec/tests.rs @@ -946,11 +946,7 @@ async fn a_staged_frame_removes_itself_and_refuses_an_occupied_path() { /// Attaches before sending the adversarial line, rather than sending it a /// fixed delay after spawning a concurrent wait: attach-then-consume are /// split at `wait_on_output` for exactly this, so this test can prove attach -/// is complete before the flood starts instead of racing it. A version that -/// flooded the pane concurrently with attaching, with no synchronization, -/// observed the ordinary `Closed` outcome in 2 of 8 runs instead of the -/// frame-budget error -- an unexplained sharp edge worth its own look, not -/// masked with a delay here. +/// is complete before the flood starts instead of racing it. #[tokio::test] async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() { use libtmux::ControlLimits; From 384ed3283461213ca95aef7c709fb82f7d849033 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 15 Sep 2026 21:59:57 -0500 Subject: [PATCH 022/117] Version(fix[has_behavior]): Make it real API, fix all three meets() sites why: the coordinator authorized this after review of "Tests(fix[version]): Predict require-gated branches with require's rule"'s four test fixups: has_behavior was gated behind test-support, reachable only from tests, while the mistake it fixes -- gating a capability above the crate's minimum supported release with meets, which clamps every development identifier to that floor regardless of its own next-release number -- lives in production code too. Seven call sites across this workspace made the same mistake (four tests, fixed in "Tests(fix[version]): Predict require-gated branches with require's rule", three production, fixed here), which is one API offering a plausible-looking wrong answer, not seven unrelated bugs. Severity ranked: internal/options.rs:303 (LATE_SCOPES) is a functional regression on any development build -- it actively refused a pane- scoped pane-border-format/pane-active-border-style/pane-border-style write through Pane::set_option even when the running server genuinely has the capability. tmux-mcp's tools/mod.rs:94 degraded capture_last_command to a plain capture on a development build that has per-line capture flags. control.rs:477 fails safe (costs a pane's back-pressure, not correctness) -- lowest severity, fixed for consistency. what: - Remove has_behavior's test-support gate; it is real, always-on library API now. Added a doctest contrasting it with meets (next-3.9 against CAPTURE_LINE_FLAGS) since it is no longer a test-only helper. - Document the trap at its source: TmuxVersion::meets's doc comment now says explicitly that it does not read a next-X.Y build's own release number even at or below the required version, and points to has_behavior for a capability question above the floor -- so the wrong choice is harder to make by accident at the definition, not only after this fix. - Switch all three production sites (internal/options.rs:303, tools/mod.rs:94, control.rs:477) from meets to has_behavior. - Switch five illustrative doctests (pane/observe.rs, server.rs x3, pane.rs) that taught the same wrong pattern -- shipping the fix while leaving the docs teaching readers to reintroduce it would not have closed anything. - Negative test for the most severe site: a_late_pane_scope_is_granted_to_whichever_tmux_actually_has_it (options.rs). Branches on whichever tmux is actually running rather than assuming one: ran against 3.2a (below the real floor, still refused, error names the capability), the default tmux 3.7d (has it, accepted), and the next-3.9 probe (has it via its own next-release number, accepted). Negative-test proof: reverted the fix to meets and reran against next-3.9 -- fails, panicking with UnsupportedCapability on a build that genuinely has the capability. Reverted the revert. - Regenerated public-api.txt (unchanged: has_behavior's signature was already recorded identically under --all-features, which is how the generator always builds); just api-check, example-coverage-check, doc-blocks, docs (-D warnings) and parity-claims all clean. --- crates/libtmux/src/control.rs | 2 +- crates/libtmux/src/internal/options.rs | 2 +- crates/libtmux/src/pane.rs | 2 +- crates/libtmux/src/pane/observe.rs | 2 +- crates/libtmux/src/server.rs | 6 +-- crates/libtmux/src/version.rs | 55 ++++++++++++++++-------- crates/libtmux/tests/options.rs | 59 ++++++++++++++++++++++++++ crates/libtmux/tests/version.rs | 1 - crates/tmux-mcp/src/tools/mod.rs | 2 +- 9 files changed, 105 insertions(+), 26 deletions(-) diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index 7cb811ba..6ff95887 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -474,7 +474,7 @@ impl ControlMode { let pane_off_is_safe = server .capabilities() .await - .is_ok_and(|capabilities| capabilities.tmux_version().meets(&CONTROL_PANE_OFF)); + .is_ok_and(|capabilities| capabilities.tmux_version().has_behavior(&CONTROL_PANE_OFF)); let timeout = server.default_timeout(); let actor::OpenedConnection { diff --git a/crates/libtmux/src/internal/options.rs b/crates/libtmux/src/internal/options.rs index 9df32806..5784ab71 100644 --- a/crates/libtmux/src/internal/options.rs +++ b/crates/libtmux/src/internal/options.rs @@ -300,7 +300,7 @@ async fn ensure_scope(core: &Core, scope: Scope<'_>, name: &str) -> Result<(), E for (option, late, needs) in LATE_SCOPES { if *option == schema.name() && *late == requested { let found = core.capabilities().await?.tmux_version(); - if !found.meets(needs) { + if !found.has_behavior(needs) { return Err(Error::UnsupportedCapability { capability: option, needs: *needs, diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 52d18a08..9499a9d4 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -1544,7 +1544,7 @@ enum CaptureBound { /// let session = server.new_session("marked").await?; /// let pane = session.panes().await?.remove(0); /// -/// if server.capabilities().await?.tmux_version().meets(&libtmux::since::CAPTURE_LINE_FLAGS) { +/// if server.capabilities().await?.tmux_version().has_behavior(&libtmux::since::CAPTURE_LINE_FLAGS) { /// let lines: Vec = pane.capture_lines(CaptureOptions::visible()).await?; /// assert!(lines.iter().all(|line| !line.starts_output || !line.starts_prompt)); /// } diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index 36afc10e..0ea8b60b 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -208,7 +208,7 @@ impl Pane { /// let session = server.new_session("prompts").await?; /// let pane = session.panes().await?.remove(0); /// - /// if server.capabilities().await?.tmux_version().meets(&libtmux::since::CAPTURE_LINE_FLAGS) { + /// if server.capabilities().await?.tmux_version().has_behavior(&libtmux::since::CAPTURE_LINE_FLAGS) { /// let lines = pane.capture_lines(CaptureOptions::history()).await?; /// // Without shell integration nothing is marked, which is an answer. /// let prompts = lines.iter().filter(|line| line.starts_prompt).count(); diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 687db148..70475bb8 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -197,7 +197,7 @@ use crate::version::since::PROMPT_HISTORY as PROMPT_HISTORY_SINCE; /// /// // tmux keeps a separate history per prompt kind, so a command typed at the /// // `:` prompt is not offered when searching. -/// if version.meets(&libtmux::since::PROMPT_HISTORY) { +/// if version.has_behavior(&libtmux::since::PROMPT_HISTORY) { /// assert!(guard.server().prompt_history(PromptKind::Command).await?.is_empty()); /// assert!(guard.server().prompt_history(PromptKind::Search).await?.is_empty()); /// } @@ -1147,7 +1147,7 @@ impl Server { /// let version = server.capabilities().await?.tmux_version().clone(); /// /// // A fresh server has answered no prompts. - /// if version.meets(&libtmux::since::PROMPT_HISTORY) { + /// if version.has_behavior(&libtmux::since::PROMPT_HISTORY) { /// assert!(server.prompt_history(PromptKind::Command).await?.is_empty()); /// } /// @@ -1225,7 +1225,7 @@ impl Server { /// let version = guard.server().capabilities().await?.tmux_version().clone(); /// /// // Whoever started the server owns it and may act. - /// if version.meets(&libtmux::since::SERVER_ACCESS) { + /// if version.has_behavior(&libtmux::since::SERVER_ACCESS) { /// let rules = guard.server().access_rules().await?; /// assert_eq!(rules.len(), 1); /// assert_eq!(rules[0].mode(), libtmux::AccessMode::Write); diff --git a/crates/libtmux/src/version.rs b/crates/libtmux/src/version.rs index 983a09d0..b3757e6c 100644 --- a/crates/libtmux/src/version.rs +++ b/crates/libtmux/src/version.rs @@ -430,6 +430,14 @@ impl TmuxVersion { /// /// Development identifiers meet only requirements at or below the crate's /// minimum supported release; they are not promoted to an invented release. + /// A `next-X.Y` identifier's own release number is not read here even + /// when `required` is that same `X.Y` or earlier -- this is a floor + /// check, not a capability check. Asking "does this tree already contain + /// a capability that shipped at version V" is [`Self::has_behavior`]'s + /// question, not this one; a call site gating a specific capability + /// behind a release above [`Self::MIN_SUPPORTED`] almost always wants + /// that instead, or it refuses a development build that already has the + /// capability it is checking for. /// /// # Examples /// @@ -463,23 +471,36 @@ impl TmuxVersion { /// Report whether this version's tree carries a capability's behavior. /// - /// This is `Self::require`'s refusal rule in boolean form, reachable so - /// a test predicting which branch `require` takes calls the same rule - /// `require` does, rather than an independently derived one that happens - /// to agree today. [`Self::meets`] is a different, publicly documented - /// rule: it clamps *every* development identifier -- `next-X.Y` included, - /// not only a bare `master` -- to "no more than the crate's minimum - /// supported release", so it refuses any requirement above that floor - /// regardless of what the build's own next-release number is. This reads - /// `next-X.Y` as the real release `X.Y` instead, so the two disagree for - /// any development build checked against a requirement above the floor: - /// [`crate::since`] holds several, and this crate's own tmux-matrix - /// probe self-reports `next-3.9`. - /// - /// Gated behind `test-support` because it exists for that reachability, - /// not as a second public capability check callers should choose - /// between. - #[cfg(feature = "test-support")] + /// This is `Self::require`'s refusal rule in boolean form, so a caller + /// gating a specific capability -- or a test predicting which branch + /// `require` takes -- uses the same rule `require` does, rather than an + /// independently derived one that can drift from it. [`Self::meets`] is + /// a different, deliberately non-identical rule: it clamps *every* + /// development identifier -- `next-X.Y` included, not only a bare + /// `master` -- to "no more than the crate's minimum supported release", + /// so it refuses any requirement above that floor regardless of what the + /// build's own next-release number is. This reads `next-X.Y` as the real + /// release `X.Y` instead, so the two disagree for any development build + /// checked against a requirement above the floor: [`crate::since`] holds + /// several, and this crate's own tmux-matrix probe self-reports + /// `next-3.9`. Checking a capability with `meets` instead of this + /// refuses that capability on a development build that already has it. + /// + /// # Examples + /// + /// ``` + /// use libtmux::{ReleaseSuffix, ReleaseVersion, TmuxVersion}; + /// + /// let next = TmuxVersion::parse_output(b"tmux next-3.9\n")?; + /// let capture_line_flags = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL); + /// + /// // `next-3.9` already contains 3.7's behavior, so this reports it -- + /// // unlike `meets`, which clamps every development identifier to the + /// // floor and would refuse it. + /// assert!(next.has_behavior(&capture_line_flags)); + /// assert!(!next.meets(&capture_line_flags)); + /// # Ok::<(), libtmux::Error>(()) + /// ``` #[must_use] pub fn has_behavior(&self, needs: &ReleaseVersion) -> bool { self.behavior_satisfies(*needs) diff --git a/crates/libtmux/tests/options.rs b/crates/libtmux/tests/options.rs index 399337ba..b2635849 100644 --- a/crates/libtmux/tests/options.rs +++ b/crates/libtmux/tests/options.rs @@ -1243,3 +1243,62 @@ async fn a_name_tmux_would_resolve_is_guarded_like_the_one_it_resolves_to() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// `pane-border-format` reached a build that already has it, not just one +/// numbered high enough. +/// +/// `ensure_scope`'s `LATE_SCOPES` check used to call `TmuxVersion::meets`, +/// which clamps every development identifier to the crate's minimum +/// supported release regardless of its own next-release number -- so a +/// `next-X.Y` build that genuinely has this capability was refused anyway. +/// Fixed by switching to `TmuxVersion::has_behavior`, which reads `next-X.Y` +/// as the real release. Asserted on whichever tmux is actually running +/// rather than assumed, so this is correct on every release the compat +/// matrix builds, `next-3.9` included, without a version predicate. +#[tokio::test] +async fn a_late_pane_scope_is_granted_to_whichever_tmux_actually_has_it() { + use libtmux::since; + + let mut builder = TestServer::builder(); + if let Some(executable) = std::env::var_os("LIBTMUX_TEST_TMUX") { + builder = builder.tmux_executable(executable); + } + let guard = builder.start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("late-scope").await.expect("a session"); + let pane = session + .active_window() + .await + .expect("active window") + .expect("a session has a window") + .active_pane() + .await + .expect("active pane") + .expect("a window has a pane"); + + let has_capability = server + .capabilities() + .await + .expect("capabilities") + .tmux_version() + .has_behavior(&since::PANE_BORDER_FORMAT_PER_PANE); + + let result = pane.set_option("pane-border-format", "#{pane_index}").await; + if has_capability { + result.expect("a build that already has this capability accepts the write"); + } else { + let error = result.expect_err("a build below the real floor is still refused"); + assert!( + matches!( + error, + libtmux::Error::UnsupportedCapability { + capability: "pane-border-format", + .. + } + ), + "the refusal names the capability: {error:?}", + ); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} diff --git a/crates/libtmux/tests/version.rs b/crates/libtmux/tests/version.rs index c6027dc8..2e37405b 100644 --- a/crates/libtmux/tests/version.rs +++ b/crates/libtmux/tests/version.rs @@ -244,7 +244,6 @@ fn enforces_the_minimum_without_promoting_development_versions() { /// contains. This is the rule the `commands.rs` tests that predicted a /// `require`-gated branch with `meets` were reproducing, unnoticed, always /// taking the "unsupported" branch against that probe. -#[cfg(feature = "test-support")] #[test] fn has_behavior_and_meets_disagree_above_the_supported_floor() { let master = TmuxVersion::parse_output(b"tmux master\n").unwrap(); diff --git a/crates/tmux-mcp/src/tools/mod.rs b/crates/tmux-mcp/src/tools/mod.rs index 2c8aa81a..12e70b71 100644 --- a/crates/tmux-mcp/src/tools/mod.rs +++ b/crates/tmux-mcp/src/tools/mod.rs @@ -91,7 +91,7 @@ impl TmuxTools { let supported = self.server.capabilities().await.is_ok_and(|capabilities| { capabilities .tmux_version() - .meets(&libtmux::since::CAPTURE_LINE_FLAGS) + .has_behavior(&libtmux::since::CAPTURE_LINE_FLAGS) }); let (rendered, marks) = if supported { From 5d4fab049530804e887557816ba6f4ed961588ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 16 Sep 2026 18:55:13 -0500 Subject: [PATCH 023/117] Control(fix[stream]): End a killed pane's stream, pair resume with mute Killing the pane a Pane::stream_output stream watched left next_chunk waiting forever: tmux names no notification for a pane leaving, and the stream only re-listed panes to repair its mute set. The re-listing now also reports whether the watched pane is still there, and a completed listing without it ends the stream, including when the pane was its window's last and the death arrives as WindowClosed. resume_pane sent only continue, so after mute_pane took a pane off on tmux 3.7+ it left the pane muted, with tmux no longer reading its pty for any client. unmute_pane and resume_pane now send on and continue in one refresh-client; each is a no-op for a flag that is not set. attach sends refresh-client -f new-layouts so LayoutChanged carries the JSON Window::layout reports on 3.8+; the flag is ignored below 3.8. PaneOutput::sender exposes the stream's own connection so a caller can mute or resume its pane. --- crates/libtmux/src/control.rs | 241 +++++++++---- crates/libtmux/src/control/lifecycle_tests.rs | 14 +- crates/libtmux/src/control/tests.rs | 2 +- crates/libtmux/tests/control.rs | 330 +++++++++++++++++- 4 files changed, 514 insertions(+), 73 deletions(-) diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index 6ff95887..8c96ede0 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -484,12 +484,26 @@ impl ControlMode { connection, } = actor::open(server.spawn_control(session).await?, limits, timeout).await?; + let sender = ControlSender { + commands, + timeout, + pane_off_is_safe, + }; + + // Ask for JSON layouts before anything else can read one. tmux 3.8+ + // hands a control client the classic `window_layout` string it hands + // a plain client only after this flag is set, so `Event::LayoutChanged` + // and a snapshot taken through this connection would otherwise + // disagree with each other on format. A release below 3.8 has no such + // flag: `server_client_set_flags` skips a name it does not recognise, + // so this is a no-op there rather than a refusal. + sender + .send(Command::new("refresh-client").arg("-f").arg("new-layouts")) + .await? + .require_success("refresh-client")?; + Ok(Self { - sender: ControlSender { - commands, - timeout, - pane_off_is_safe, - }, + sender, events: ControlEvents { events, stop, @@ -742,10 +756,19 @@ impl ControlSender { /// /// Below [`crate::since::CONTROL_PANE_OFF`] this pauses the pane rather /// than taking it out of the stream, because taking it out crashes the - /// server. tmux reports a paused pane, so a caller reading - /// [`ControlEvents`] sees [`Event::Paused`] for it there and not on a - /// newer tmux. The pane stops arriving either way; what a paused pane - /// costs is the back-pressure, since tmux keeps draining its terminal. + /// server. tmux keeps reading the pane's pty either way, discarding what + /// this connection does not want; a paused pane costs only that + /// connection's own back-pressure, and [`ControlEvents`] sees + /// [`Event::Paused`] for it. + /// + /// At or above that release, taking the pane out of the stream also stops + /// tmux reading its pty at all -- for every attached client, not only this + /// connection, and for a one-shot reader such as `capture-pane` too -- + /// until [`Self::unmute_pane`] or [`Self::resume_pane`] turns it back on. + /// A human attached to the same pane sees it stop updating, and the pane's + /// own program can block on `write` once the kernel's pty buffer fills. + /// What arrives once resumed is therefore a backlog, not a gap: see + /// [`Self::unmute_pane`]. /// /// # Errors /// @@ -765,39 +788,64 @@ impl ControlSender { /// Resume sending what a pane writes, after [`Self::mute_pane`]. /// + /// Below [`crate::since::CONTROL_PANE_OFF`], [`Self::mute_pane`] paused the + /// pane rather than taking it out of the stream, and this continues it: /// tmux resumes from the pane's current output rather than replaying what - /// was skipped, so a caller unmuting a pane has a gap, not a backlog. + /// was skipped, so the caller sees a gap, not a backlog. /// - /// Below [`crate::since::CONTROL_PANE_OFF`] this continues the pane that - /// [`Self::mute_pane`] paused, which is the same gap by another name. + /// At or above that release, [`Self::mute_pane`] took the pane out of the + /// stream instead, which also stopped tmux reading its pty; this turns + /// that back on, and everything written while muted arrives at once, as a + /// backlog rather than a gap. + /// + /// This sends both tmux commands in one dispatch rather than choosing + /// between them, so it is exactly [`Self::resume_pane`] -- either name + /// undoes [`Self::mute_pane`] correctly regardless of which mechanism the + /// running tmux used. /// /// # Errors /// /// Returns an error when the connection has closed or tmux refuses the /// stream change. pub async fn unmute_pane(&self, pane: &PaneId) -> Result<(), Error> { - self.set_pane_stream( - pane, - if self.pane_off_is_safe { - "on" - } else { - "continue" - }, - ) - .await + self.recover_pane(pane).await } - /// Resume a pane tmux paused because this connection fell behind. + /// Resume a pane tmux paused because this connection fell behind, or one + /// [`Self::mute_pane`] muted. /// - /// Pairs with [`Event::Paused`], which only arrives once a caller has - /// asked for pausing with [`Self::pause_after`]. + /// Pairs with [`Event::Paused`], which arrives once a caller has asked for + /// pausing with [`Self::pause_after`]. It is also exactly + /// [`Self::unmute_pane`]: both send the same two commands, so calling + /// either after [`Self::mute_pane`] recovers the pane on every version, + /// rather than only the one whose mechanism happens to match. /// /// # Errors /// /// Returns an error when the connection has closed or tmux refuses the /// stream change. pub async fn resume_pane(&self, pane: &PaneId) -> Result<(), Error> { - self.set_pane_stream(pane, "continue").await + self.recover_pane(pane).await + } + + /// Undo whichever of `off` or `pause` a pane is under, in one dispatch. + /// + /// `refresh-client -A` accepts more than one `pane:state` pair, so `on` + /// and `continue` travel together. tmux's `control_set_pane_on` and + /// `control_continue_pane` are each a no-op when their own flag is not + /// set, so sending both is correct whether the pane was taken out of the + /// stream, paused, both, or neither. + async fn recover_pane(&self, pane: &PaneId) -> Result<(), Error> { + self.send( + Command::new("refresh-client") + .arg("-A") + .arg(format!("{pane}:on")) + .arg("-A") + .arg(format!("{pane}:continue")), + ) + .await? + .require_success("refresh-client") + .map(|_| ()) } /// Ask tmux to report a format whenever it changes. @@ -958,6 +1006,30 @@ impl ControlSender { Ok(()) } + /// Report whether tmux still lists this pane, anywhere on the server. + /// + /// Used to tell a pane's death from an unrelated listing change: an event + /// that may mean a pane appeared can equally mean one left, and tmux + /// publishes no notification that names which. + pub(crate) async fn pane_exists(&self, pane: &PaneId) -> Result { + let listed = self + .send( + Command::new("list-panes") + .arg("-a") + .arg("-F") + .arg("#{pane_id}"), + ) + .await? + .require_success("list-panes")?; + + for line in listed.output() { + if decode_watched_pane_id(line)? == *pane { + return Ok(true); + } + } + Ok(false) + } + async fn set_pane_stream(&self, pane: &PaneId, state: &str) -> Result<(), Error> { self.send( Command::new("refresh-client") @@ -1111,9 +1183,12 @@ const NARROW_DIRTY: u8 = 2; /// /// Built by [`crate::Pane::stream_output`]. This is a [`Stream`] of the bytes /// that pane produced, in order. -/// Its infallible items combine normal termination and connection failure -/// into `None`. Call [`Self::shutdown`] to observe any connection error, or -/// use [`ControlEvents`] to receive errors during iteration. +/// Its infallible items combine normal termination, connection failure, and +/// the watched pane being killed into `None`, once whatever was already +/// buffered has drained. Call [`Self::shutdown`] to observe a connection +/// error, or use [`ControlEvents`] to receive errors during iteration; a +/// killed pane is not an error either way, since ending is the correct answer +/// once nothing more will arrive. /// /// tmux is told to send this connection nothing but the watched pane. A /// neighbouring pane running `yes` otherwise moves tens of megabytes a second @@ -1128,7 +1203,8 @@ pub struct PaneOutput { events: ControlEvents, boundary: u64, closed: bool, - /// Kept to re-narrow the subscription, not to send a caller's commands. + /// Kept to re-narrow the subscription and to check the watched pane still + /// exists, not to send a caller's commands. /// /// tmux has no notification for a pane being created, so a pane that /// appears after the attach arrives unmuted; the event loop below repairs @@ -1138,6 +1214,9 @@ pub struct PaneOutput { /// /// Each pass costs a `list-panes` round trip, so a burst coalesces. narrowing: Arc, + /// The re-narrowing pass in flight, if any, polled for whether it found + /// the watched pane still listed. + narrow_handle: Option>, } impl PaneOutput { @@ -1149,16 +1228,22 @@ impl PaneOutput { closed: false, sender, narrowing: Arc::new(AtomicU8::new(NARROW_IDLE)), + narrow_handle: None, } } - /// Tell tmux again to send only this pane. - /// - /// Detached rather than awaited so [`Stream::poll_next`], which cannot - /// await, repairs the subscription the same way [`Self::next_chunk`] does. - /// A failure leaves the caller its own pane alongside noise, so it does - /// not end the stream. - fn narrow(&self) { + /// Tell tmux again to send only this pane, and check it is still there. + /// + /// Spawned rather than awaited so [`Stream::poll_next`], which cannot + /// await, repairs the subscription the same way [`Self::next_chunk`] + /// does. tmux publishes no notification naming a pane that left the + /// server -- only ones consistent with a pane having *appeared* -- so a + /// pane's death is read from the same re-listing this already does to + /// repair the mute set, rather than from a second round trip. A failure + /// checking or re-narrowing leaves the caller its own pane alongside + /// noise, so it does not end the stream by itself; only a listing that + /// completes without the watched pane in it does. + fn narrow(&mut self) { let transition = self.narrowing .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| match state { @@ -1173,7 +1258,7 @@ impl PaneOutput { let sender = self.sender.clone(); let pane = self.pane.clone(); let narrowing = Arc::clone(&self.narrowing); - tokio::spawn(async move { + self.narrow_handle = Some(tokio::spawn(async move { loop { let _ = sender.watch_only(std::slice::from_ref(&pane)).await; match narrowing.compare_exchange( @@ -1189,7 +1274,12 @@ impl PaneOutput { } } } - }); + // Settled on the final pass's view, so a pane that reappeared + // mid-burst under the same id is not reported gone. An error here + // -- the connection closing under us -- is not evidence of + // anything about the pane, so it counts as still there. + sender.pane_exists(&pane).await.unwrap_or(true) + })); } /// Return the pane being watched. @@ -1198,6 +1288,17 @@ impl PaneOutput { &self.pane } + /// Return the connection this stream reads, to mute or resume panes on it. + /// + /// The watched pane's own stream cannot be muted through [`Self`] alone: + /// [`ControlSender::mute_pane`], [`ControlSender::unmute_pane`], and + /// [`ControlSender::resume_pane`] all take a target, and [`Self::pane`] + /// names this one. + #[must_use] + pub const fn sender(&self) -> &ControlSender { + &self.sender + } + /// Capture the pane's visible screen at an ordered point in this stream. /// /// `on_output` receives every unread chunk tmux ordered before the capture @@ -1299,25 +1400,7 @@ impl PaneOutput { /// A chunk is what tmux chose to report at once, which is not a line and /// not a fixed size. Callers wanting lines should buffer. pub async fn next_chunk(&mut self) -> Option> { - if self.closed { - return None; - } - loop { - let delivery = self.events.next_delivery().await; - match delivery { - Some(Delivery::Event( - Event::Output { pane, bytes } | Event::ExtendedOutput { pane, bytes, .. }, - )) if pane == self.pane => { - return Some(bytes); - } - Some(Delivery::Event(Event::Exit { .. })) | None => { - self.closed = true; - return None; - } - Some(Delivery::Event(event)) if event.may_have_added_a_pane() => self.narrow(), - _ => {} - } - } + poll_fn(|context| Pin::new(&mut *self).poll_next(context)).await } /// End the connection and report how it went. @@ -1334,24 +1417,54 @@ impl PaneOutput { impl Stream for PaneOutput { type Item = Vec; - fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll>> { - if self.closed { + fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll>> { + // `PaneOutput` holds nothing self-referential, so projecting to a + // plain `&mut Self` is sound and lets the rest of this read like an + // ordinary method body. + let this = self.get_mut(); + if this.closed { return Poll::Ready(None); } + + // A re-narrow already in flight is polled for its answer before + // reading more events, so a pane confirmed gone ends the stream even + // when nothing further arrives to wake this on the event channel + // alone. + if let Some(mut handle) = this.narrow_handle.take() { + match Pin::new(&mut handle).poll(context) { + Poll::Pending => this.narrow_handle = Some(handle), + Poll::Ready(Ok(true)) => {} + Poll::Ready(Ok(false)) => { + this.closed = true; + return Poll::Ready(None); + } + // Nothing aborts this task, so a join failure is a panic in + // the check, not a cancellation; resuming it here keeps it + // visible rather than reporting a bug as a dead pane. + Poll::Ready(Err(error)) => std::panic::resume_unwind(error.into_panic()), + } + } + loop { - match std::task::ready!(self.events.events.poll_recv(context)) { + match std::task::ready!(this.events.events.poll_recv(context)) { Some(Delivery::Event( Event::Output { pane, bytes } | Event::ExtendedOutput { pane, bytes, .. }, - )) if pane == self.pane => { + )) if pane == this.pane => { return Poll::Ready(Some(bytes)); } Some(Delivery::Event(Event::Exit { .. })) | None => { - self.closed = true; + this.closed = true; return Poll::Ready(None); } + // `LayoutChanged` et al. can mean a pane appeared; `WindowClosed` + // cannot, but it is how the watched pane's own window closing + // under it is reported when that pane was the window's last, + // so both are read the same way: re-list, and end the stream + // if the watched pane is not on it. Some(Delivery::Event(event)) => { - if event.may_have_added_a_pane() { - self.narrow(); + if event.may_have_added_a_pane() || matches!(event, Event::WindowClosed { .. }) + { + this.narrow(); } } Some(Delivery::Boundary(_)) => {} diff --git a/crates/libtmux/src/control/lifecycle_tests.rs b/crates/libtmux/src/control/lifecycle_tests.rs index 6ad91e5c..8febfd12 100644 --- a/crates/libtmux/src/control/lifecycle_tests.rs +++ b/crates/libtmux/src/control/lifecycle_tests.rs @@ -212,8 +212,12 @@ fn process_script(parent: &Path, descendant: &Path, prefix: &str) -> String { ) } +/// The discarded opening block, followed by a success reply to the +/// `refresh-client -f new-layouts` request `ControlMode::attach` now sends +/// before returning -- block 1 is the opening handshake, block 2 answers that +/// request, so a test's own first command after attaching is block 3. fn opening_success() -> &'static str { - "printf '%%begin 0 1 0\\n%%end 0 1 0\\n'" + "printf '%%begin 0 1 0\\n%%end 0 1 0\\n'\nIFS= read -r _new_layouts\nprintf '%%begin 0 2 0\\n%%end 0 2 0\\n'" } async fn attach(server: &Server) -> Result { @@ -372,7 +376,7 @@ async fn an_open_response_block_has_one_deadline() { let marker = fixture.path().join("command-started"); let _guard = ProcessGuard::new([parent.clone(), descendant.clone()]); let prefix = format!( - "{}\nIFS= read -r _line\n: > {}\nprintf '%%begin 0 2 0\\n'", + "{}\nIFS= read -r _line\n: > {}\nprintf '%%begin 0 3 0\\n'", opening_success(), shell_quote(&marker), ); @@ -586,7 +590,7 @@ async fn watcher_shutdown_interrupts_an_open_response_block() { let marker = fixture.path().join("command-started"); let _guard = ProcessGuard::new([parent.clone(), descendant.clone()]); let prefix = format!( - "{}\nIFS= read -r _line\n: > {}\nprintf '%%begin 0 2 0\\n'", + "{}\nIFS= read -r _line\n: > {}\nprintf '%%begin 0 3 0\\n'", opening_success(), shell_quote(&marker), ); @@ -749,7 +753,7 @@ async fn terminal_notifications_drain_after_eof_inside_a_reply() { let executable = write_script( fixture.path(), &format!( - "{}\nIFS= read -r _command\nindex=0\nwhile [ \"$index\" -lt {} ]; do\n printf '%%sessions-changed\\n'\n index=$((index + 1))\ndone\nprintf '%%begin 0 2 0\\npartial\\n'", + "{}\nIFS= read -r _command\nindex=0\nwhile [ \"$index\" -lt {} ]; do\n printf '%%sessions-changed\\n'\n index=$((index + 1))\ndone\nprintf '%%begin 0 3 0\\npartial\\n'", opening_success(), EVENT_QUEUE + 1, ), @@ -864,7 +868,7 @@ async fn pane_snapshot_separates_output_at_the_capture_block() { let executable = write_script( fixture.path(), &format!( - "{}\nIFS= read -r _command\nprintf '%%output %%1 before\\n'\nprintf '%%begin 0 2 0\\nvisible\\n%%end 0 2 0\\n'\nprintf '%%output %%1 after\\n'\nprintf '%%exit done\\n'", + "{}\nIFS= read -r _command\nprintf '%%output %%1 before\\n'\nprintf '%%begin 0 3 0\\nvisible\\n%%end 0 3 0\\n'\nprintf '%%output %%1 after\\n'\nprintf '%%exit done\\n'", opening_success(), ), ); diff --git a/crates/libtmux/src/control/tests.rs b/crates/libtmux/src/control/tests.rs index 79996302..a17e2690 100644 --- a/crates/libtmux/src/control/tests.rs +++ b/crates/libtmux/src/control/tests.rs @@ -240,7 +240,7 @@ async fn dirty_narrowing_reruns_after_an_in_flight_failure() { let (_events, received) = mpsc::channel(1); let (stop, _stopped) = watch::channel(()); let connection = tokio::spawn(async { Ok::<(), Error>(()) }); - let output = PaneOutput::new( + let mut output = PaneOutput::new( "%1".parse().expect("a pane id"), ControlEvents { events: received, diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index b2b1c491..0900c54c 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -15,6 +15,7 @@ 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); +assert_impl_all!(libtmux::control::PaneOutput: Send, Sync, Unpin, futures_core::Stream); /// Report whether an event says a window has appeared. /// @@ -1086,6 +1087,188 @@ async fn a_stream_opens_on_a_server_that_is_already_flooding() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Killing the observed pane ends `next_chunk()` rather than hanging it, +/// when the pane's window survives and the death is reported as a layout +/// change. +/// +/// tmux publishes no dedicated "pane gone" notification; a split, a title +/// change, and a pane dying all report through the same event vocabulary, so +/// this checks the death path specifically rather than trusting that +/// `may_have_added_a_pane` and its neighbours share one implementation. +#[tokio::test] +async fn observed_pane_death_ends_the_stream_via_layout_change() { + use libtmux::{SplitDirection, SplitOptions}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("pane-death-layout") + .await + .expect("session"); + let window = session + .windows() + .await + .expect("windows") + .into_iter() + .next() + .expect("one window"); + + // A second pane keeps the window alive once the watched one is killed, + // so tmux reports the death as `%layout-change` rather than closing the + // window outright. + let survivor = window + .split(SplitOptions::new(SplitDirection::Right).command("sleep 300")) + .await + .expect("pane is created"); + let watched = window + .split(SplitOptions::new(SplitDirection::Below).command("sleep 300")) + .await + .expect("pane is created"); + + let mut output = watched.stream_output().await.expect("the pane streams"); + + watched.kill().await.expect("the watched pane is killed"); + + let ended = tokio::time::timeout(Duration::from_secs(8), output.next_chunk()).await; + assert_eq!( + ended, + Ok(None), + "next_chunk must end, not hang, once the watched pane is confirmed gone", + ); + + let _ = survivor; + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// Killing the observed pane ends `next_chunk()` rather than hanging it, when +/// killing it also closes its window because it was the window's last pane +/// (D7). +/// +/// tmux reports this as `%unlinked-window-close` and `%session-window-changed` +/// -- neither of which `may_have_added_a_pane` names, and only the second +/// happens to also emit a covered `%session-changed` for this session's +/// current-window bookkeeping. `WindowClosed`'s own trigger does not depend +/// on that coincidence. +#[tokio::test] +async fn observed_pane_death_ends_the_stream_via_window_close() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("pane-death-window") + .await + .expect("session"); + + // A second window keeps the session alive once the first window's only + // pane is killed, so that pane's death closes its window without ending + // the session. + session + .new_window(NewWindowOptions::new("elsewhere").command("sleep 300")) + .await + .expect("a second window is created"); + let first = session + .windows() + .await + .expect("windows") + .into_iter() + .find(|window| window.name().as_bytes() != b"elsewhere") + .expect("the original window still exists"); + let watched = first + .panes() + .await + .expect("panes list") + .into_iter() + .next() + .expect("the original window's one pane"); + + let mut output = watched.stream_output().await.expect("the pane streams"); + + watched.kill().await.expect("the watched pane is killed"); + + let ended = tokio::time::timeout(Duration::from_secs(8), output.next_chunk()).await; + assert_eq!( + ended, + Ok(None), + "next_chunk must end, not hang, once the watched pane's window closes under it", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// `PaneOutput::sender` reaches the exact connection `stream_output` opened, +/// so a caller reading a stream can mute and resume that same stream without +/// bypassing `stream_output` and reimplementing its internals. +#[tokio::test] +async fn pane_output_sender_reaches_the_streams_own_connection() { + use libtmux::{SplitDirection, SplitOptions}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("stream-mutes-itself") + .await + .expect("session"); + let window = session + .windows() + .await + .expect("windows") + .into_iter() + .next() + .expect("one window"); + // `sleep 300` is the pane's own command rather than a shell, so a + // keystroke sent the instant the pane exists is echoed rather than + // swallowed by a prompt that has not started yet. + let pane = window + .split(SplitOptions::new(SplitDirection::Below).command("sleep 300")) + .await + .expect("pane is created"); + + let mut output = pane.stream_output().await.expect("the pane streams"); + assert!(!output.sender().is_closed(), "a fresh sender is open"); + + output + .sender() + .mute_pane(output.pane()) + .await + .expect("the stream mutes its own pane"); + pane.send_keys("echo muted-via-sender") + .await + .expect("the muted marker is sent"); + + // Nothing should arrive while muted; a short, bounded wait is the proof, + // not a hang -- the deadline running out is the expected outcome here. + let muted = tokio::time::timeout(Duration::from_millis(500), output.next_chunk()).await; + assert!( + muted.is_err(), + "no chunk should arrive on a stream muted through its own sender: {muted:?}", + ); + + output + .sender() + .unmute_pane(output.pane()) + .await + .expect("the stream unmutes its own pane"); + pane.send_keys("echo unmuted-via-sender") + .await + .expect("the unmuted marker is sent"); + + let resumed = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let chunk = output.next_chunk().await.expect("the stream is still open"); + if String::from_utf8_lossy(&chunk).contains("unmuted-via-sender") { + return; + } + } + }) + .await; + assert!( + resumed.is_ok(), + "the stream must resume once unmuted through its own sender", + ); + + output.shutdown().await.expect("the connection shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// Muting a pane that is already producing must not take the server with it. /// /// Below [`libtmux::since::CONTROL_PANE_OFF`], `refresh-client -A :off` @@ -1383,9 +1566,9 @@ async fn a_subscription_name_tmux_would_misread_is_refused() { /// The pause threshold and the resume that answers it must both dispatch. /// /// `pause_after` asks tmux to pause a pane rather than disconnect a client -/// that falls behind, and `resume_pane` is what restarts one it paused -- -/// which is a different thing from `unmute_pane`, that being the counterpart -/// to a mute the caller asked for. +/// that falls behind, and `resume_pane` is what restarts one it paused. It is +/// also exactly `unmute_pane`: both send the same two commands, so either +/// name recovers a pane regardless of which mechanism muted or paused it. /// /// What is covered here is that both are built and accepted. Driving a real /// pause means falling far enough behind for tmux to notice, which is a @@ -1420,6 +1603,147 @@ async fn the_pause_threshold_and_its_resume_are_accepted() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// `resume_pane` leaves a pane able to receive output again after +/// `mute_pane` muted it, even though `resume_pane`'s own doc says it pairs +/// with `pause_after`, not `mute_pane`. +/// +/// This checks a marker sent *after* resuming, not the one sent while muted: +/// below `since::CONTROL_PANE_OFF`, a paused pane's output is a gap for this +/// connection by design, not a backlog. Both `unmute_pane` and `resume_pane` +/// send `on` and `continue` together, so either recovers a pane regardless +/// of which mechanism muted or paused it. +#[tokio::test] +async fn resume_pane_recovers_what_mute_pane_muted() { + use libtmux::{SplitDirection, SplitOptions}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("resume-recovers-mute") + .await + .expect("session"); + let window = session + .windows() + .await + .expect("windows") + .into_iter() + .next() + .expect("one window"); + + // `sleep 300` is the pane's own command rather than a shell, so a + // keystroke sent the instant the pane exists is echoed rather than + // swallowed by a prompt that has not started yet. + let pane = window + .split(SplitOptions::new(SplitDirection::Below).command("sleep 300")) + .await + .expect("pane is created"); + + let (commands, mut events) = ControlMode::attach(server, session.id()) + .await + .expect("control mode attaches") + .split(); + + commands.mute_pane(pane.id()).await.expect("the pane mutes"); + pane.send_keys("echo while-muted-marker") + .await + .expect("the first marker is sent"); + + commands + .resume_pane(pane.id()) + .await + .expect("resuming is accepted"); + + pane.send_keys("echo after-resume-marker") + .await + .expect("the second marker is sent"); + + let saw_marker = wait_for(&mut events, |event| { + matches!(event, Event::Output { pane: reported, bytes } + if reported == pane.id() + && String::from_utf8_lossy(bytes).contains("after-resume-marker")) + }) + .await + .is_some(); + assert!( + saw_marker, + "resume_pane must leave the pane able to receive output again", + ); + + events.shutdown().await.expect("control mode shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// `Event::LayoutChanged` and a plain snapshot of `window.layout()` must +/// agree, byte for byte, on the same window at the same moment. +/// +/// Without asking for `new-layouts`, a control client keeps receiving the +/// classic layout string from `%layout-change` on a tmux that hands a plain +/// client JSON for the same window -- so the two would silently +/// disagree on 3.8+, and any code holding both an event stream and a snapshot +/// would be holding two incompatible forms of the same fact. +#[tokio::test] +async fn layout_events_and_snapshots_agree_on_format() { + use libtmux::{SplitDirection, SplitOptions}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("layout-agreement") + .await + .expect("session"); + let window = session + .windows() + .await + .expect("windows") + .into_iter() + .next() + .expect("one window"); + + let (_commands, mut events) = ControlMode::attach(server, session.id()) + .await + .expect("control mode attaches") + .split(); + + window + .split(SplitOptions::new(SplitDirection::Right).command("sleep 300")) + .await + .expect("the split changes the layout"); + + let event = wait_for(&mut events, |event| { + matches!(event, Event::LayoutChanged { window: reported, .. } if reported == window.id()) + }) + .await + .expect("the split reports a layout change"); + let Event::LayoutChanged { layout, .. } = event else { + unreachable!("wait_for only returns what its predicate matched"); + }; + + let mut refreshed = window; + refreshed.refresh().await.expect("the window still exists"); + assert_eq!( + layout.as_bytes(), + refreshed.layout().as_bytes(), + "the event and a plain snapshot must report the same layout form", + ); + + // Agreement alone would also hold if both happened to stay classic, so + // this pins which form 3.8+ actually agrees on. + let json_capable = server + .capabilities() + .await + .expect("capabilities read") + .tmux_version() + .has_behavior(&libtmux::since::JSON_LAYOUTS); + let starts_json = layout.as_bytes().starts_with(b"{"); + assert_eq!( + starts_json, json_capable, + "the agreed form must be JSON from since::JSON_LAYOUTS onward, classic below it", + ); + + events.shutdown().await.expect("control mode shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[tokio::test] async fn a_pane_is_watched_wherever_it_now_lives() { use libtmux::{JoinOptions, SplitDirection, SplitOptions}; From 953864df8548d352cb8490e8c2844bdb7028ef0c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 16 Sep 2026 18:55:13 -0500 Subject: [PATCH 024/117] Window(fix[layout]): Refuse a value that is not a layout before dispatch tmux 3.3 and 3.3a exit on a layout select-layout cannot parse, destroying every session on the socket. select_layout("-o") passed behind -- reached tmux as exactly that value; without the refusal the new test's server is gone on 3.3a (ServerGone on "-o"). A saved value must now be a preset name, a classic layout (four hex digits and a comma, as layout_parse reads it), or JSON. JSON below 3.8 is refused with UnsupportedCapability naming 3.8; anything else with Error::UnrecognizedLayout, which retains no input. LayoutSpec::Saved no longer claims every saved string restores the exact arrangement: only JSON carries pane ids. Pane::left and Pane::top make that checkable, and meets documents that it clamps a development build. --- crates/libtmux/docs/public-api.txt | 5 + crates/libtmux/src/error.rs | 11 ++ crates/libtmux/src/error/classification.rs | 3 +- crates/libtmux/src/pane.rs | 12 ++ crates/libtmux/src/version.rs | 29 +++ crates/libtmux/src/window.rs | 80 +++++++- crates/libtmux/tests/hierarchy.rs | 54 ++++++ crates/libtmux/tests/mutations.rs | 212 ++++++++++++++++++++- 8 files changed, 399 insertions(+), 7 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index abe506c6..b65d2e37 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -236,6 +236,7 @@ constant libtmux::since::CAPTURE_LINE_FLAGS: libtmux::ReleaseVersion constant libtmux::since::CAPTURE_TRIM_BLANK_CELLS: libtmux::ReleaseVersion constant libtmux::since::CLIENTS_HIDE_STOPPED: libtmux::ReleaseVersion constant libtmux::since::CONTROL_PANE_OFF: libtmux::ReleaseVersion +constant libtmux::since::JSON_LAYOUTS: libtmux::ReleaseVersion constant libtmux::since::MIRRORED_LAYOUTS: libtmux::ReleaseVersion constant libtmux::since::PANE_BORDER_FORMAT_PER_PANE: libtmux::ReleaseVersion constant libtmux::since::PANE_BORDER_STYLE_PER_PANE: libtmux::ReleaseVersion @@ -428,6 +429,7 @@ function libtmux::Pane::is_piped: fn(&self) -> bool function libtmux::Pane::is_synchronized: fn(&self) -> bool function libtmux::Pane::join_into: async fn(self, beside: &Self, options: libtmux::JoinOptions) -> Result function libtmux::Pane::kill: async fn(self) -> Result<(), libtmux::Error> +function libtmux::Pane::left: fn(&self) -> i32 function libtmux::Pane::option_names: async fn(&self) -> Result, libtmux::Error> function libtmux::Pane::options: async fn(&self) -> Result, libtmux::Error> function libtmux::Pane::paste_buffer: async fn(&self, name: Option<&str>) -> Result<(), libtmux::Error> @@ -453,6 +455,7 @@ function libtmux::Pane::stream_output_with_limits: async fn(&self, limits: libtm function libtmux::Pane::swap_with: async fn(&mut self, other: &Self) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::title: fn(&self) -> &libtmux::TmuxText function libtmux::Pane::toggle_zoom: async fn(&mut self) -> Result<&mut Self, libtmux::Error> +function libtmux::Pane::top: fn(&self) -> i32 function libtmux::Pane::tty: fn(&self) -> &libtmux::TmuxText function libtmux::Pane::typed_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Pane::unset_hook: async fn(&self, name: &str) -> Result<(), libtmux::Error> @@ -750,6 +753,7 @@ function libtmux::control::Event::pane: const fn(&self) -> Option<&libtmux::Pane function libtmux::control::Event::window: const fn(&self) -> Option<&libtmux::WindowId> function libtmux::control::PaneOutput::next_chunk: async fn(&mut self) -> Option> function libtmux::control::PaneOutput::pane: const fn(&self) -> &libtmux::PaneId +function libtmux::control::PaneOutput::sender: const fn(&self) -> &libtmux::control::ControlSender function libtmux::control::PaneOutput::shutdown: async fn(self) -> Result<(), libtmux::Error> function libtmux::control::PaneOutput::snapshot: async fn(&mut self, on_output: impl FnMut(&[u8])) -> Result, libtmux::Error> function libtmux::escape_format: fn(text: impl AsRef) -> std::ffi::OsString @@ -2192,6 +2196,7 @@ variant libtmux::Error::SupervisorLost variant libtmux::Error::Timeout variant libtmux::Error::UnreadableAccessRule variant libtmux::Error::UnreadableFormatValue +variant libtmux::Error::UnrecognizedLayout variant libtmux::Error::UnsupportedCapability variant libtmux::Error::UnsupportedTmuxVersion variant libtmux::Error::VersionProbeFailed diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index a5bd8db6..3410ece0 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -675,6 +675,16 @@ pub enum Error { operation: &'static str, }, + /// A saved layout value is not a preset name, a classic layout string, or + /// a JSON layout. + /// + /// Refused before dispatch rather than handed to tmux: 3.3 and 3.3a exit + /// on a layout `select-layout` cannot parse, destroying every session on + /// the socket, and a value such as `-o` is never a layout on any release. + /// The rejected value is not retained. + #[error("select-layout needs a preset name or a layout tmux reported")] + UnrecognizedLayout, + /// A plan has a dependency that cannot be resolved before dispatch. #[cfg(feature = "plan")] #[error("invalid plan: {source}")] @@ -1331,6 +1341,7 @@ impl fmt::Debug for Error { .field("declared", declared) .finish(), Self::RuntimeNested => formatter.debug_struct("RuntimeNested").finish(), + Self::UnrecognizedLayout => formatter.debug_struct("UnrecognizedLayout").finish(), Self::InvalidServerConfiguration { kind } => formatter .debug_struct("InvalidServerConfiguration") .field("kind", kind) diff --git a/crates/libtmux/src/error/classification.rs b/crates/libtmux/src/error/classification.rs index 38f9fd1f..044b572d 100644 --- a/crates/libtmux/src/error/classification.rs +++ b/crates/libtmux/src/error/classification.rs @@ -87,7 +87,7 @@ impl Error { | Self::RuntimeUnavailable { .. } => ErrorKind::Unreachable, // The call is wrong, not the environment: the same future awaited // directly would work. - Self::RuntimeNested => ErrorKind::InvalidInput, + Self::RuntimeNested | Self::UnrecognizedLayout => ErrorKind::InvalidInput, Self::UnsupportedTmuxVersion { .. } | Self::UnsupportedCapability { .. } | Self::CapabilityDefective { .. } => ErrorKind::UnsupportedVersion, @@ -191,6 +191,7 @@ impl Error { | Self::LinkGone { .. } | Self::RuntimeUnavailable { .. } | Self::RuntimeNested + | Self::UnrecognizedLayout | Self::ServerGone { kind: ServerGoneKind::Lost | ServerGoneKind::Stopped, .. diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 9499a9d4..5ee8cb92 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -218,6 +218,18 @@ impl Pane { *self.projection.pane().pane_height() } + /// Return the pane's left edge, in cells from its window's own left edge. + #[must_use] + pub fn left(&self) -> i32 { + *self.projection.pane().pane_left() + } + + /// Return the pane's top edge, in cells from its window's own top edge. + #[must_use] + pub fn top(&self) -> i32 { + *self.projection.pane().pane_top() + } + /// Report whether this pane is the active one in its window. #[must_use] pub fn is_active(&self) -> bool { diff --git a/crates/libtmux/src/version.rs b/crates/libtmux/src/version.rs index b3757e6c..d418cecb 100644 --- a/crates/libtmux/src/version.rs +++ b/crates/libtmux/src/version.rs @@ -448,6 +448,23 @@ impl TmuxVersion { /// assert!(version.meets(&TmuxVersion::MIN_SUPPORTED)); /// # Ok::<(), libtmux::Error>(()) /// ``` + /// + /// The English verb suggests this answers "is a capability available", + /// but on a development build it can refuse one that is already there: + /// + /// ``` + /// use libtmux::{ReleaseSuffix, ReleaseVersion, TmuxVersion}; + /// + /// let next = TmuxVersion::parse_output(b"tmux next-3.9\n")?; + /// let capture_line_flags = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL); + /// + /// // `next-3.9`'s tree already has 3.7's behavior, but this clamps every + /// // development identifier to the crate's floor, so it says no anyway. + /// // `TmuxVersion::has_behavior` is the question that says yes. + /// assert!(!next.meets(&capture_line_flags)); + /// assert!(next.has_behavior(&capture_line_flags)); + /// # Ok::<(), libtmux::Error>(()) + /// ``` #[must_use] pub fn meets(&self, required: &ReleaseVersion) -> bool { match self.release { @@ -753,4 +770,16 @@ pub mod since { /// Below this release `layout_set_lookup` does not carry those names, and /// tmux refuses one as it would a typo. pub const MIRRORED_LAYOUTS: ReleaseVersion = ReleaseVersion::new(3, 5, ReleaseSuffix::FINAL); + + /// `window_layout` and `select-layout` using a JSON subset instead of the + /// classic checksum-prefixed string, and so [`crate::LayoutSpec::Saved`] + /// accepting one. + /// + /// tmux's own `CHANGES FROM 3.7c TO 3.8` names the release: "Layout + /// strings now use a JSON subset format ... The old format is still + /// accepted; control mode clients receive old layouts unless they set the + /// new-layouts flag." Below this release a JSON string is refused as an + /// unrecognised layout, with no hint that the value is simply from a + /// newer tmux. + pub const JSON_LAYOUTS: ReleaseVersion = ReleaseVersion::new(3, 8, ReleaseSuffix::FINAL); } diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index b02a33c1..0d7923d3 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -467,7 +467,28 @@ impl Window { .await?; OsString::from(named.as_str()) } - LayoutSpec::Saved(saved) => saved.clone(), + LayoutSpec::Saved(saved) => { + // Checked here rather than left to tmux: 3.3 and 3.3a exit on + // a value `select-layout` cannot parse, taking every session on + // the socket with them, and `--` does not help -- it turns + // `-o` from the undo flag into exactly such a value. + let server = crate::Server::from_core(Arc::clone(&self.core)); + match SavedLayout::classify(saved) { + SavedLayout::Preset(named) => { + server + .require(named.as_str(), named.minimum_release()) + .await?; + } + SavedLayout::Classic => {} + SavedLayout::Json => { + server + .require("a JSON layout string", crate::version::since::JSON_LAYOUTS) + .await?; + } + SavedLayout::Unrecognized => return Err(Error::UnrecognizedLayout), + } + saved.clone() + } }; listing::mutate( @@ -1250,8 +1271,18 @@ impl fmt::Display for Layout { /// /// A [`Layout`] names an arrangement tmux computes. A saved string is one /// tmux already computed: [`Window::layout`] reports one, and handing it back -/// restores that exact arrangement including the pane sizes, which a named -/// layout cannot express. +/// restores the pane sizes, which a named layout cannot express. +/// +/// From [`crate::since::JSON_LAYOUTS`] (3.8) a saved string carries each +/// pane's id, so the restored arrangement is byte-exact, process for process. +/// Below that release the classic checksum-prefixed string carries only +/// sizes and positions: the shape returns, but which pane lands in which cell +/// depends on whether the live pane list happens to already be in the saved +/// cell order, which a mirrored or otherwise asymmetric layout can defeat. A +/// saved string from 3.8+ is refused below it with +/// [`crate::ErrorKind::UnsupportedVersion`], and a value that is none of a +/// preset name, a classic string, or JSON with +/// [`crate::ErrorKind::InvalidInput`], both before dispatch. /// /// # Examples /// @@ -1335,6 +1366,49 @@ impl From<&TmuxText> for LayoutSpec { } } +/// The shape of a saved layout value, read before it reaches tmux. +enum SavedLayout { + /// A preset name passed as text rather than as a [`Layout`]. + Preset(Layout), + /// tmux's checksum-prefixed string: four hex digits and a comma. + Classic, + /// The JSON form tmux reports from [`crate::since::JSON_LAYOUTS`]. + Json, + /// Nothing tmux ever reported, and nothing `select-layout` can parse. + Unrecognized, +} + +impl SavedLayout { + fn classify(saved: &OsStr) -> Self { + let Some(text) = saved.to_str() else { + return Self::Unrecognized; + }; + let presets = [ + Layout::EvenHorizontal, + Layout::EvenVertical, + Layout::MainHorizontal, + Layout::MainHorizontalMirrored, + Layout::MainVertical, + Layout::MainVerticalMirrored, + Layout::Tiled, + ]; + if let Some(named) = presets.into_iter().find(|named| named.as_str() == text) { + return Self::Preset(named); + } + // `layout_parse` reads `%hx,` and insists it consumed exactly five + // bytes; tmux itself always writes the checksum as four lowercase + // digits. + let bytes = text.as_bytes(); + if bytes.len() > 5 && bytes[..4].iter().all(u8::is_ascii_hexdigit) && bytes[4] == b',' { + return Self::Classic; + } + if text.starts_with('{') { + return Self::Json; + } + Self::Unrecognized + } +} + /// Where a split puts the pane it makes. /// /// # Examples diff --git a/crates/libtmux/tests/hierarchy.rs b/crates/libtmux/tests/hierarchy.rs index 5dc69573..ab1e81dc 100644 --- a/crates/libtmux/tests/hierarchy.rs +++ b/crates/libtmux/tests/hierarchy.rs @@ -230,6 +230,60 @@ async fn panes_report_their_window_and_process_details() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// `Pane::left`/`Pane::top` must agree with tmux's own `#{pane_left}` and +/// `#{pane_top}`, and actually separate two panes a horizontal split placed +/// side by side. +#[tokio::test] +async fn pane_position_matches_the_raw_format_fields() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + + new_session(server, "positions").await; + run( + server, + Command::new("split-window") + .arg("-t") + .arg("positions") + .arg("-h") + .arg("-d") + .arg("sleep 300"), + ) + .await; + + let panes = server.panes().await.expect("panes list"); + assert_eq!(panes.len(), 2, "the split produced a second pane"); + + for pane in &panes { + let left = pane + .format("#{pane_left}") + .await + .expect("pane_left reads") + .as_str() + .expect("pane_left is ASCII") + .parse::() + .expect("pane_left is an integer"); + let top = pane + .format("#{pane_top}") + .await + .expect("pane_top reads") + .as_str() + .expect("pane_top is ASCII") + .parse::() + .expect("pane_top is an integer"); + assert_eq!(pane.left(), left, "Pane::left must match #{{pane_left}}"); + assert_eq!(pane.top(), top, "Pane::top must match #{{pane_top}}"); + } + + // A horizontal split places one pane's left edge at 0 and the other's + // strictly to its right, both at the same top. + let lefts: Vec = panes.iter().map(Pane::left).collect(); + assert_eq!(lefts.iter().min().copied(), Some(0)); + assert_ne!(lefts[0], lefts[1], "a horizontal split separates the panes"); + assert_eq!(panes[0].top(), panes[1].top()); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[tokio::test] async fn traversal_scopes_each_level_to_its_parent() { let guard = TestServer::builder().start().await.expect("tmux starts"); diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index e5b2a554..5cf69af3 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -8,7 +8,7 @@ use std::time::Duration; use libtmux::test::{TestServer, retry_until}; -use libtmux::{Layout, NewSessionOptions, NewWindowOptions}; +use libtmux::{ErrorKind, Layout, NewSessionOptions, NewWindowOptions}; use libtmux::{SplitDirection, SplitOptions, TmuxText}; fn text(value: Option<&TmuxText>) -> Vec { @@ -618,7 +618,7 @@ async fn a_line_send_redacts_input_and_classifies_a_gone_pane() { let secret = "sentinel-line-secret"; let error = stale.send_line(secret).await.expect_err("the pane is gone"); - assert_eq!(error.kind(), libtmux::ErrorKind::ObjectGone); + assert_eq!(error.kind(), ErrorKind::ObjectGone); let diagnostic = format!("{error:?} {error}"); assert!(!diagnostic.contains(secret), "{diagnostic}"); @@ -1244,7 +1244,7 @@ async fn a_taken_session_name_is_classified_rather_than_a_bare_refusal() { matches!(&error, libtmux::Error::SessionExists { name } if name == "taken"), "the refusal names what was taken: {error:?}", ); - assert_eq!(error.kind(), libtmux::ErrorKind::Refused); + assert_eq!(error.kind(), ErrorKind::Refused); // The first session is untouched by the refusal. assert_eq!(server.sessions().await.expect("sessions").len(), 1); @@ -1416,6 +1416,212 @@ async fn a_layout_is_named_saved_or_stepped_through() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A saved layout with more than two panes restores each pane's own position +/// only where tmux reports the layout as JSON. +/// +/// `LayoutSpec::Saved`'s doc says a saved string restores "the exact +/// arrangement, including which process ended up where" only from +/// `since::JSON_LAYOUTS` (3.8) onward; below it, the classic checksum-prefixed +/// string reconstructs the shape but can hand two same-sized cells to each +/// other's panes. This is tmux's own limitation, matching what the Python +/// and Go ports found for a saved layout's pane identity, pinned here +/// per-version rather than asserted as always true or always false. +#[tokio::test] +async fn real_tmux_compat_saved_layout_pane_identity_needs_json() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("layout-identity") + .await + .expect("session"); + let mut window = session + .active_window() + .await + .expect("windows") + .expect("a window"); + + for _ in 0..3 { + window + .split(SplitOptions::new(SplitDirection::Below)) + .await + .expect("a pane is added"); + } + // Mirrored presets arrived in 3.5; the unmirrored one is asymmetric too. + let asymmetric = if server + .capabilities() + .await + .expect("capabilities read") + .tmux_version() + .has_behavior(&libtmux::since::MIRRORED_LAYOUTS) + { + Layout::MainVerticalMirrored + } else { + Layout::MainVertical + }; + window + .select_layout(asymmetric) + .await + .expect("tmux arranges four panes asymmetrically"); + + let saved = window.layout().to_owned(); + let before: Vec<(i32, i32, String)> = window + .panes() + .await + .expect("panes list") + .iter() + .map(|pane| (pane.left(), pane.top(), pane.id().to_string())) + .collect(); + + window + .select_layout(Layout::Tiled) + .await + .expect("tmux rearranges the panes"); + window + .select_layout(&saved) + .await + .expect("tmux restores the saved layout"); + + let after: Vec<(i32, i32, String)> = window + .panes() + .await + .expect("panes list") + .iter() + .map(|pane| (pane.left(), pane.top(), pane.id().to_string())) + .collect(); + + let json_capable = server + .capabilities() + .await + .expect("capabilities read") + .tmux_version() + .has_behavior(&libtmux::since::JSON_LAYOUTS); + + if json_capable { + assert_eq!( + window.layout().as_bytes(), + saved.as_bytes(), + "a JSON layout restores byte-exact, pane ids included", + ); + assert_eq!( + after, before, + "each pane returns to the exact cell it saved", + ); + } else { + // Below 3.8 the shape always comes back; whether each pane's own id + // returns to its own cell depends on whether the live pane-list order + // happens to match the saved cell order, which this does not assert + // either way -- only that the crate does not overclaim it for this + // arrangement, which mirrored layouts are chosen to stress. Compared + // as a multiset, since which pane reports which coordinate first is + // exactly what is not being asserted here. + let mut before_shape: Vec<(i32, i32)> = + before.iter().map(|(left, top, _)| (*left, *top)).collect(); + let mut after_shape: Vec<(i32, i32)> = + after.iter().map(|(left, top, _)| (*left, *top)).collect(); + before_shape.sort_unstable(); + after_shape.sort_unstable(); + assert_eq!( + after_shape, before_shape, + "the geometry is restored either way" + ); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A value that is not a layout is refused before it reaches tmux. +/// +/// tmux 3.3 and 3.3a exit on a layout `select-layout` cannot parse, taking +/// every session on the socket with them, and `--` alone turns `-o` from the +/// undo flag into exactly such a value. The session surviving is what fails +/// there without the refusal. +#[tokio::test] +async fn a_value_that_is_not_a_layout_is_refused_before_dispatch() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("not-a-layout").await.expect("session"); + let mut window = session + .active_window() + .await + .expect("windows") + .expect("a window"); + + for value in ["-o", "garbage", "", "next", "0000", "zzzz,80x24,0,0,0"] { + let error = window + .select_layout(value) + .await + .expect_err("a value that is not a layout is refused"); + assert_eq!(error.kind(), ErrorKind::InvalidInput, "{value:?}: {error}"); + } + assert!( + server + .has_session("not-a-layout") + .await + .expect("tmux still answers"), + "the session survives every refused value", + ); + window + .select_layout("tiled") + .await + .expect("a preset name passed as text still applies"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A JSON-looking saved layout on an old tmux is refused with a version +/// hint, not just tmux's generic "invalid layout". +#[tokio::test] +async fn a_json_layout_on_an_old_tmux_names_the_version_it_needs() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("json-layout").await.expect("session"); + let mut window = session + .active_window() + .await + .expect("windows") + .expect("a window"); + + let json_capable = server + .capabilities() + .await + .expect("capabilities read") + .tmux_version() + .has_behavior(&libtmux::since::JSON_LAYOUTS); + + let error = window + .select_layout(r#"{"V":2,"L":[]}"#) + .await + .expect_err("a bare JSON skeleton is not a real layout either way"); + let message = error.to_string(); + if json_capable { + // This tmux understands the form; whatever refuses it is tmux's own + // validation of the content, not a version gate. + assert!( + !message.contains("needs tmux"), + "a JSON-capable tmux is not refused for its version: {message}", + ); + } else { + assert!( + message.contains("needs tmux") && message.contains("3.8"), + "an old tmux names the release a JSON layout needs: {message}", + ); + } + + // A classic-looking garbage string never mentions a version: it is + // refused for its content on every release, the same way named layouts + // and other malformed strings already are. + let classic_error = window + .select_layout("not-a-real-layout") + .await + .expect_err("garbage is refused"); + assert!( + !classic_error.to_string().contains("needs tmux"), + "a non-JSON refusal never claims a version floor: {classic_error}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A pane broken out into its own window can be put back. /// /// `break_out` moves a pane away and `join_into` moves one back, so between From a6dc5d416e7e68dc400556472910bd18290a6beb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 16 Sep 2026 18:55:13 -0500 Subject: [PATCH 025/117] MCP(fix[wait]): Report a pattern already on screen as present_at_entry send_keys then wait_for_text for text the typed line contains could match the shell's echo. A pattern on screen once the stream is attached now returns outcome present_at_entry immediately rather than a flag on a result that waited out its deadline. Attaching still comes first, so a pattern landing between the attach and the capture is reported rather than lost. A deadline whose buffer already holds a match reports matched. The echo test types without Enter and waits for the echo before calling wait_for_text; without the early return it reports deadline. --- crates/tmux-mcp/TOOLS.md | 2 +- crates/tmux-mcp/src/exec.rs | 114 +++++++++++++++++++++------ crates/tmux-mcp/src/exec/tests.rs | 35 +++++++- crates/tmux-mcp/src/tools/observe.rs | 11 ++- crates/tmux-mcp/tests/agent.rs | 50 ++++++++++++ 5 files changed, 180 insertions(+), 32 deletions(-) diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 598ca6bf..073a8cc3 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -711,7 +711,7 @@ Change tmux state; no client-supplied executable input. Block until something si ## `wait_for_text` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. +Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index 9e303aee..5767be9e 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -76,7 +76,17 @@ pub enum RunOutcome { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WaitOutcome { - /// A pattern matched. + /// A wanted pattern was already on the pane's screen before this began + /// watching, rather than in output that arrived afterward. + /// + /// A wait only sees what a pane writes after it starts, so this is never + /// folded into [`Self::Matched`]: the same pattern sent moments earlier + /// with `send_keys` can already be sitting there as the shell's own echo + /// of the typed command, and a caller that treated that as a fresh match + /// would act before the command it sent had necessarily run. Choosing a + /// pattern not already visible waits for one that has not happened yet. + PresentAtEntry, + /// A pattern matched, in output that arrived after the wait attached. Matched, /// A stop pattern matched, so the wait ended early. Stopped, @@ -120,12 +130,6 @@ pub struct WaitView { pub matched_index: Option, /// The pattern that matched, as it was given. pub matched_pattern: Option, - /// Whether a success pattern was already on screen when the wait began. - /// - /// A wait only sees what a pane writes after it starts, so a pattern - /// already present will not match. This says so, rather than leaving the - /// caller to wait out the deadline wondering. - pub present_at_entry: bool, /// What the pane wrote, with escape sequences removed. pub text: String, /// How many bytes arrived, before filtering or truncation. @@ -251,35 +255,67 @@ pub(crate) async fn wait_for_text_with_limits( limits: ControlLimits, ) -> Result { // Attached first: a pattern that arrives while the screen is being read - // must still be seen. + // must still be seen. Reading first would lose one that landed between + // the capture and the attach, and wait out the deadline over output that + // did arrive. One landing in that gap is reported as present at entry + // instead, which is still true of the screen. let output = pane.stream_output_with_limits(limits).await?; - wait_on_output(pane, output, patterns, stops, timeout, cancelled).await + if let Some(view) = read_present_at_entry(pane, patterns).await? { + // The answer is already in hand; a failure closing a stream nothing + // read does not change it. + let _ = output.shutdown().await; + return Ok(view); + } + wait_on_output(output, patterns, stops, timeout, cancelled).await +} + +/// Report a wanted pattern already on the pane's screen, before any stream +/// attaches to watch for one arriving. +/// +/// See [`WaitOutcome::PresentAtEntry`] for why this is a distinct outcome +/// from [`WaitOutcome::Matched`] rather than a flag alongside it. +async fn read_present_at_entry( + pane: &Pane, + patterns: &Patterns, +) -> Result, Error> { + // No patterns means "wait for anything at all", which nothing already on + // screen can pre-empt: there is nothing yet to call present. + if patterns.is_empty() { + return Ok(None); + } + + // A screen that cannot be read is not a reason to refuse to wait; the + // same failure surfaces from the attach right after this. + let Ok(lines) = pane.capture_with(CaptureOptions::visible()).await else { + return Ok(None); + }; + let mut screen = Vec::new(); + for line in &lines { + screen.extend_from_slice(line.as_bytes()); + screen.push(b'\n'); + } + let Some((index, source)) = patterns.first_match(&screen) else { + return Ok(None); + }; + + Ok(Some(WaitView { + pane: pane.id().to_string(), + outcome: WaitOutcome::PresentAtEntry, + matched_index: Some(index), + matched_pattern: Some(source.to_owned()), + text: String::from_utf8_lossy(&screen).into_owned(), + bytes: screen.len(), + })) } /// The read loop [`wait_for_text_with_limits`] runs once attached. async fn wait_on_output( - pane: &Pane, mut output: libtmux::control::PaneOutput, patterns: &Patterns, stops: &Patterns, timeout: Duration, cancelled: &CancellationToken, ) -> Result { - // What is already on screen will never match, because a stream only - // carries what comes next. Saying so is cheaper than a wasted deadline. - let present_at_entry = match pane.capture_with(CaptureOptions::visible()).await { - Ok(lines) => { - let mut screen = Vec::new(); - for line in &lines { - screen.extend_from_slice(line.as_bytes()); - screen.push(b'\n'); - } - patterns.first_match(&screen).is_some() - } - // A screen that cannot be read is not a reason to refuse to wait. - Err(_) => false, - }; - let mut filter = TextFilter::new(); let mut text: Vec = Vec::new(); let mut bytes = 0usize; @@ -353,16 +389,42 @@ async fn wait_on_output( return Err(error); } + // A chunk can arrive in the same instant the deadline elapses; without + // this, that race would report `Deadline` while `text` already holds a + // match, which is exactly the shape DOTNET-10 hit in another port. + let (outcome, matched_index, matched_pattern) = + reconcile_deadline(outcome, matched_index, matched_pattern, patterns, &text); + Ok(WaitView { pane: pane_id, outcome, matched_index, matched_pattern, - present_at_entry, text: String::from_utf8_lossy(&text).into_owned(), bytes, }) } +/// Reclassify a timed-out wait as matched when the buffer it is about to +/// report already contains a pattern. +/// +/// Only `Deadline` is reconsidered: `Stopped`, `PaneClosed`, and `Cancelled` +/// already carry their own reason and are returned unchanged. +fn reconcile_deadline( + outcome: WaitOutcome, + matched_index: Option, + matched_pattern: Option, + patterns: &Patterns, + text: &[u8], +) -> (WaitOutcome, Option, Option) { + if !matches!(outcome, WaitOutcome::Deadline) { + return (outcome, matched_index, matched_pattern); + } + match patterns.first_match(text) { + Some((index, source)) => (WaitOutcome::Matched, Some(index), Some(source.to_owned())), + None => (outcome, matched_index, matched_pattern), + } +} + #[cfg(test)] mod tests; diff --git a/crates/tmux-mcp/src/exec/tests.rs b/crates/tmux-mcp/src/exec/tests.rs index e9d7be50..97f23a1c 100644 --- a/crates/tmux-mcp/src/exec/tests.rs +++ b/crates/tmux-mcp/src/exec/tests.rs @@ -17,6 +17,40 @@ fn finding_a_needle_reports_where_it_starts() { assert_eq!(find(b"abc", b""), None); } +/// A wait that timed out must not report `Deadline` over a buffer that +/// already contains a match (a timeout report holding the matched text in +/// its own `tail`). +#[test] +fn reconcile_deadline_promotes_a_match_the_buffer_already_holds() { + let patterns = Patterns::compile(&["MARK".to_owned()], false, false).expect("pattern compiles"); + + let (outcome, index, pattern) = + reconcile_deadline(WaitOutcome::Deadline, None, None, &patterns, b"...MARK..."); + assert_eq!(outcome, WaitOutcome::Matched); + assert_eq!(index, Some(0)); + assert_eq!(pattern, Some("MARK".to_owned())); + + // A genuine timeout with nothing to reclassify stays a timeout. + let (outcome, index, pattern) = reconcile_deadline( + WaitOutcome::Deadline, + None, + None, + &patterns, + b"nothing here", + ); + assert_eq!(outcome, WaitOutcome::Deadline); + assert_eq!(index, None); + assert_eq!(pattern, None); + + // A terminal outcome that already carries its own reason is untouched, + // even when the buffer also happens to contain a wanted pattern. + let (outcome, index, pattern) = + reconcile_deadline(WaitOutcome::Cancelled, None, None, &patterns, b"...MARK..."); + assert_eq!(outcome, WaitOutcome::Cancelled); + assert_eq!(index, None); + assert_eq!(pattern, None); +} + #[test] fn shell_words_preserve_raw_bytes_and_split_apostrophes() { for (input, expected) in [ @@ -986,7 +1020,6 @@ async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() let cancelled = CancellationToken::new(); let error = wait_on_output( - &pane, output, &patterns, &stops, diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index 4f568311..6cb0efb7 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -334,10 +334,13 @@ impl TmuxTools { stream, so text that scrolls past between checks is still seen. Prefer \ run_shell_command for commands you are sending yourself: it reports an exit \ status instead of guessing from output. Use this for output you did \ - not author, such as a server logging that it is ready. The live stream \ - attaches a client while waiting, changing the session's attached-client \ - state. Each list accepts at most 32 patterns, each at most 4,096 bytes, \ - using Rust's linear-time regex engine.", + not author, such as a server logging that it is ready. A pattern that is a \ + substring of a command you just sent with send_keys can already be on \ + screen as its echo; outcome present_at_entry reports that rather than \ + matched, so a still-pending command does not read as already done. The \ + live stream attaches a client while waiting, changing the session's \ + attached-client state. Each list accepts at most 32 patterns, each at most \ + 4,096 bytes, using Rust's linear-time regex engine.", title = "Wait For Pane Text", meta = crate::capability_meta!( Inspect, None, diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index d0a8a26d..3af36d8b 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -2758,6 +2758,56 @@ async fn wait_and_cursor_tools_observe_live_output() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// `send_keys` then `wait_for_text` for a pattern the typed line's own echo +/// already shows must not report `matched` (RS-4). +/// +/// The line is typed without Enter and the wait begins only once the echo is +/// on screen, so the pattern is present at entry on every run rather than on +/// whichever runs the shell happened to echo first. +#[tokio::test] +async fn send_then_wait_does_not_match_the_commands_own_echo() { + let (guard, tools, pane) = typing_fixture("send-then-wait").await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo MCPMARKER", + "enter": false + }))) + .await + .expect("input is sent"); + let server = guard.server(); + libtmux::test::retry_until(Duration::from_secs(5), async || { + server + .cmd(Command::new("capture-pane").arg("-p").arg("-t").arg(&pane)) + .await + .is_ok_and(|captured| captured.stdout_lossy().contains("MCPMARKER")) + }) + .await + .expect("the shell echoes the typed line"); + + let waited = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MCPMARKER"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait answers"), + ); + assert_eq!( + waited["outcome"], "present_at_entry", + "a pattern already on screen before the wait attached must not read as a fresh match: {waited}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[tokio::test] async fn search_snapshot_and_configuration_reads_are_structured() { let (guard, tools, pane) = typing_fixture("inspect").await; From 7a59a9e28a85fabecbb615748c9dffabb578255d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 16 Sep 2026 18:55:13 -0500 Subject: [PATCH 026/117] Docs(fix[notes]): Record 3.2a's ignored size, the capture race, changelog NewSessionOptions::size has no effect on a detached session's first window on tmux 3.2a, and a capture right after wait_for_text can miss the redraw that satisfied it; both are tmux behaviour, now documented. --- crates/libtmux/src/pane/observe.rs | 8 ++++++++ crates/libtmux/src/server.rs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index 0ea8b60b..b92803e1 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -257,6 +257,14 @@ impl Pane { /// A pane whose process ends answers [`PaneWait::Dead`] rather than /// running to the deadline, because waiting longer cannot change it. /// + /// A [`Pane::capture`] called immediately after this returns can + /// occasionally miss the very output that satisfied the wait: tmux's own + /// redraw of the screen a fast, multi-byte-heavy write produced can still + /// be in flight when the next `capture-pane` reads it, independent of + /// this crate. Raw tmux shows the same gap running the equivalent + /// `send-keys`/`capture-pane` sequence directly. A caller sensitive to + /// this should retry the capture rather than trust it on the first look. + /// /// # Errors /// /// Returns an error when tmux cannot be reached or refuses a capture. diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 70475bb8..077a3532 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1982,6 +1982,12 @@ impl NewSessionOptions { } /// Set the initial size, which a detached session would otherwise default. + /// + /// tmux 3.2a accepts this and sets `default-size` as asked, but still + /// draws the new, client-less window at its own classic default, + /// `80x23`: a pane's reported width and height on 3.2a do not reflect + /// this call, even though the option was set. Every later release draws + /// at the requested size. pub fn size(mut self, width: u32, height: u32) -> Self { self.width = Some(width); self.height = Some(height); From 55f12d58b3cd1287b317b1b3cf2407f69bec8445 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 17:22:53 -0500 Subject: [PATCH 027/117] Window(fix[layout]): Accept a unique preset prefix, refuse an ambiguous one layout_set_lookup is a prefix match, so raw tmux applies "tile" and "even-h" on every supported release; SavedLayout::classify only matched the full preset name, so both were refused as "not a layout". Prefix candidates are filtered to the presets the running release actually has, so "main-h" is unique before 3.5 and ambiguous from it (Error::AmbiguousLayout, naming the candidates), and an empty string never reads as "ambiguous among all seven". Factored the guard into Window::validate_saved_layout so a caller other than Window::select_layout can reach the same refusal: plan::ops::SelectLayout bypassed it entirely, and any tmux-workspace file with a bad layout: value took down the whole server building one session, not just that session. Plan::run now validates every recorded SelectLayout before its first command, the same way it already validates option scopes. Plan::run_over_control_mode does not get this check: that connection carries no server to probe a version against. tmux 3.3 and 3.3a exit on a select-layout value they cannot parse, taking every session on the socket with them; verified against the pinned 3.3a binary that the fixed paths never dispatch such a value. --- crates/libtmux/docs/public-api.txt | 3 + crates/libtmux/src/error.rs | 24 ++++ crates/libtmux/src/error/classification.rs | 5 +- crates/libtmux/src/plan/ops/windows.rs | 9 ++ crates/libtmux/src/plan/run.rs | 28 ++++- crates/libtmux/src/window.rs | 128 ++++++++++++++++----- crates/libtmux/tests/mutations.rs | 78 +++++++++++++ crates/libtmux/tests/plan.rs | 45 +++++++- 8 files changed, 285 insertions(+), 35 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index b65d2e37..d8359a8d 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -1869,6 +1869,8 @@ struct_field libtmux::ClientFields::client_written: libtmux::query::IntegerField struct_field libtmux::EnvironmentEntry::Set::0: libtmux::TmuxText struct_field libtmux::Error::AfterEffect::operation: &'static str struct_field libtmux::Error::AfterEffect::source: Box +struct_field libtmux::Error::AmbiguousLayout::candidates: Vec<&'static str> +struct_field libtmux::Error::AmbiguousLayout::input: String struct_field libtmux::Error::CapabilityDefective::broken_in: libtmux::ReleaseVersion struct_field libtmux::Error::CapabilityDefective::capability: &'static str struct_field libtmux::Error::CapabilityDefective::fixed_in: libtmux::ReleaseVersion @@ -2165,6 +2167,7 @@ variant libtmux::ControlModeErrorKind::UnrepresentableCommand variant libtmux::EnvironmentEntry::Removed variant libtmux::EnvironmentEntry::Set variant libtmux::Error::AfterEffect +variant libtmux::Error::AmbiguousLayout variant libtmux::Error::CapabilityDefective variant libtmux::Error::ClientSuspended variant libtmux::Error::CommandFailed diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index 3410ece0..48cb2331 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -685,6 +685,25 @@ pub enum Error { #[error("select-layout needs a preset name or a layout tmux reported")] UnrecognizedLayout, + /// A saved layout value is a preset prefix that names more than one + /// preset on the running tmux release. + /// + /// tmux's own `layout_set_lookup` accepts a unique prefix (`tile` and + /// `even-h` both apply cleanly on every supported release), so refusing + /// every prefix would reject values tmux itself understands. An + /// ambiguous one is refused before dispatch instead of leaving tmux to + /// pick one silently. The candidates are only the presets available on + /// the running release: `main-h` is unique on tmux 3.2a and ambiguous + /// from 3.5, where the mirrored pair exists. + #[non_exhaustive] + #[error("layout {input:?} names more than one preset: {}", candidates.join(", "))] + AmbiguousLayout { + /// The value the caller passed. + input: String, + /// The preset names it could mean. + candidates: Vec<&'static str>, + }, + /// A plan has a dependency that cannot be resolved before dispatch. #[cfg(feature = "plan")] #[error("invalid plan: {source}")] @@ -1342,6 +1361,11 @@ impl fmt::Debug for Error { .finish(), Self::RuntimeNested => formatter.debug_struct("RuntimeNested").finish(), Self::UnrecognizedLayout => formatter.debug_struct("UnrecognizedLayout").finish(), + Self::AmbiguousLayout { input, candidates } => formatter + .debug_struct("AmbiguousLayout") + .field("input", input) + .field("candidates", candidates) + .finish(), Self::InvalidServerConfiguration { kind } => formatter .debug_struct("InvalidServerConfiguration") .field("kind", kind) diff --git a/crates/libtmux/src/error/classification.rs b/crates/libtmux/src/error/classification.rs index 044b572d..877bc783 100644 --- a/crates/libtmux/src/error/classification.rs +++ b/crates/libtmux/src/error/classification.rs @@ -87,7 +87,9 @@ impl Error { | Self::RuntimeUnavailable { .. } => ErrorKind::Unreachable, // The call is wrong, not the environment: the same future awaited // directly would work. - Self::RuntimeNested | Self::UnrecognizedLayout => ErrorKind::InvalidInput, + Self::RuntimeNested | Self::UnrecognizedLayout | Self::AmbiguousLayout { .. } => { + ErrorKind::InvalidInput + } Self::UnsupportedTmuxVersion { .. } | Self::UnsupportedCapability { .. } | Self::CapabilityDefective { .. } => ErrorKind::UnsupportedVersion, @@ -192,6 +194,7 @@ impl Error { | Self::RuntimeUnavailable { .. } | Self::RuntimeNested | Self::UnrecognizedLayout + | Self::AmbiguousLayout { .. } | Self::ServerGone { kind: ServerGoneKind::Lost | ServerGoneKind::Stopped, .. diff --git a/crates/libtmux/src/plan/ops/windows.rs b/crates/libtmux/src/plan/ops/windows.rs index f0c1e833..aba65fe2 100644 --- a/crates/libtmux/src/plan/ops/windows.rs +++ b/crates/libtmux/src/plan/ops/windows.rs @@ -371,6 +371,15 @@ impl SelectLayout { .arg(self.layout.clone()), ) } + + /// The layout value this operation would send, unvalidated. + /// + /// `render` has no server to check it against; the validation lives in + /// [`crate::plan::Plan::run`], before the plan's first command, which is + /// what this exists for. + pub(crate) fn layout(&self) -> &std::ffi::OsStr { + &self.layout + } } operation!( diff --git a/crates/libtmux/src/plan/run.rs b/crates/libtmux/src/plan/run.rs index dc71480b..1dbd5bc5 100644 --- a/crates/libtmux/src/plan/run.rs +++ b/crates/libtmux/src/plan/run.rs @@ -17,7 +17,8 @@ use super::{Op, OperationKind, Part, Plan, Scope, Step}; use crate::error::ListingDecodeError; use crate::formats::FormatCodecError; use crate::{ - Command, CommandChain, Error, IdParseError, PaneId, Server, SessionId, TmuxText, WindowId, + Command, CommandChain, Error, IdParseError, PaneId, Server, SessionId, TmuxText, Window, + WindowId, }; /// How an operation ended. @@ -299,6 +300,7 @@ impl Plan { self.validate() .map_err(|source| Error::InvalidPlan { source })?; self.validate_option_scopes()?; + self.validate_layouts(server).await?; let steps = planner.steps(self); let mut bound: HashMap<(usize, Part), OsString> = HashMap::new(); let mut outcomes = vec![Outcome::Skipped; self.len()]; @@ -436,6 +438,25 @@ impl Plan { Ok(()) } + /// Refuse a `select-layout` value that `select-layout` itself cannot + /// parse. + /// + /// A plan renders its own commands, so a recorded + /// [`super::ops::SelectLayout`] reaches tmux without passing + /// [`Window::select_layout`]'s guard: 3.3 and 3.3a exit on a layout + /// value they cannot parse, taking every session on the socket with + /// them. Checked here, alongside `validate_option_scopes`, rather than + /// in `render`, which has no server to check a preset's version floor + /// against. Before the first command either way. + async fn validate_layouts(&self, server: &Server) -> Result<(), Error> { + for operation in self.steps() { + if let Op::SelectLayout(select) = operation { + Window::validate_saved_layout(server, select.layout()).await?; + } + } + Ok(()) + } + /// Lower one invocation's operations into commands. fn render_step( &self, @@ -701,6 +722,11 @@ impl Plan { /// written, a slot dependency is invalid, or a creating operation does not /// return valid IDs. Validation happens before the first command. A command /// tmux refuses is reported in the [`PlanResult`]. + /// + /// A [`super::ops::SelectLayout`] here does not get [`Self::run`]'s + /// `select-layout` guard: that check needs a version probe, and this + /// connection carries no [`Server`] to run one against. A layout value + /// this cannot parse still reaches tmux directly. pub async fn run_over_control_mode( &self, sender: &crate::control::ControlSender, diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 0d7923d3..f1d008e2 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -468,26 +468,8 @@ impl Window { OsString::from(named.as_str()) } LayoutSpec::Saved(saved) => { - // Checked here rather than left to tmux: 3.3 and 3.3a exit on - // a value `select-layout` cannot parse, taking every session on - // the socket with them, and `--` does not help -- it turns - // `-o` from the undo flag into exactly such a value. let server = crate::Server::from_core(Arc::clone(&self.core)); - match SavedLayout::classify(saved) { - SavedLayout::Preset(named) => { - server - .require(named.as_str(), named.minimum_release()) - .await?; - } - SavedLayout::Classic => {} - SavedLayout::Json => { - server - .require("a JSON layout string", crate::version::since::JSON_LAYOUTS) - .await?; - } - SavedLayout::Unrecognized => return Err(Error::UnrecognizedLayout), - } - saved.clone() + Self::validate_saved_layout(&server, saved).await? } }; @@ -508,6 +490,60 @@ impl Window { Ok(self) } + /// Validate a saved layout string before it reaches tmux, and return the + /// argument to send. + /// + /// The guard [`Self::select_layout`] applies to a [`LayoutSpec::Saved`] + /// value, factored out so every caller that dispatches `select-layout` + /// with a caller-supplied string goes through the same refusal rather + /// than reimplementing it: [`crate::plan::ops::SelectLayout`] validates + /// each recorded operation this way before a plan's first command, and + /// an MCP or other integration should call this (or [`Self::select_layout`] + /// directly, when it already holds a [`Window`]) rather than building the + /// `select-layout` command itself. + /// + /// # Errors + /// + /// Returns [`Error::UnrecognizedLayout`] for a value that is not a + /// preset name, a unique preset prefix, a classic layout string, or + /// JSON; [`Error::AmbiguousLayout`] for a prefix that names more than + /// one preset available on the running release; and + /// [`crate::ErrorKind::UnsupportedVersion`] for a preset or a JSON + /// layout this release predates. + pub(crate) async fn validate_saved_layout( + server: &crate::Server, + saved: &OsStr, + ) -> Result { + // Checked here rather than left to tmux: 3.3 and 3.3a exit on a + // value `select-layout` cannot parse, taking every session on the + // socket with them, and `--` does not help -- it turns `-o` from + // the undo flag into exactly such a value. + let version = server.capabilities().await?.tmux_version().clone(); + match SavedLayout::classify(saved, &version) { + SavedLayout::Preset(named) => { + server + .require(named.as_str(), named.minimum_release()) + .await?; + // The resolved name, not whatever prefix the caller spelled: + // a caller who typed `tile` gets `tiled` sent, not a second + // round of tmux's own matching. + Ok(OsString::from(named.as_str())) + } + SavedLayout::Classic => Ok(saved.to_owned()), + SavedLayout::Json => { + server + .require("a JSON layout string", crate::version::since::JSON_LAYOUTS) + .await?; + Ok(saved.to_owned()) + } + SavedLayout::Ambiguous(candidates) => Err(Error::AmbiguousLayout { + input: saved.to_string_lossy().into_owned(), + candidates, + }), + SavedLayout::Unrecognized => Err(Error::UnrecognizedLayout), + } + } + /// Restart the window's command in place. /// /// Passing `None` reruns whatever the window started with. Every pane in @@ -1368,33 +1404,63 @@ impl From<&TmuxText> for LayoutSpec { /// The shape of a saved layout value, read before it reaches tmux. enum SavedLayout { - /// A preset name passed as text rather than as a [`Layout`]. + /// A preset name, or a prefix of exactly one, passed as text rather than + /// as a [`Layout`]. Preset(Layout), /// tmux's checksum-prefixed string: four hex digits and a comma. Classic, /// The JSON form tmux reports from [`crate::since::JSON_LAYOUTS`]. Json, + /// A prefix that names more than one preset available on the running + /// release. + Ambiguous(Vec<&'static str>), /// Nothing tmux ever reported, and nothing `select-layout` can parse. Unrecognized, } impl SavedLayout { - fn classify(saved: &OsStr) -> Self { + /// Every preset [`select_layout`](Window::select_layout) knows, in the + /// order tmux itself declares them. + const PRESETS: [Layout; 7] = [ + Layout::EvenHorizontal, + Layout::EvenVertical, + Layout::MainHorizontal, + Layout::MainHorizontalMirrored, + Layout::MainVertical, + Layout::MainVerticalMirrored, + Layout::Tiled, + ]; + + fn classify(saved: &OsStr, version: &crate::TmuxVersion) -> Self { let Some(text) = saved.to_str() else { return Self::Unrecognized; }; - let presets = [ - Layout::EvenHorizontal, - Layout::EvenVertical, - Layout::MainHorizontal, - Layout::MainHorizontalMirrored, - Layout::MainVertical, - Layout::MainVerticalMirrored, - Layout::Tiled, - ]; - if let Some(named) = presets.into_iter().find(|named| named.as_str() == text) { + if let Some(named) = Self::PRESETS.into_iter().find(|named| named.as_str() == text) { return Self::Preset(named); } + // tmux's own `layout_set_lookup` is a prefix match, so `tile` and + // `even-h` both apply on every release; matched only against the + // presets the running release actually has, so `main-h` is unique + // on 3.2a (five presets) even though it is ambiguous from 3.5, + // where the mirrored pair exists. An empty string matches every + // preset's prefix and must stay unrecognized rather than reading as + // ambiguous among all seven. + if !text.is_empty() { + let candidates: Vec = Self::PRESETS + .into_iter() + .filter(|named| version.meets(&named.minimum_release())) + .filter(|named| named.as_str().starts_with(text)) + .collect(); + match candidates.as_slice() { + [only] => return Self::Preset(*only), + [_, ..] => { + return Self::Ambiguous( + candidates.iter().map(|named| named.as_str()).collect(), + ); + } + [] => {} + } + } // `layout_parse` reads `%hx,` and insists it consumed exactly five // bytes; tmux itself always writes the checksum as four lowercase // digits. diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index 5cf69af3..d4d1b759 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -1568,6 +1568,84 @@ async fn a_value_that_is_not_a_layout_is_refused_before_dispatch() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A unique preset prefix applies; a prefix naming more than one preset is +/// refused, naming the candidates. +/// +/// tmux's own `layout_set_lookup` is a prefix match, so `tile` and `even-h` +/// apply on every release and never reach the 3.3a crash path -- an +/// exact-match-only guard refuses a spelling tmux itself accepts. +#[tokio::test] +async fn a_unique_layout_prefix_applies_an_ambiguous_one_names_its_candidates() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("layout-prefix").await.expect("session"); + let mut window = session + .active_window() + .await + .expect("windows") + .expect("a window"); + window + .split(SplitOptions::new(SplitDirection::Right)) + .await + .expect("a second pane to lay out"); + + window + .select_layout("tile") + .await + .expect("a unique prefix of `tiled` applies"); + window + .select_layout("even-h") + .await + .expect("a unique prefix of `even-horizontal` applies"); + + let error = window + .select_layout("even-") + .await + .expect_err("a prefix naming two presets is ambiguous"); + assert_eq!(error.kind(), ErrorKind::InvalidInput, "{error}"); + let message = error.to_string(); + assert!(message.contains("even-horizontal"), "{message}"); + assert!(message.contains("even-vertical"), "{message}"); + + // Empty is not a prefix of "every preset": it stays the same refusal as + // every other unrecognized value, not "ambiguous among all seven". + let empty_error = window + .select_layout("") + .await + .expect_err("empty is refused, not ambiguous"); + assert!( + !empty_error.to_string().contains("more than one preset"), + "{empty_error}", + ); + + // `main-h` is unique among the presets tmux 3.2a knows (five) and + // ambiguous once the mirrored pair exists (3.5+) -- the candidate set is + // the running release's, not every preset this crate can name. + let mirrored_capable = server + .capabilities() + .await + .expect("capabilities read") + .tmux_version() + .has_behavior(&libtmux::since::MIRRORED_LAYOUTS); + let main_h = window.select_layout("main-h").await; + if mirrored_capable { + let error = main_h.expect_err("main-h is ambiguous once the mirrored pair exists"); + assert_eq!(error.kind(), ErrorKind::InvalidInput, "{error}"); + } else { + main_h.expect("main-h is unique without the mirrored pair"); + } + + assert!( + server + .has_session("layout-prefix") + .await + .expect("tmux still answers"), + "the session survives every prefix, unique or ambiguous", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A JSON-looking saved layout on an old tmux is refused with a version /// hint, not just tmux's generic "invalid layout". #[tokio::test] diff --git a/crates/libtmux/tests/plan.rs b/crates/libtmux/tests/plan.rs index 97982bc5..90825e8a 100644 --- a/crates/libtmux/tests/plan.rs +++ b/crates/libtmux/tests/plan.rs @@ -16,8 +16,8 @@ use std::time::Duration; use libtmux::plan::{ Attribution, CapturePane, KillPane, KillWindow, NewSession, NewWindow, OperationKind, OperationReport, OperationValue, Outcome, PaneTarget, Plan, PlanResult, - PlanValidationErrorKind, Planner, SelectPane, SelectWindow, SendKeys, SetEnvironment, - SetOption, SplitWindow, StepReason, WindowTarget, + PlanValidationErrorKind, Planner, SelectLayout, SelectPane, SelectWindow, SendKeys, + SetEnvironment, SetOption, SplitWindow, StepReason, WindowTarget, }; use libtmux::test::TestServer; use libtmux::{ @@ -726,6 +726,47 @@ async fn a_plan_will_not_write_an_option_where_tmux_keeps_another() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A plan refuses a `select-layout` value `select-layout` itself cannot +/// parse, before its first command. +/// +/// A plan renders its own commands, so `SelectLayout` reached tmux without +/// the check the direct `Window::select_layout` path makes: tmux 3.3 and +/// 3.3a exit on a layout value they cannot parse, taking every session on +/// the socket with them. +#[tokio::test] +async fn a_plan_refuses_a_layout_value_select_layout_cannot_parse() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + + for value in ["-o", "garbage", ""] { + let mut plan = Plan::new(); + let session = plan.add(NewSession::new("layout-guard")); + plan.add(SelectLayout::new(session.window(), value)); + + let error = plan + .run(server, Planner::Sequential) + .await + .map(|_| ()) + .expect_err("select-layout cannot parse this value"); + assert_eq!( + error.kind(), + libtmux::ErrorKind::InvalidInput, + "{value:?}: {error:?}", + ); + assert!( + server.sessions().await.expect("sessions").is_empty(), + "{value:?}: validation happens before the first command", + ); + } + + assert!( + server.is_alive().await, + "the server survives every refused value", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// Text that happens to name a tmux key is typed, not pressed. /// /// `send-keys` resolves every argument against its key table before treating From 14a37474882dbba21600a4fe6595313e5f348ede Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 17:23:04 -0500 Subject: [PATCH 028/117] MCP(fix[errors]): Report tool failures as isError content, not JSON-RPC Every tool returned Result, ErrorData>, and rmcp's own Result::into_call_tool_result keeps an Err(ErrorData) as a JSON-RPC protocol error rather than folding it into an isError result, so a tmux refusal, a self-protection guard, or any other tool-side check came back as -32603/-32602 instead of content the model reads and acts on. A protocol error is now reserved for what the framework itself refuses before a tool body runs: an unknown tool name or arguments that fail schema validation. Added ToolError, wrapping the same classified ErrorData every helper in tools/error.rs already built, and implementing IntoContents so rmcp's own conversion produces isError: true content instead of an Err. Every tool signature now returns Result<_, ToolError>; internal helpers were untouched since ToolError: From lets `?` convert at the boundary. Re-exported at the crate root because a caller that invokes TmuxTools directly, rather than over the wire, needs ToolError::into_error_data() to read the same classification a wire client gets from the response content. select_layout dispatched its own "select-layout -- " command, bypassing Window::select_layout's guard entirely: "-o", "garbage", and "" all killed tmux 3.3a through this tool. It now goes through the window handle it already resolved, so it gets the same pre-dispatch refusal the direct API has; verified against the pinned 3.3a binary that the tool no longer dispatches such a value. read_batch_preserves_nested_protocol_errors assumed every nested tool failure was a protocol error; a business refusal (a missing pane) now reports through the batch item's own result with isError, and the test is split into that case and a genuine nested protocol fault (a disallowed nested tool name). --- crates/tmux-mcp/src/lib.rs | 1 + crates/tmux-mcp/src/run_request.rs | 8 +- crates/tmux-mcp/src/tools/contract.rs | 47 +++++---- crates/tmux-mcp/src/tools/control.rs | 65 +++++------- crates/tmux-mcp/src/tools/error.rs | 86 ++++++++++++--- crates/tmux-mcp/src/tools/inspect.rs | 23 ++--- crates/tmux-mcp/src/tools/mod.rs | 25 ++--- crates/tmux-mcp/src/tools/observe.rs | 56 ++++++---- crates/tmux-mcp/src/tools/pane_input.rs | 31 +++--- crates/tmux-mcp/tests/agent.rs | 72 ++++++++----- crates/tmux-mcp/tests/protocol.rs | 132 ++++++++++++++++++++++-- crates/tmux-mcp/tests/tools.rs | 52 +++++++++- 12 files changed, 426 insertions(+), 172 deletions(-) diff --git a/crates/tmux-mcp/src/lib.rs b/crates/tmux-mcp/src/lib.rs index 2936247f..6dc24100 100644 --- a/crates/tmux-mcp/src/lib.rs +++ b/crates/tmux-mcp/src/lib.rs @@ -60,6 +60,7 @@ pub use policy::{ SocketProvenance, SurfaceError, TOOLS_ENV, TOOLSETS_ENV, Toolset, }; pub use tail::Cursor; +pub use tools::error::ToolError; pub use views::*; use std::path::PathBuf; diff --git a/crates/tmux-mcp/src/run_request.rs b/crates/tmux-mcp/src/run_request.rs index 532f7607..6f5c54dd 100644 --- a/crates/tmux-mcp/src/run_request.rs +++ b/crates/tmux-mcp/src/run_request.rs @@ -10,9 +10,9 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; use std::time::Duration; use libtmux::{Pane, ServerGeneration}; -use rmcp::model::ErrorData; use tokio_util::sync::CancellationToken; +use crate::ToolError; use crate::exec::{self, RunOutcome, RunView}; use crate::retained::RetainedBytes; use crate::text::{TextFilter, readable_from}; @@ -200,7 +200,7 @@ pub(crate) enum RunError { /// Pane input may have reached tmux, but delivery was not acknowledged. DispatchUnknown(Box), /// Pane state changed after watcher setup and before dispatch. - Guard(ErrorData), + Guard(ToolError), /// Completion framing failed before the pane watcher was attached. Frame, } @@ -294,7 +294,7 @@ pub(crate) async fn run( suppress_history: bool, cancelled: &CancellationToken, transport: RunTransport<'_>, - final_check: impl Future>, + final_check: impl Future>, ) -> Result { let RunTransport { server, @@ -414,7 +414,7 @@ mod tests { .server() .resolved_tmux_executable() .expect("fixture tmux resolves"); - let refusal = ErrorData::invalid_params("refused".to_owned(), None); + let refusal: ToolError = ErrorData::invalid_params("refused".to_owned(), None).into(); let generation = guard .server() .generation() diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 3ab0a612..83861042 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use crate::{PaneView, SessionView, TmuxTools, WindowView}; -use super::error::{bad_input, object_gone, tmux_error}; +use super::error::{ToolError, bad_input, object_gone, tmux_error}; use super::lossy; const READ_BATCH_MAX_OPERATIONS: usize = 16; @@ -437,7 +437,7 @@ impl TmuxTools { pub async fn get_session_info( &self, Parameters(crate::SessionArgs { session }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let session = self.find_session(&session).await?; Ok(Json(SessionView { id: session.id().to_string(), @@ -457,7 +457,7 @@ impl TmuxTools { pub async fn get_window_info( &self, Parameters(crate::WindowArgs { window }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { Ok(Json(Self::one_window(&self.find_window(&window).await?))) } @@ -471,7 +471,7 @@ impl TmuxTools { pub async fn get_pane_info( &self, Parameters(crate::PaneArgs { pane }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let pane = self.find_pane(&pane).await?; let socket = self.socket(); Ok(Json(self.pane_view(&pane, socket))) @@ -488,7 +488,7 @@ impl TmuxTools { pub async fn find_pane_by_position( &self, Parameters(PositionArgs { window, corner }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let panes = self .find_window(&window) .await? @@ -540,7 +540,7 @@ impl TmuxTools { pub async fn get_tmux_variables( &self, Parameters(VariablesArgs { names, pane }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { if !(1..=32).contains(&names.len()) { return Err(bad_input( "names must contain between one and 32 tmux variables".to_owned(), @@ -582,7 +582,7 @@ impl TmuxTools { pub async fn rename_session( &self, Parameters(RenameSessionArgs { session, name }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut session = self.find_session(&session).await?; session .rename(libtmux::escape_format(name)) @@ -610,7 +610,7 @@ impl TmuxTools { pub async fn rename_window( &self, Parameters(RenameWindowArgs { window, name }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut window = self.find_window(&window).await?; window .rename(libtmux::escape_format(name)) @@ -633,7 +633,7 @@ impl TmuxTools { width, height, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut window = self.find_window(&window).await?; window .resize(width, height) @@ -658,7 +658,7 @@ impl TmuxTools { destination_session, destination_index, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let session = self.find_session(&destination_session).await?; let mut window = self.find_window(&window).await?; window @@ -681,7 +681,7 @@ impl TmuxTools { source_pane, target_pane, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_pane(&target_pane).await?; let mut source = self.find_pane(&source_pane).await?; source @@ -706,7 +706,7 @@ impl TmuxTools { pub async fn set_pane_title( &self, Parameters(PaneTitleArgs { pane, title }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut pane = self.find_pane(&pane).await?; pane.set_title(libtmux::escape_format(title)) .await @@ -725,7 +725,7 @@ impl TmuxTools { pub async fn set_mouse_enabled( &self, Parameters(SessionFlagArgs { session, enabled }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let value = if enabled { "on" } else { "off" }; if let Some(name) = session.as_deref() { self.find_session(name) @@ -756,7 +756,7 @@ impl TmuxTools { pub async fn set_history_limit( &self, Parameters(HistoryLimitArgs { session, limit }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { if let Some(name) = session.as_deref() { self.find_session(name) .await? @@ -797,7 +797,7 @@ impl TmuxTools { name, start_directory, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let session = self.find_session(&session).await?; let mut options = name.map(libtmux::escape_format).map_or_else( libtmux::NewWindowOptions::unnamed, @@ -835,7 +835,7 @@ impl TmuxTools { percent, start_directory, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { // Parsed from the same table the advertised schema is checked against, // so a word a client is offered is a word this accepts. let direction = match direction.as_deref() { @@ -880,7 +880,7 @@ impl TmuxTools { pub async fn respawn_pane_configured( &self, Parameters(RespawnArgs { pane, kill_first }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut pane = self.find_pane(&pane).await?; pane.respawn(None::, kill_first) .await @@ -907,7 +907,7 @@ impl TmuxTools { pub async fn set_synchronize_panes( &self, Parameters(WindowFlagArgs { window, enabled }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let value = if enabled { "on" } else { "off" }; self.find_window(&window) .await? @@ -938,7 +938,7 @@ impl TmuxTools { operations, on_error, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { if operations.is_empty() || operations.len() > 64 { return Err(bad_input( "operations must contain 1 through 64 items".to_owned(), @@ -971,7 +971,7 @@ impl TmuxTools { success: false, result: None, result_truncated: false, - error: Some(error), + error: Some(error.into_error_data()), }); if on_error.stops() { break; @@ -1020,7 +1020,7 @@ impl TmuxTools { on_error, }): Parameters, context: RequestContext, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { if operations.is_empty() || operations.len() > READ_BATCH_MAX_OPERATIONS { return Err(bad_input(format!( "operations must contain 1 through {READ_BATCH_MAX_OPERATIONS} items" @@ -1041,7 +1041,10 @@ impl TmuxTools { if !allowed.contains(tool.as_str()) { if !batch.push(BatchItem { index, - error: Some(bad_input(format!("{tool} is not an enabled inspect tool"))), + error: Some( + bad_input(format!("{tool} is not an enabled inspect tool")) + .into_error_data(), + ), tool, success: false, result: None, diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index acd466b0..b47a9740 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -3,7 +3,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use libtmux::{Command, CommandChain, Error, NewSessionOptions}; use rmcp::handler::server::wrapper::{Json, Parameters}; -use rmcp::model::ErrorData; use rmcp::{tool, tool_router}; use crate::{ @@ -12,7 +11,7 @@ use crate::{ SendKeysArgs, Sent, SessionArgs, SessionView, Size, TmuxTools, WindowArgs, Windows, }; -use super::error::{EffectBoundary, bad_input, tmux_error, vanished}; +use super::error::{EffectBoundary, ToolError, bad_input, tmux_error, vanished}; use super::lossy; use super::pane_input::{MissingSource, PaneInputReach, active_run_error}; @@ -64,15 +63,15 @@ async fn delete_private_paste_buffer(server: &libtmux::Server, name: &str) -> Re server.delete_buffer(name).await } -fn cleanup_after_refusal(primary: ErrorData, cleanup: Result<(), Error>) -> ErrorData { +fn cleanup_after_refusal(primary: ToolError, cleanup: Result<(), Error>) -> ToolError { match cleanup { Ok(()) => primary, Err(cleanup) => { + let message = primary.into_error_data().message; let mut boundary = EffectBoundary::new("paste_text"); boundary.mark(); boundary.local(format!( - "{}; temporary paste buffer cleanup failed: {cleanup}", - primary.message + "{message}; temporary paste buffer cleanup failed: {cleanup}" )) } } @@ -83,7 +82,7 @@ impl TmuxTools { pub(super) async fn protect_window_caller( &self, window: &libtmux::Window, - ) -> Result<(), ErrorData> { + ) -> Result<(), ToolError> { let Some(own) = self.protected_pane().await? else { return Ok(()); }; @@ -95,7 +94,7 @@ impl TmuxTools { } /// Refuse to destroy a session that currently contains the caller pane. - async fn protect_session_caller(&self, session: &libtmux::Session) -> Result<(), ErrorData> { + async fn protect_session_caller(&self, session: &libtmux::Session) -> Result<(), ToolError> { let Some(own) = self.protected_pane().await? else { return Ok(()); }; @@ -114,7 +113,7 @@ impl TmuxTools { keys, enter, }: SendKeysArgs, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let keys = keys.unwrap_or_default(); if text.is_none() && keys.is_empty() && !enter { return Err(bad_input("send_keys needs text, keys, or enter".to_owned())); @@ -178,7 +177,7 @@ impl TmuxTools { pub async fn kill_window( &self, Parameters(WindowArgs { window }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let window = self.find_window(&window).await?; let id = window.id().to_string(); self.protect_window_caller(&window).await?; @@ -198,7 +197,7 @@ impl TmuxTools { pub async fn kill_pane( &self, Parameters(PaneArgs { pane }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let pane = self.find_pane(&pane).await?; let id = pane.id().to_string(); if self.protected_pane().await? == Some(id.as_str()) { @@ -235,7 +234,7 @@ impl TmuxTools { name, start_directory, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut options = NewSessionOptions::new(libtmux::escape_format(name)); if let Some(directory) = start_directory { options = options.start_directory(libtmux::escape_format(directory)); @@ -266,7 +265,7 @@ impl TmuxTools { pub async fn kill_session( &self, Parameters(SessionArgs { session }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_session(&session).await?; let id = target.id().to_string(); self.protect_session_caller(&target).await?; @@ -292,7 +291,7 @@ impl TmuxTools { direction, cells, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let direction = crate::schema::resize_direction(&direction).ok_or_else(|| { bad_input(format!( "direction must be {}, not {direction}", @@ -336,7 +335,7 @@ impl TmuxTools { pub async fn send_keys( &self, Parameters(args): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { self.send_keys_one(args).await } @@ -355,7 +354,7 @@ impl TmuxTools { pub async fn select_pane( &self, Parameters(SelectPaneArgs { pane, direction }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_pane(&pane).await?; // `next` and `previous` are resolved here rather than with a tmux @@ -442,7 +441,7 @@ impl TmuxTools { pub async fn select_window( &self, Parameters(SelectWindowArgs { window, direction }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let mut target = self.find_window(&window).await?; let mut boundary = EffectBoundary::new("select_window"); @@ -517,26 +516,17 @@ impl TmuxTools { pub async fn select_layout( &self, Parameters(SelectLayoutArgs { window, layout }): Parameters, - ) -> Result, ErrorData> { - let target = self.find_window(&window).await?; - let result = self - .server - .cmd( - Command::new("select-layout") - .arg("-t") - .arg(target.id().to_string()) - // `select-layout` has flags of its own, and a layout is - // the caller's text. Without the separator, asking for - // `-E` spread the panes evenly and reported `-E` back as - // the layout that had been applied. - .arg("--") - .arg(layout.clone()), - ) + ) -> Result, ToolError> { + let mut target = self.find_window(&window).await?; + // Through `Window::select_layout` rather than a raw `select-layout` + // dispatch: that is where the pre-dispatch refusal lives (3.3 and + // 3.3a exit on a layout value `select-layout` cannot parse, taking + // every session on the socket with them), and a second, unguarded + // path here bypassed it. + target + .select_layout(layout.clone()) .await .map_err(|e| tmux_error(&e))?; - if let Some(error) = result.refusal_for("select-layout") { - return Err(tmux_error(&error)); - } Ok(Json(Layout { window: target.id().to_string(), @@ -559,7 +549,7 @@ impl TmuxTools { pub async fn clear_pane( &self, Parameters(PaneArgs { pane }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_pane(&pane).await?; target.clear_history().await.map_err(|e| tmux_error(&e))?; @@ -590,7 +580,7 @@ impl TmuxTools { pub async fn paste_text( &self, Parameters(PasteTextArgs { pane, text, enter }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let initial = self .preflight_pane_input( &pane, @@ -681,7 +671,7 @@ impl TmuxTools { pub async fn signal_channel( &self, Parameters(ChannelArgs { channel, .. }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { self.server .signal_channel(&channel) .await @@ -806,6 +796,7 @@ mod tests { let Err(error) = result else { panic!("Enter reached tmux but its held reply did not fail"); }; + let error = error.into_error_data(); assert_eq!(error.code, ErrorCode::INTERNAL_ERROR); let detail = error.data.expect("the error carries detail"); assert_eq!(detail["kind"], "partial_effect", "{detail}"); diff --git a/crates/tmux-mcp/src/tools/error.rs b/crates/tmux-mcp/src/tools/error.rs index 6edcb647..974da354 100644 --- a/crates/tmux-mcp/src/tools/error.rs +++ b/crates/tmux-mcp/src/tools/error.rs @@ -1,4 +1,51 @@ -use rmcp::model::ErrorData; +use rmcp::model::{ContentBlock, ErrorData, IntoContents}; + +/// A tool-execution failure, reported as `isError` tool content. +/// +/// MCP separates protocol faults (unknown tool, arguments that do not match +/// the schema -- rejected before a tool body ever runs) from failures a tool +/// body discovers itself. Only the first belongs in the JSON-RPC `error` +/// field; the second is data the model reads and acts on, which is what +/// [`rmcp::model::CallToolResult::is_error`] is for. Every helper below +/// builds one of these instead of a raw [`ErrorData`], so a tool signature +/// that returns `Result<_, ToolError>` cannot surface a tmux or input +/// refusal as a protocol error by construction. +/// +/// Public, and re-exported at the crate root: a caller that invokes +/// [`crate::TmuxTools`]'s methods directly, rather than over the wire, gets +/// this type back and needs [`Self::into_error_data`] to read it. +#[derive(Debug)] +pub struct ToolError(ErrorData); + +impl From for ToolError { + fn from(value: ErrorData) -> Self { + Self(value) + } +} + +impl ToolError { + /// Recover the underlying [`ErrorData`]. + /// + /// For the one caller that reports a nested tool's own failure inside a + /// batch item rather than as this tool's failure + /// ([`super::contract::TmuxTools::call_read_tools_batch`]), and for a + /// test that calls a tool directly and inspects the classification a + /// wire client would otherwise read from `isError` content. + #[must_use] + pub fn into_error_data(self) -> ErrorData { + self.0 + } +} + +impl IntoContents for ToolError { + fn into_contents(self) -> Vec { + let body = serde_json::json!({ + "message": self.0.message, + "data": self.0.data, + }); + vec![ContentBlock::text(body.to_string())] + } +} // Every error this server returns carries the same three fields on its `data`, // so an agent decides what to do next by reading them rather than by matching @@ -20,7 +67,7 @@ use rmcp::model::ErrorData; /// /// libtmux already draws the distinctions above, so they are carried through /// rather than flattened. -pub(super) fn tmux_error(error: &libtmux::Error) -> ErrorData { +pub(super) fn tmux_error(error: &libtmux::Error) -> ToolError { use libtmux::ErrorKind; let kind = error.kind(); @@ -55,9 +102,10 @@ pub(super) fn tmux_error(error: &libtmux::Error) -> ErrorData { } _ => ErrorData::internal_error(message, Some(detail)), } + .into() } -fn partial_effect(message: impl Into) -> ErrorData { +fn partial_effect(message: impl Into) -> ToolError { ErrorData::internal_error( message.into(), Some(serde_json::json!({ @@ -66,6 +114,7 @@ fn partial_effect(message: impl Into) -> ErrorData { "stale": false, })), ) + .into() } pub(super) struct EffectBoundary { @@ -85,7 +134,7 @@ impl EffectBoundary { self.effect_seen = true; } - pub(super) fn error(&self, error: libtmux::Error) -> ErrorData { + pub(super) fn error(&self, error: libtmux::Error) -> ToolError { let error = if self.effect_seen { error.after_effect(self.operation) } else { @@ -94,11 +143,11 @@ impl EffectBoundary { tmux_error(&error) } - pub(super) fn tmux(&self, result: Result) -> Result { + pub(super) fn tmux(&self, result: Result) -> Result { result.map_err(|error| self.error(error)) } - pub(super) fn local(&self, message: impl Into) -> ErrorData { + pub(super) fn local(&self, message: impl Into) -> ToolError { debug_assert!(self.effect_seen); partial_effect(message) } @@ -124,8 +173,8 @@ fn stale_detail() -> serde_json::Value { /// libtmux, so they mint the classification directly. An agent should not have /// to tell the two apart: a pane that vanished between the listing and the call /// reads the same either way. -pub(super) fn object_gone(what: &str, id: &str) -> ErrorData { - ErrorData::invalid_params(format!("no {what} {id}"), Some(stale_detail())) +pub(super) fn object_gone(what: &str, id: &str) -> ToolError { + ErrorData::invalid_params(format!("no {what} {id}"), Some(stale_detail())).into() } /// Report state that moved between two calls this server made. @@ -133,15 +182,15 @@ pub(super) fn object_gone(what: &str, id: &str) -> ErrorData { /// Not the caller's mistake — the handle was good when it was taken — so the /// code stays an internal error. The classification is the one for a target /// that was already gone, because the useful response is the same: look again. -pub(super) fn vanished(message: &str) -> ErrorData { - ErrorData::internal_error(message.to_owned(), Some(stale_detail())) +pub(super) fn vanished(message: &str) -> ToolError { + ErrorData::internal_error(message.to_owned(), Some(stale_detail())).into() } /// Report an argument this server will not pass to tmux. /// /// Nothing about the server needs to change for the next call to work, and /// nothing has gone stale: the caller has to send something else. -pub(super) fn bad_input(message: impl Into) -> ErrorData { +pub(super) fn bad_input(message: impl Into) -> ToolError { ErrorData::invalid_params( message.into(), Some(serde_json::json!({ @@ -150,6 +199,7 @@ pub(super) fn bad_input(message: impl Into) -> ErrorData { "stale": false, })), ) + .into() } #[cfg(test)] @@ -165,7 +215,9 @@ mod tests { #[test] fn an_effect_boundary_changes_only_later_failures() { let mut boundary = EffectBoundary::new("send_keys"); - let first = boundary.error(libtmux::Error::RuntimeNested); + let first = boundary + .error(libtmux::Error::RuntimeNested) + .into_error_data(); assert_eq!(first.code, ErrorCode::INVALID_PARAMS); assert_eq!( first.data.expect("the first error carries detail")["kind"], @@ -173,14 +225,18 @@ mod tests { ); boundary.mark(); - let later = boundary.error(libtmux::Error::RuntimeNested); + let later = boundary + .error(libtmux::Error::RuntimeNested) + .into_error_data(); assert_eq!(later.code, ErrorCode::INTERNAL_ERROR); let detail = later.data.expect("the later error carries detail"); assert_eq!(detail["kind"], "partial_effect", "{detail}"); assert_eq!(detail["retryable"], false, "{detail}"); assert_eq!(detail["stale"], false, "{detail}"); - let local = boundary.local("the selected object vanished"); + let local = boundary + .local("the selected object vanished") + .into_error_data(); assert_eq!(local.code, ErrorCode::INTERNAL_ERROR); let detail = local.data.expect("the local error carries detail"); assert_eq!(detail["kind"], "partial_effect", "{detail}"); @@ -241,7 +297,7 @@ mod tests { assert_eq!(held, libtmux::ChannelWait::Signalled); assert_eq!(error.kind(), ErrorKind::Refused); - let projected = tmux_error(&error); + let projected = tmux_error(&error).into_error_data(); assert_eq!(projected.code, ErrorCode::INTERNAL_ERROR); let detail = projected.data.expect("the refusal carries detail"); assert_eq!(detail["kind"], "refused", "{detail}"); diff --git a/crates/tmux-mcp/src/tools/inspect.rs b/crates/tmux-mcp/src/tools/inspect.rs index 9aee3368..f3b4d65f 100644 --- a/crates/tmux-mcp/src/tools/inspect.rs +++ b/crates/tmux-mcp/src/tools/inspect.rs @@ -2,7 +2,6 @@ use std::time::{Duration, Instant}; use libtmux::{CaptureOptions, Command}; use rmcp::handler::server::wrapper::{Json, Parameters}; -use rmcp::model::ErrorData; use rmcp::{tool, tool_router}; use crate::exec::Patterns; @@ -12,7 +11,7 @@ use crate::{ Sessions, ShowEnvironmentArgs, ShowHooksArgs, Snapshot, SnapshotArgs, TmuxTools, Tree, Windows, }; -use super::error::{bad_input, tmux_error}; +use super::error::{ToolError, bad_input, tmux_error}; use super::{OptionScope, lossy, lossy_optional}; /// Separates the fields of a `snapshot_pane` format query. @@ -112,7 +111,7 @@ impl TmuxTools { title = "List Sessions", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}) )] - pub async fn list_sessions(&self) -> Result, ErrorData> { + pub async fn list_sessions(&self) -> Result, ToolError> { let sessions = self.server.sessions().await.map_err(|e| tmux_error(&e))?; Ok(Json(Self::render_sessions(&sessions))) } @@ -124,7 +123,7 @@ impl TmuxTools { title = "List Windows", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}) )] - pub async fn list_windows(&self) -> Result, ErrorData> { + pub async fn list_windows(&self) -> Result, ToolError> { let windows = self.server.windows().await.map_err(|e| tmux_error(&e))?; Ok(Json(Self::render_windows(&windows))) } @@ -135,7 +134,7 @@ impl TmuxTools { title = "List Panes", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}; always_load) )] - pub async fn list_panes(&self) -> Result, ErrorData> { + pub async fn list_panes(&self) -> Result, ToolError> { let panes = self.server.panes().await.map_err(|e| tmux_error(&e))?; Ok(Json(self.render_panes(&panes))) @@ -150,7 +149,7 @@ impl TmuxTools { title = "Describe Server", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}; always_load) )] - pub async fn describe(&self) -> Result, ErrorData> { + pub async fn describe(&self) -> Result, ToolError> { let tree = self.server.hierarchy().await.map_err(|e| tmux_error(&e))?; let sessions: Vec<_> = tree .iter() @@ -209,7 +208,7 @@ impl TmuxTools { start, end, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { if last_command { return self.capture_last_command(&pane).await; } @@ -268,7 +267,7 @@ impl TmuxTools { max_lines, history, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_pane(&pane).await?; // One format query for the state a listing does not carry. @@ -362,7 +361,7 @@ impl TmuxTools { session, window, }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let patterns = Patterns::compile(std::slice::from_ref(&pattern), regex, match_case) .map_err(|(source, reason)| { bad_input(format!("pattern {source} is invalid: {reason}")) @@ -476,7 +475,7 @@ impl TmuxTools { target, .. }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let scope = self .option_scope(scope.as_deref(), target.as_deref()) .await?; @@ -512,7 +511,7 @@ impl TmuxTools { pub async fn show_environment( &self, Parameters(ShowEnvironmentArgs { session }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let entries = match session.as_deref() { Some(name) => self.find_session(name).await?.environment_all().await, None => self.server.environment_all().await, @@ -549,7 +548,7 @@ impl TmuxTools { pub async fn show_hooks( &self, Parameters(ShowHooksArgs { session }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let found = match session.as_deref() { Some(name) => self.find_session(name).await?.hooks().await, None => self.server.hooks().await, diff --git a/crates/tmux-mcp/src/tools/mod.rs b/crates/tmux-mcp/src/tools/mod.rs index 12e70b71..2f075224 100644 --- a/crates/tmux-mcp/src/tools/mod.rs +++ b/crates/tmux-mcp/src/tools/mod.rs @@ -1,6 +1,6 @@ mod contract; mod control; -mod error; +pub(crate) mod error; mod inspect; mod observe; mod pane_input; @@ -17,7 +17,7 @@ use crate::{ Capture, Marks, PaneView, Panes, SessionView, Sessions, TmuxTools, WindowView, Windows, }; -use error::{bad_input, object_gone, tmux_error}; +use error::{ToolError, bad_input, object_gone, tmux_error}; /// Render tmux bytes for a protocol that requires valid UTF-8. /// @@ -86,7 +86,7 @@ impl TmuxTools { pub(super) async fn capture_last_command( &self, pane: &str, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_pane(pane).await?; let supported = self.server.capabilities().await.is_ok_and(|capabilities| { capabilities @@ -182,7 +182,7 @@ impl TmuxTools { &self, scope: Option<&str>, target: Option<&str>, - ) -> Result { + ) -> Result { let needs = |what: &str| bad_input(format!("scope {what} needs a target id")); match scope { @@ -275,7 +275,7 @@ impl TmuxTools { /// /// A returned pane has been resolved in the caller's claimed session on /// the selected daemon. Malformed or stale context refuses the operation. - pub(super) async fn protected_pane(&self) -> Result, ErrorData> { + pub(super) async fn protected_pane(&self) -> Result, ToolError> { if self.caller.is_none() { return Ok(None); } @@ -296,7 +296,7 @@ impl TmuxTools { socket: &Path, generation: libtmux::ServerGeneration, panes: &[libtmux::Pane], - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let Some(caller) = self.caller.as_deref() else { return Ok(None); }; @@ -323,7 +323,7 @@ impl TmuxTools { } /// Classify a refusal that protects the pane this process talks through. - pub(super) fn self_protection(message: String) -> ErrorData { + pub(super) fn self_protection(message: String) -> ToolError { ErrorData::invalid_params( message, // Its own kind, because this is the server declining rather than @@ -335,16 +335,17 @@ impl TmuxTools { "stale": false, })), ) + .into() } - fn caller_context_refusal(detail: &str) -> ErrorData { + fn caller_context_refusal(detail: &str) -> ToolError { Self::self_protection(format!( "refusing this operation because {detail}; restart the MCP outside tmux or with a complete current TMUX and TMUX_PANE context" )) } /// Refuse a command that may destroy the pane this process talks through. - pub(super) fn self_harm(what: &str, own: &str) -> ErrorData { + pub(super) fn self_harm(what: &str, own: &str) -> ToolError { Self::self_protection(format!( "refusing to kill this {what}: pane {own} matches this MCP server's inherited \ caller context, so killing it may end this conversation. Run the command in \ @@ -361,7 +362,7 @@ impl TmuxTools { /// again" will look again and `not-a-window` will still not be a window. /// And `@01` resolves, where a string comparison against the canonical /// `@1` called it missing. - pub(super) async fn find_window(&self, id: &str) -> Result { + pub(super) async fn find_window(&self, id: &str) -> Result { let window: libtmux::WindowId = id.parse().map_err(|error: libtmux::IdParseError| { let sigil = error.expected_sigil(); bad_input(format!( @@ -379,7 +380,7 @@ impl TmuxTools { /// Resolve a pane id, reporting an unknown one as invalid input. /// /// Shares the reasoning on [`Self::find_window`]. - pub(super) async fn find_pane(&self, id: &str) -> Result { + pub(super) async fn find_pane(&self, id: &str) -> Result { let pane: libtmux::PaneId = id.parse().map_err(|error: libtmux::IdParseError| { let sigil = error.expected_sigil(); bad_input(format!( @@ -395,7 +396,7 @@ impl TmuxTools { } /// Resolve a session by name, reporting an unknown one as invalid input. - pub(super) async fn find_session(&self, name: &str) -> Result { + pub(super) async fn find_session(&self, name: &str) -> Result { self.server .sessions() .await diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index 6cb0efb7..a3b8e053 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -13,7 +13,7 @@ use crate::{ TmuxTools, WaitForTextArgs, WaitView, }; -use super::error::{EffectBoundary, bad_input, tmux_error}; +use super::error::{EffectBoundary, ToolError, bad_input, tmux_error}; use super::pane_input::{MissingSource, PaneInputPlan, PaneInputReach, active_run_error}; #[derive(Clone, Eq, PartialEq)] @@ -48,7 +48,7 @@ fn known_posix_shell(command: &libtmux::TmuxText) -> bool { fn require_known_shell( plan: &PaneInputPlan, checkpoint: &str, -) -> Result { +) -> Result { let pane = plan.target.id(); let Some(command) = plan .target @@ -62,7 +62,7 @@ fn require_known_shell( Ok(command.clone()) } -fn resolved_executable(server: &libtmux::Server) -> Result { +fn resolved_executable(server: &libtmux::Server) -> Result { server.resolved_tmux_executable().ok_or_else(|| { ErrorData::internal_error( "the configured tmux executable cannot be resolved from its captured launch context" @@ -73,10 +73,11 @@ fn resolved_executable(server: &libtmux::Server) -> Result { "stale": false, })), ) + .into() }) } -fn run_route(server: &libtmux::Server, plan: &PaneInputPlan) -> Result { +fn run_route(server: &libtmux::Server, plan: &PaneInputPlan) -> Result { let executable = resolved_executable(server)?; if !exec::route_is_terminal_safe(executable.as_os_str(), &plan.endpoint) { return Err(run_error(run_request::RunError::Frame)); @@ -90,7 +91,7 @@ fn run_route(server: &libtmux::Server, plan: &PaneInputPlan) -> Result ErrorData { +fn run_error(error: run_request::RunError) -> ToolError { match error { run_request::RunError::Tmux(error) => tmux_error(&error), run_request::RunError::DispatchUnknown(cause) => ErrorData::internal_error( @@ -105,7 +106,8 @@ fn run_error(error: run_request::RunError) -> ErrorData { "retryable": false, "stale": false, })), - ), + ) + .into(), run_request::RunError::Guard(error) => error, run_request::RunError::Frame => ErrorData::internal_error( "run_shell_command could not prepare a secure completion frame; no pane input was sent" @@ -115,11 +117,12 @@ fn run_error(error: run_request::RunError) -> ErrorData { "retryable": false, "stale": false, })), - ), + ) + .into(), } } -fn tail_error(error: TailError) -> ErrorData { +fn tail_error(error: TailError) -> ToolError { match error { TailError::Tmux(error) => tmux_error(&error), TailError::Snapshot { error, opened } => tail_snapshot_error(error, opened), @@ -144,6 +147,7 @@ fn tail_error(error: TailError) -> ErrorData { "capacity": limit, })), ) + .into() } } TailError::ReaderStopped { opened } => { @@ -164,6 +168,7 @@ fn tail_error(error: TailError) -> ErrorData { "stale": false, })), ) + .into() } } TailError::OwnerUnavailable => ErrorData::internal_error( @@ -173,7 +178,8 @@ fn tail_error(error: TailError) -> ErrorData { "retryable": true, "stale": false, })), - ), + ) + .into(), TailError::OpeningAtCapacity { limit } => ErrorData::internal_error( "another pane tail is opening; retry capture_since after it finishes".to_owned(), Some(serde_json::json!({ @@ -183,11 +189,12 @@ fn tail_error(error: TailError) -> ErrorData { "resource": "tail_opening", "capacity": limit, })), - ), + ) + .into(), } } -fn tail_snapshot_error(error: libtmux::Error, opened: bool) -> ErrorData { +fn tail_snapshot_error(error: libtmux::Error, opened: bool) -> ToolError { let mut boundary = EffectBoundary::new("capture_since"); if opened { boundary.mark(); @@ -237,7 +244,7 @@ impl TmuxTools { }): Parameters, cancelled: tokio_util::sync::CancellationToken, reporter: Reporter, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { if command.as_bytes().contains(&0) { return Err(bad_input("command must not contain a NUL byte".to_owned())); } @@ -374,7 +381,7 @@ impl TmuxTools { }): Parameters, cancelled: tokio_util::sync::CancellationToken, reporter: Reporter, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let compile = |sources: Vec| { Patterns::compile(&sources, regex, match_case).map_err(|(source, reason)| { bad_input(format!("pattern {source} is invalid: {reason}")) @@ -414,7 +421,7 @@ impl TmuxTools { pub async fn capture_since( &self, Parameters(CaptureSinceArgs { pane, cursor }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { let target = self.find_pane(&pane).await?; let cursor = cursor .as_deref() @@ -464,7 +471,7 @@ impl TmuxTools { pub async fn wait_for_channel( &self, Parameters(ChannelArgs { channel, seconds }): Parameters, - ) -> Result, ErrorData> { + ) -> Result, ToolError> { // libtmux caps this at its own command timeout and reports running // out of time as an outcome rather than an error, which is the shape // this tool wants: the budget stays a request, and a deadline stays @@ -483,7 +490,8 @@ impl TmuxTools { return Err(ErrorData::internal_error( "tmux reported a wait outcome this server does not know".to_owned(), None, - )); + ) + .into()); } Err(error) => return Err(tmux_error(&error)), }; @@ -584,7 +592,7 @@ mod tests { source: &str, foreground: &libtmux::TmuxText, lease: &run_request::PaneReservation, - ) -> Result<(), ErrorData> { + ) -> Result<(), ToolError> { if transition == "caller" { server .window_by_id(pane.window_id()) @@ -651,7 +659,8 @@ mod tests { .socket_path("/tmp/libtmux-rs-test/conflicting.sock") .build() .expect_err("two socket selectors are refused"); - let error = run_error(run_request::RunError::DispatchUnknown(Box::new(source))); + let error = + run_error(run_request::RunError::DispatchUnknown(Box::new(source))).into_error_data(); let data = error.data.as_ref().expect("the failure carries metadata"); assert_eq!(error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); @@ -754,6 +763,7 @@ mod tests { let Err(run_request::RunError::Guard(error)) = result else { panic!("the final preflight must reject the {transition} transition"); }; + let error = error.into_error_data(); assert_eq!(error.code, rmcp::model::ErrorCode::INVALID_PARAMS); assert!(error.message.contains(expected_refusal), "{transition}"); assert_eq!( @@ -780,7 +790,7 @@ mod tests { #[test] fn unavailable_cursor_identity_is_an_internal_failure() { - let error = tail_error(TailError::OwnerUnavailable); + let error = tail_error(TailError::OwnerUnavailable).into_error_data(); let data = error.data.expect("the failure is classified"); assert_eq!(error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); @@ -793,7 +803,7 @@ mod tests { #[test] fn a_busy_tail_opener_is_retryable_without_a_partial_effect() { - let error = tail_error(TailError::OpeningAtCapacity { limit: 1 }); + let error = tail_error(TailError::OpeningAtCapacity { limit: 1 }).into_error_data(); let data = error.data.expect("the failure is classified"); assert_eq!(error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); @@ -816,14 +826,16 @@ mod tests { let existing = tail_error(TailError::Snapshot { error: configuration_error(), opened: false, - }); + }) + .into_error_data(); let existing_data = existing.data.expect("the failure is classified"); assert_eq!(existing_data["kind"], "unreachable"); let error = tail_error(TailError::Snapshot { error: configuration_error(), opened: true, - }); + }) + .into_error_data(); let data = error.data.expect("the failure is classified"); assert_eq!(error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); diff --git a/crates/tmux-mcp/src/tools/pane_input.rs b/crates/tmux-mcp/src/tools/pane_input.rs index fdbd1789..7e9f7d4d 100644 --- a/crates/tmux-mcp/src/tools/pane_input.rs +++ b/crates/tmux-mcp/src/tools/pane_input.rs @@ -8,7 +8,7 @@ use rmcp::model::ErrorData; use crate::TmuxTools; use crate::run_request::{self, PaneReservation}; -use super::error::{bad_input, object_gone, tmux_error, vanished}; +use super::error::{ToolError, bad_input, object_gone, tmux_error, vanished}; #[derive(Clone, Copy)] pub(crate) enum PaneInputReach { @@ -111,7 +111,7 @@ struct ClientAttention { const CLIENT_ATTENTION_FORMAT: &str = "#{client_control_mode}|#{session_id}|#{window_id}|#{window_index}|#{pane_id}|#{window_zoomed_flag}"; -fn client_attention_error(detail: &str) -> ErrorData { +fn client_attention_error(detail: &str) -> ToolError { ErrorData::internal_error( format!("tmux returned malformed client attention state: {detail}"), Some(serde_json::json!({ @@ -120,9 +120,10 @@ fn client_attention_error(detail: &str) -> ErrorData { "stale": false, })), ) + .into() } -fn endpoint_error(message: impl Into) -> ErrorData { +fn endpoint_error(message: impl Into) -> ToolError { ErrorData::internal_error( message.into(), Some(serde_json::json!({ @@ -131,9 +132,10 @@ fn endpoint_error(message: impl Into) -> ErrorData { "stale": false, })), ) + .into() } -fn pane_snapshot_error(detail: &str) -> ErrorData { +fn pane_snapshot_error(detail: &str) -> ToolError { ErrorData::internal_error( format!("tmux returned malformed pane input state: {detail}"), Some(serde_json::json!({ @@ -142,9 +144,10 @@ fn pane_snapshot_error(detail: &str) -> ErrorData { "stale": false, })), ) + .into() } -fn missing_source_error(pane: &str, missing: MissingSource) -> ErrorData { +fn missing_source_error(pane: &str, missing: MissingSource) -> ToolError { match missing { MissingSource::CallerInput => object_gone("pane", pane), MissingSource::ObservedTransition => { @@ -156,7 +159,7 @@ fn missing_source_error(pane: &str, missing: MissingSource) -> ErrorData { } } -pub(crate) fn active_run_error(pane: &str) -> ErrorData { +pub(crate) fn active_run_error(pane: &str) -> ToolError { ErrorData::internal_error( format!( "pane {pane} has an active run_shell_command; wait for its completion or pane closure before sending more input" @@ -167,6 +170,7 @@ pub(crate) fn active_run_error(pane: &str) -> ErrorData { "stale": false, })), ) + .into() } fn parse_flag(value: &[u8]) -> Result { @@ -354,7 +358,7 @@ fn validate_configured_members( generation: ServerGeneration, endpoint: &Path, reservation: Option<&PaneReservation>, -) -> Result<(), ErrorData> { +) -> Result<(), ToolError> { for id in configured { let candidate = members .get(id) @@ -389,7 +393,7 @@ fn validate_configured_members( fn configured_signature( configured: &[String], members: &BTreeMap, -) -> Result, ErrorData> { +) -> Result, ToolError> { configured .iter() .map(|id| { @@ -413,7 +417,7 @@ impl TmuxTools { /// byte into four safe ASCII characters. The configured path is /// byte-exact, and it is the path every command here already travels /// over, so it is what the caller comparison must rest on. - fn pane_input_endpoint(&self) -> Result { + fn pane_input_endpoint(&self) -> Result { let endpoint = self.server.socket_path().to_path_buf(); if !crate::exec::route_path_is_terminal_safe(endpoint.as_os_str()) { return Err(endpoint_error( @@ -428,7 +432,7 @@ impl TmuxTools { pane: &str, reach: PaneInputReach, missing: MissingSource, - ) -> Result { + ) -> Result { self.preflight_pane_input_with_run(pane, reach, missing, None) .await } @@ -439,7 +443,7 @@ impl TmuxTools { reach: PaneInputReach, missing: MissingSource, reservation: &PaneReservation, - ) -> Result { + ) -> Result { self.preflight_pane_input_with_run(pane, reach, missing, Some(reservation)) .await } @@ -450,7 +454,7 @@ impl TmuxTools { reach: PaneInputReach, missing: MissingSource, reservation: Option<&PaneReservation>, - ) -> Result { + ) -> Result { let generation = self .server .generation() @@ -865,7 +869,8 @@ mod tests { ) .await .err() - .expect("the linked caller placement remains protected"); + .expect("the linked caller placement remains protected") + .into_error_data(); assert_eq!( error.data.expect("typed refusal")["kind"], diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index 3af36d8b..9afbce0b 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -78,11 +78,13 @@ impl GuardedDispatch { Self::Send => tools .send_keys(args(serde_json::json!({"pane": pane, "keys": ["C-l"]}))) .await - .map(|_| ()), + .map(|_| ()) + .map_err(tmux_mcp::ToolError::into_error_data), Self::Paste => tools .paste_text(args(serde_json::json!({"pane": pane, "text": "race"}))) .await - .map(|_| ()), + .map(|_| ()) + .map_err(tmux_mcp::ToolError::into_error_data), } }) } @@ -596,6 +598,7 @@ async fn run_error(tools: &TmuxTools, pane: &str, command: &str) -> rmcp::model: .await .err() .unwrap_or_else(|| panic!("guarded run is refused: {command}")) + .into_error_data() } type RunTask = tokio::task::JoinHandle< @@ -631,6 +634,7 @@ async fn waiting_run( tmux_mcp::Reporter::none(), ) .await + .map_err(tmux_mcp::ToolError::into_error_data) } }); await_channel(server, &started).await; @@ -698,7 +702,8 @@ async fn assert_paste_refused_unchanged( .paste_text(args(serde_json::json!({"pane": pane, "text": "guarded"}))) .await .err() - .expect("paste is refused"); + .expect("paste is refused") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], kind); assert_eq!(server.buffer_names().await.expect("buffers list"), buffers); assert_eq!(pane_screen(tools, pane).await, screen); @@ -862,7 +867,8 @@ async fn send_keys_refuses_modal_and_dead_configured_members_before_input() { }))) .await .err() - .expect("a modal configured peer refuses the whole input"); + .expect("a modal configured peer refuses the whole input") + .into_error_data(); assert_eq!( modal_error.data.expect("typed refusal")["kind"], "invalid_input" @@ -916,7 +922,8 @@ async fn pane_input_refuses_input_disabled_configured_members() { }))) .await .err() - .expect("an input-disabled configured peer refuses the whole input"); + .expect("an input-disabled configured peer refuses the whole input") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], "invalid_input"); assert_channel_quiet(guard.server(), channel).await; @@ -952,7 +959,8 @@ async fn pane_input_refuses_terminal_attention_but_not_control_clients() { }))) .await .err() - .expect("the attended active pane refuses input"); + .expect("the attended active pane refuses input") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], "invalid_input"); assert_channel_quiet(guard.server(), source_channel).await; assert_paste_refused_unchanged(&tools, guard.server(), &source, "invalid_input").await; @@ -962,7 +970,8 @@ async fn pane_input_refuses_terminal_attention_but_not_control_clients() { .send_keys(args(serde_json::json!({"pane": source, "keys": ["C-l"]}))) .await .err() - .expect("caller protection still takes precedence"); + .expect("caller protection still takes precedence") + .into_error_data(); assert_self_protection(error, &source); pane_handle(guard.server(), &source) @@ -992,7 +1001,8 @@ async fn pane_input_refuses_terminal_attention_but_not_control_clients() { }))) .await .err() - .expect("every pane visible to a terminal client refuses input"); + .expect("every pane visible to a terminal client refuses input") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], "invalid_input"); assert_channel_quiet(guard.server(), peer_channel).await; @@ -1115,7 +1125,8 @@ async fn pane_input_and_batch_protect_only_reached_caller_panes() { }))) .await .err() - .expect("direct caller input is refused"); + .expect("direct caller input is refused") + .into_error_data(); assert_self_protection(error, &source); assert_channel_quiet(guard.server(), direct_channel).await; @@ -1132,7 +1143,8 @@ async fn pane_input_and_batch_protect_only_reached_caller_panes() { }))) .await .err() - .expect("a synchronized caller peer refuses the whole input"); + .expect("a synchronized caller peer refuses the whole input") + .into_error_data(); assert_self_protection(error, &peer); assert_channel_quiet(guard.server(), peer_channel).await; @@ -1245,7 +1257,8 @@ async fn pane_input_fails_closed_on_incomplete_or_inconsistent_caller_context() .paste_text(args(serde_json::json!({"pane": source, "text": ""}))) .await .err() - .unwrap_or_else(|| panic!("{case} must fail pane input closed")); + .unwrap_or_else(|| panic!("{case} must fail pane input closed")) + .into_error_data(); assert_eq!( error.data.as_ref().expect("typed caller refusal")["kind"], "self_protection", @@ -1363,7 +1376,8 @@ async fn paste_text_keeps_empty_input_buffer_free_and_cleans_late_refusal() { .paste_text(args(serde_json::json!({"pane": pane, "text": "late"}))) .await .err() - .expect("a transition after setup refuses the paste"); + .expect("a transition after setup refuses the paste") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], "invalid_input"); assert!(pane_handle(guard.server(), &pane).await.is_in_mode()); assert_eq!( @@ -1387,7 +1401,8 @@ async fn paste_text_keeps_empty_input_buffer_free_and_cleans_late_refusal() { .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) .await .err() - .expect("even an empty paste performs its target guard"); + .expect("even an empty paste performs its target guard") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], "invalid_input"); assert_eq!( guard.server().buffer_names().await.expect("buffers list"), @@ -1528,7 +1543,8 @@ async fn teardown_refuses_the_inherited_caller_pane() { .kill_pane(args(serde_json::json!({"pane": own}))) .await .map(|_| ()) - .expect_err("caller pane is protected"); + .expect_err("caller pane is protected") + .into_error_data(); assert!(error.message.contains(&own), "{}", error.message); assert_eq!(panes(&tools).await.len(), 1); @@ -1587,7 +1603,8 @@ async fn real_tmux_compat_run_shell_command_reports_output_status_and_cancellati .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) .await .err() - .expect("the interrupted command still reserves the pane"); + .expect("the interrupted command still reserves the pane") + .into_error_data(); assert_active_run(&refusal, outcome); signal_channel(guard.server(), &release).await; assert_eq!( @@ -1747,7 +1764,7 @@ async fn real_tmux_compat_dead_pane_settles_an_interrupted_run() { .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) .await .err() - .and_then(|error| error.data) + .and_then(|error| error.into_error_data().data) .is_some_and(|data| data["kind"] == "invalid_input") }) .await @@ -1838,7 +1855,9 @@ async fn active_run_reservation_is_process_wide_and_guards_all_input() { ("run", overlapping.map(|_| ())), ] { assert_active_run( - &result.expect_err("active run guards every pane-input route"), + &result + .expect_err("active run guards every pane-input route") + .into_error_data(), operation, ); } @@ -1914,7 +1933,8 @@ async fn assert_input_reservation_blocks_run(operation: GuardedDispatch) { ); assert_active_run( &run.err() - .expect("the input reservation refuses the racing run"), + .expect("the input reservation refuses the racing run") + .into_error_data(), &format!("run racing a {}", operation.name()), ); guard.shutdown().await.expect("tmux fixture shuts down"); @@ -2005,7 +2025,8 @@ async fn uncertain_dispatch_keeps_the_run_reserved_until_proven() { ) .await .err() - .expect("the accepted send times out before acknowledgement"); + .expect("the accepted send times out before acknowledgement") + .into_error_data(); assert_eq!( error.data.expect("typed uncertain dispatch")["kind"], "dispatch_unknown" @@ -2017,7 +2038,8 @@ async fn uncertain_dispatch_keeps_the_run_reserved_until_proven() { .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) .await .err() - .expect("uncertain delivery reserves the pane"); + .expect("uncertain delivery reserves the pane") + .into_error_data(); assert_active_run(&refusal, "paste after uncertain dispatch"); signal_channel(guard.server(), release).await; @@ -2166,7 +2188,8 @@ async fn run_reports_phase_aware_source_disappearance() { ) .await .err() - .expect("a pane killed after the initial checkpoint is refused"); + .expect("a pane killed after the initial checkpoint is refused") + .into_error_data(); let final_detail = final_error.data.expect("transition detail"); assert_eq!(final_error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); assert_eq!(final_detail["kind"], "object_gone"); @@ -2190,7 +2213,8 @@ async fn run_reports_phase_aware_source_disappearance() { ) .await .err() - .expect("an initially unknown pane is caller input"); + .expect("an initially unknown pane is caller input") + .into_error_data(); assert_eq!(initial_error.code, rmcp::model::ErrorCode::INVALID_PARAMS); assert_eq!( initial_error.data.expect("caller detail")["kind"], @@ -2315,7 +2339,8 @@ async fn pane_input_rejects_terminal_control_in_the_socket_route() { }))) .await .err() - .expect("pane input rejects a terminal-control socket"); + .expect("pane input rejects a terminal-control socket") + .into_error_data(); assert_eq!(error.data.expect("typed refusal")["kind"], "decode"); assert_eq!(pane_screen(&bootstrap_tools, &pane).await, before); @@ -3001,6 +3026,7 @@ async fn mcp_flag_shaped_metadata_operands_stay_literal() { else { panic!("a flag-shaped invalid layout was executed as an option"); }; + let layout_error = layout_error.into_error_data(); assert!( !layout_error.message.contains("unknown flag"), "the layout reached tmux as an operand: {layout_error:?}" diff --git a/crates/tmux-mcp/tests/protocol.rs b/crates/tmux-mcp/tests/protocol.rs index f57db74b..2f8e2f93 100644 --- a/crates/tmux-mcp/tests/protocol.rs +++ b/crates/tmux-mcp/tests/protocol.rs @@ -174,18 +174,23 @@ async fn read_batch_rejects_more_than_sixteen_operations() { let arguments = json!({"operations": operations, "on_error": "stop"}); let request = CallToolRequestParams::new("call_read_tools_batch") .with_arguments(arguments.as_object().cloned().expect("object arguments")); - let error = wire + let result = wire .client .call_tool(request) .await - .expect_err("seventeen operations exceed the batch limit"); + .expect("too many operations is a tool result, not a protocol error"); - assert!(error.to_string().contains("1 through 16"), "{error}"); + assert_eq!(result.is_error, Some(true), "{result:?}"); + let text = result.content.first().and_then(|block| block.as_text()); + assert!( + text.is_some_and(|text| text.text.contains("1 through 16")), + "{result:?}" + ); wire.shutdown().await; } #[tokio::test] -async fn read_batch_preserves_nested_protocol_errors() { +async fn read_batch_reports_a_nested_business_refusal_as_a_failed_result() { let guard = TestServer::builder().start().await.expect("tmux starts"); let tools = TmuxTools::builder(guard.server().clone()) .selection(selection("inspect")) @@ -207,13 +212,67 @@ async fn read_batch_preserves_nested_protocol_errors() { let structured = response .structured_content .expect("batch has structured content"); - let error = &structured["results"][0]["error"]; + let item = &structured["results"][0]; + + // A nested tool's own tmux-level refusal is not a JSON-RPC protocol + // fault: it comes back as a failed nested `CallToolResult`, in + // `result`, and `error` stays unset for it. + assert_eq!(item["success"], false, "{item}"); + assert!(item["error"].is_null(), "{item}"); + let nested = &item["result"]; + assert_eq!(nested["isError"], true, "{nested}"); + let text = nested["content"][0]["text"] + .as_str() + .expect("nested content is text"); + let detail: Value = serde_json::from_str(text).expect("nested body is JSON"); + assert_eq!(detail["message"], "no pane %999999", "{detail}"); + assert_eq!(detail["data"]["kind"], "object_gone", "{detail}"); + assert_eq!(detail["data"]["retryable"], false, "{detail}"); + assert_eq!(detail["data"]["stale"], true, "{detail}"); + + wire.shutdown().await; + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn read_batch_preserves_a_nested_protocol_error() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = TmuxTools::builder(guard.server().clone()) + .selection(selection("inspect")) + .build(); + let wire = Wire::connect(tools).await; + // A nested tool that is not on this batch operation's own allow list is + // rejected before the nested router is asked at all -- a protocol-shaped + // fault about the batch request itself, not about anything tmux did, so + // it stays in `error` rather than `result`. + let response = wire + .call( + "call_read_tools_batch", + json!({ + "operations": [{ + "tool": "kill_pane", + "arguments": {"pane": "%0"} + }], + "on_error": "stop" + }), + ) + .await; + let structured = response + .structured_content + .expect("batch has structured content"); + let item = &structured["results"][0]; + + assert_eq!(item["success"], false, "{item}"); + assert!(item["result"].is_null(), "{item}"); + let error = &item["error"]; assert_eq!(error["code"], -32602, "{error}"); - assert_eq!(error["message"], "no pane %999999", "{error}"); - assert_eq!(error["data"]["kind"], "object_gone", "{error}"); - assert_eq!(error["data"]["retryable"], false, "{error}"); - assert_eq!(error["data"]["stale"], true, "{error}"); + assert!( + error["message"] + .as_str() + .is_some_and(|message| message.contains("kill_pane")), + "{error}" + ); wire.shutdown().await; guard.shutdown().await.expect("tmux fixture shuts down"); @@ -438,3 +497,58 @@ async fn commandless_creation_runs_the_configured_process() { wire.shutdown().await; guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// A tmux-level refusal is `isError` tool content, not a JSON-RPC error: +/// the model can read it and decide what to do next, and a client +/// that does not surface protocol errors to the model still sees it. +#[tokio::test] +async fn a_tmux_refusal_is_an_is_error_result_not_a_protocol_error() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = TmuxTools::builder(guard.server().clone()) + .selection(selection("inspect")) + .build(); + let wire = Wire::connect(tools).await; + + let request = CallToolRequestParams::new("get_pane_info").with_arguments( + json!({"pane": "%999999"}) + .as_object() + .cloned() + .expect("object arguments"), + ); + let result = wire + .client + .call_tool(request) + .await + .expect("a tmux refusal is a tool result, not a protocol error"); + + assert_eq!(result.is_error, Some(true), "{result:?}"); + let text = result + .content + .first() + .and_then(|block| block.as_text()) + .expect("isError content is text"); + let detail: Value = serde_json::from_str(&text.text).expect("content body is JSON"); + assert_eq!(detail["data"]["kind"], "object_gone", "{detail}"); + + wire.shutdown().await; + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A request the framework cannot route at all is still a JSON-RPC +/// protocol error, because no tool body ever ran to produce content for +/// the model to read. +#[tokio::test] +async fn an_unknown_tool_is_still_a_protocol_error() { + let tools = TmuxTools::builder(libtmux::Server::new().expect("server config")) + .selection(selection("inspect")) + .build(); + let wire = Wire::connect(tools).await; + + let request = CallToolRequestParams::new("this_tool_does_not_exist"); + wire.client + .call_tool(request) + .await + .expect_err("an unknown tool name cannot become a tool result"); + + wire.shutdown().await; +} diff --git a/crates/tmux-mcp/tests/tools.rs b/crates/tmux-mcp/tests/tools.rs index 9c29bb9d..1ba29538 100644 --- a/crates/tmux-mcp/tests/tools.rs +++ b/crates/tmux-mcp/tests/tools.rs @@ -52,7 +52,8 @@ async fn unknown_targets_are_structured_invalid_input() { .capture_pane(args(serde_json::json!({"pane": "%999999"}))) .await .map(|_| ()) - .expect_err("missing pane fails"); + .expect_err("missing pane fails") + .into_error_data(); assert_eq!(error.code, ErrorCode::INVALID_PARAMS); let data = error.data.expect("classification"); @@ -79,7 +80,8 @@ async fn a_malformed_id_is_bad_input_and_a_padded_one_still_resolves() { .capture_pane(args(serde_json::json!({"pane": "not-a-pane"}))) .await .map(|_| ()) - .expect_err("a malformed id fails"); + .expect_err("a malformed id fails") + .into_error_data(); assert_eq!(error.code, ErrorCode::INVALID_PARAMS); let data = error.data.expect("classification"); @@ -180,7 +182,8 @@ async fn layout_input_shaped_like_a_flag_is_not_obeyed() { }))) .await .map(|_| ()) - .expect_err("flag-shaped layout is data"); + .expect_err("flag-shaped layout is data") + .into_error_data(); assert!(error.message.contains("-E"), "{}", error.message); let after: Vec = json(tools.list_panes().await.expect("panes"))["panes"] .as_array() @@ -190,3 +193,46 @@ async fn layout_input_shaped_like_a_flag_is_not_obeyed() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// A layout value `select-layout` cannot parse is refused, and does not +/// take tmux down with it. +/// +/// `select_layout` used to build its own `select-layout` command directly, +/// bypassing `Window::select_layout`'s guard: 3.3 and 3.3a exit on a value +/// they cannot parse, taking every session on the socket with them, and +/// `--` alone does not help, because it turns `-o` from the undo flag into +/// exactly such a value. +#[tokio::test] +async fn select_layout_refuses_an_unparseable_value_and_the_server_survives() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "layout-guard"}))) + .await + .expect("session starts"); + let window = json(tools.list_windows().await.expect("windows"))["windows"][0]["id"] + .as_str() + .expect("a window id") + .to_owned(); + + for value in ["-o", "garbage", ""] { + let error = tools + .select_layout(args(serde_json::json!({"window": window, "layout": value}))) + .await + .map(|_| ()) + .expect_err("select-layout cannot parse this value") + .into_error_data(); + assert_eq!(error.code, ErrorCode::INVALID_PARAMS, "{value:?}: {error:?}"); + } + + // The daemon and its session are unharmed, not merely this process: + // every refused value above would have killed tmux 3.3a outright. + assert!( + guard.server().is_alive().await, + "the server survives every refused value", + ); + let sessions = json(tools.list_sessions().await.expect("sessions")); + assert_eq!(sessions["sessions"][0]["name"], "layout-guard"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From a3f95953cc5873e280393da6f35e3cd810bfe4aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 17:32:32 -0500 Subject: [PATCH 029/117] Control(fix[stream]): End a stream when a never-active window closes PaneOutput::poll_next narrowed on Event::WindowClosed or may_have_added_a_pane(), and tmux reports a watched pane's window closing under it as Event::UnlinkedWindowClosed instead whenever that window was never the session's active one -- neither trigger names it, so the stream hung forever. observed_pane_death_ends_the_stream_via_window_close passed before this fix by coincidence: its watched window is the active one, so killing its last pane also moves the session's active window and tmux sends a %session-changed the existing trigger already covers. Its doc comment claimed the trigger did not depend on that, which was false; corrected it and added observed_pane_death_in_a_never_active_window_ends_the_stream for the case with no such event to lean on. Confirmed the new test hangs on the unfixed trigger (drained real pane output before killing, so the connection's own attach-time %session-changed cannot race the kill and mask the gap) and passes with UnlinkedWindowClosed added. --- crates/libtmux/src/control.rs | 15 +++--- crates/libtmux/src/window.rs | 5 +- crates/libtmux/tests/control.rs | 93 ++++++++++++++++++++++++++++++--- crates/tmux-mcp/tests/tools.rs | 6 ++- 4 files changed, 104 insertions(+), 15 deletions(-) diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index 8c96ede0..d19535d3 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -1456,13 +1456,16 @@ impl Stream for PaneOutput { this.closed = true; return Poll::Ready(None); } - // `LayoutChanged` et al. can mean a pane appeared; `WindowClosed` - // cannot, but it is how the watched pane's own window closing - // under it is reported when that pane was the window's last, - // so both are read the same way: re-list, and end the stream - // if the watched pane is not on it. + // A layout change can mean a pane appeared; a window close + // means the watched pane's own window died -- `Unlinked` + // when it was not the session's active window. All three + // re-list and end the stream if the watched pane is gone. Some(Delivery::Event(event)) => { - if event.may_have_added_a_pane() || matches!(event, Event::WindowClosed { .. }) + if event.may_have_added_a_pane() + || matches!( + event, + Event::WindowClosed { .. } | Event::UnlinkedWindowClosed { .. } + ) { this.narrow(); } diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index f1d008e2..33c63dd8 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -1435,7 +1435,10 @@ impl SavedLayout { let Some(text) = saved.to_str() else { return Self::Unrecognized; }; - if let Some(named) = Self::PRESETS.into_iter().find(|named| named.as_str() == text) { + if let Some(named) = Self::PRESETS + .into_iter() + .find(|named| named.as_str() == text) + { return Self::Preset(named); } // tmux's own `layout_set_lookup` is a prefix match, so `tile` and diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index 0900c54c..6fadb678 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -1141,14 +1141,17 @@ async fn observed_pane_death_ends_the_stream_via_layout_change() { } /// Killing the observed pane ends `next_chunk()` rather than hanging it, when -/// killing it also closes its window because it was the window's last pane -/// (D7). +/// killing it also closes its window because it was the window's last pane, +/// and that window was the session's active one. /// -/// tmux reports this as `%unlinked-window-close` and `%session-window-changed` -/// -- neither of which `may_have_added_a_pane` names, and only the second -/// happens to also emit a covered `%session-changed` for this session's -/// current-window bookkeeping. `WindowClosed`'s own trigger does not depend -/// on that coincidence. +/// The watched window here is active throughout -- the second window is +/// created with `NewWindowOptions`'s default `-d`, so it is never selected -- +/// so tmux also has to move the session's active window to the survivor, and +/// reports that as `%session-changed`, which `may_have_added_a_pane` already +/// covers. That means this case alone cannot tell `WindowClosed`'s own +/// trigger apart from riding along on `%session-changed`; see +/// `observed_pane_death_in_a_never_active_window_ends_the_stream` for the +/// case with no such event to lean on. #[tokio::test] async fn observed_pane_death_ends_the_stream_via_window_close() { let guard = TestServer::builder().start().await.expect("tmux starts"); @@ -1194,6 +1197,82 @@ async fn observed_pane_death_ends_the_stream_via_window_close() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Killing the observed pane ends `next_chunk()` rather than hanging it, when +/// the window that closes under it was never the session's active one. +/// +/// tmux reports this shape as `%unlinked-window-close` alone: no +/// `%session-changed`, because the active window never moves, so +/// `PaneOutput::poll_next`'s narrow trigger must include +/// `Event::UnlinkedWindowClosed` alongside `Event::WindowClosed` and +/// `may_have_added_a_pane()` or the stream hangs forever once the pane's +/// window closes this way. +#[tokio::test] +async fn observed_pane_death_in_a_never_active_window_ends_the_stream() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("pane-death-inactive-window") + .await + .expect("session"); + // The first window stays active throughout: `NewWindowOptions` defaults + // to `-d`, so the second window below is created without selecting it. + let watched_window = session + .new_window(NewWindowOptions::new("closing")) + .await + .expect("a second, unselected window is created"); + let watched = watched_window + .panes() + .await + .expect("panes list") + .into_iter() + .next() + .expect("the new window's one pane"); + + let mut output = watched.stream_output().await.expect("the pane streams"); + // Drains a chunk of real output before the kill, so the connection's own + // attach-time `%session-changed` -- and the narrow it triggers -- cannot + // still be in flight and race the kill below: `send_keys` and its own + // echo cannot arrive before the events that preceded them on the same + // connection have already been processed. + watched + .send_keys("echo mcp-inactive-window-marker\n") + .await + .expect("a marker command is sent"); + loop { + let chunk = tokio::time::timeout(Duration::from_secs(5), output.next_chunk()) + .await + .expect("marker output arrives") + .expect("the stream is alive before the kill"); + if String::from_utf8_lossy(&chunk).contains("mcp-inactive-window-marker") { + break; + } + } + // The typed-line echo above is not necessarily the command's whole + // reaction: drain until a short gap, so a chunk still queued behind it + // is not mistaken later for the stream ending because of the kill. + while let Ok(Some(_)) = + tokio::time::timeout(Duration::from_millis(300), output.next_chunk()).await + {} + + watched.kill().await.expect("the watched pane is killed"); + + let ended = tokio::time::timeout(Duration::from_secs(8), output.next_chunk()).await; + assert_eq!( + ended, + Ok(None), + "next_chunk must end, not hang, once a never-active window closes under it", + ); + + assert!( + server + .has_session("pane-death-inactive-window") + .await + .expect("tmux still answers"), + "the session, and its still-active first window, survive", + ); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// `PaneOutput::sender` reaches the exact connection `stream_output` opened, /// so a caller reading a stream can mute and resume that same stream without /// bypassing `stream_output` and reimplementing its internals. diff --git a/crates/tmux-mcp/tests/tools.rs b/crates/tmux-mcp/tests/tools.rs index 1ba29538..3f7aca15 100644 --- a/crates/tmux-mcp/tests/tools.rs +++ b/crates/tmux-mcp/tests/tools.rs @@ -222,7 +222,11 @@ async fn select_layout_refuses_an_unparseable_value_and_the_server_survives() { .map(|_| ()) .expect_err("select-layout cannot parse this value") .into_error_data(); - assert_eq!(error.code, ErrorCode::INVALID_PARAMS, "{value:?}: {error:?}"); + assert_eq!( + error.code, + ErrorCode::INVALID_PARAMS, + "{value:?}: {error:?}" + ); } // The daemon and its session are unharmed, not merely this process: From 3a1e2371fd7d2ec5e7c8617936638dc5f8a9a5e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 17:37:30 -0500 Subject: [PATCH 030/117] Control(fix[watch]): Scope watch_only's mute list to its own session watch_only listed list-panes -a (every pane on the server) and muted everything not in the caller's watch set, including panes in sessions this connection was never sent output for in the first place: a control client is sent only its attached session's output. On tmux 3.7+ mute_pane's off stops tmux reading a muted pane's pty for every attached client, not just this connection, so watching one pane froze an unrelated session's pane for as long as the stream stayed open. Scoped the listing to -s (this connection's own session) instead. watching_one_pane_does_not_touch_an_unrelated_session opens a second, untouched session's pane and confirms it keeps producing output while a watch is open elsewhere; confirmed it fails (the other pane's marker never arrives within the bound) against the unscoped -a listing and passes with -s. Factored the existing pattern of settling a fresh stream's attach-time narrow before acting into settle_stream, shared with observed_pane_death_in_a_never_active_window_ends_the_stream. --- crates/libtmux/src/control.rs | 14 ++-- crates/libtmux/tests/control.rs | 114 +++++++++++++++++++++++++------- 2 files changed, 100 insertions(+), 28 deletions(-) diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index d19535d3..aba7ea77 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -958,9 +958,15 @@ impl ControlSender { /// Receive output from these panes and no others. /// - /// Lists panes over this same connection, so the answer cannot disagree - /// with the connection it configures, then mutes every pane not named. - /// See [`Self::mute_pane`] for why this beats filtering what arrives. + /// Lists panes in the session this connection attached to -- over this + /// same connection, so the answer cannot disagree with it -- then mutes + /// every one not named. See [`Self::mute_pane`] for why this beats + /// filtering what arrives, and why the listing must not reach past this + /// connection's own session: `off` stops tmux reading a muted pane's pty + /// for every client, not just this connection, so muting a pane outside + /// this session would widen the change to whatever else is watching it, + /// and a control client is sent only its attached session's output in + /// the first place, so panes outside it were never part of this stream. /// /// A pane created after this call is not muted, because tmux publishes no /// notification for a pane appearing. Repeat this whenever @@ -975,7 +981,7 @@ impl ControlSender { let listed = self .send( Command::new("list-panes") - .arg("-a") + .arg("-s") .arg("-F") .arg("#{pane_id}"), ) diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index 6fadb678..b02b4706 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -51,6 +51,41 @@ async fn wait_for( } } +/// Send a marker command into `pane` and drain `output` until it has been +/// seen and the connection has gone quiet. +/// +/// A fresh `stream_output()` connection reports its own attach-time +/// `%session-changed` before anything else, which triggers a narrow whose +/// `list-panes` round trip is still in flight for a moment. A caller that +/// acts immediately -- killing the watched pane, or touching another +/// session -- can race that narrow and have it coincidentally catch the +/// change anyway, which is not evidence the thing under test does. +#[allow(clippy::panic, clippy::expect_used, reason = "test assertion helper")] +async fn settle_stream( + pane: &libtmux::Pane, + output: &mut libtmux::control::PaneOutput, + marker: &str, +) { + pane.send_keys(format!("echo {marker}\n")) + .await + .expect("a settle command is sent"); + loop { + let chunk = tokio::time::timeout(Duration::from_secs(5), output.next_chunk()) + .await + .expect("settle output arrives") + .expect("the stream is alive"); + if String::from_utf8_lossy(&chunk).contains(marker) { + break; + } + } + // The marker line's echo is not necessarily the whole reaction: drain + // until a short gap, so a chunk still queued behind it is not mistaken + // later for something the caller's own action produced. + while let Ok(Some(_)) = + tokio::time::timeout(Duration::from_millis(300), output.next_chunk()).await + {} +} + #[tokio::test] async fn commands_travel_down_one_connection() { let guard = TestServer::builder().start().await.expect("tmux starts"); @@ -788,6 +823,56 @@ async fn a_pane_created_after_narrowing_is_muted_too() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Watching one pane must not touch a pane in an unrelated session. +/// +/// A control client is sent only its attached session's output, so +/// `watch_only` never needed to look past it; listing `list-panes -a` +/// (every pane on the server) instead made it mute an unrelated session's +/// pane too, and on tmux 3.7+ `mute_pane`'s `off` stops tmux reading a +/// muted pane's pty for every client, not just this connection -- freezing +/// the other session's pane for as long as this stream stayed open. +#[tokio::test] +async fn watching_one_pane_does_not_touch_an_unrelated_session() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let watched_session = server + .new_session("watch-scope-watched") + .await + .expect("session"); + let watched = watched_session.panes().await.expect("panes list").remove(0); + + let other_session = server + .new_session("watch-scope-unrelated") + .await + .expect("session"); + let other = other_session.panes().await.expect("panes list").remove(0); + + let mut output = watched.stream_output().await.expect("the pane streams"); + settle_stream(&watched, &mut output, "mcp-watch-scope-settle").await; + + other + .send_line("echo mcp-watch-scope-unrelated-marker") + .await + .expect("the unrelated session's pane runs a command"); + + let arrived = libtmux::test::retry_until(Duration::from_secs(5), async || { + other.capture().await.is_ok_and(|lines| { + lines.iter().any(|line| { + line.to_string_lossy() + .contains("mcp-watch-scope-unrelated-marker") + }) + }) + }) + .await; + assert!( + arrived.is_ok(), + "an unrelated session's pane keeps producing output while a watch is open elsewhere", + ); + + output.shutdown().await.expect("the connection shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// Command output whose every line begins with `%` must survive the block. /// /// A pane id is spelled `%0`, so `list-panes -F '#{pane_id}'` produces rows @@ -1229,30 +1314,11 @@ async fn observed_pane_death_in_a_never_active_window_ends_the_stream() { .expect("the new window's one pane"); let mut output = watched.stream_output().await.expect("the pane streams"); - // Drains a chunk of real output before the kill, so the connection's own - // attach-time `%session-changed` -- and the narrow it triggers -- cannot - // still be in flight and race the kill below: `send_keys` and its own - // echo cannot arrive before the events that preceded them on the same - // connection have already been processed. - watched - .send_keys("echo mcp-inactive-window-marker\n") - .await - .expect("a marker command is sent"); - loop { - let chunk = tokio::time::timeout(Duration::from_secs(5), output.next_chunk()) - .await - .expect("marker output arrives") - .expect("the stream is alive before the kill"); - if String::from_utf8_lossy(&chunk).contains("mcp-inactive-window-marker") { - break; - } - } - // The typed-line echo above is not necessarily the command's whole - // reaction: drain until a short gap, so a chunk still queued behind it - // is not mistaken later for the stream ending because of the kill. - while let Ok(Some(_)) = - tokio::time::timeout(Duration::from_millis(300), output.next_chunk()).await - {} + // Settles the connection's own attach-time `%session-changed`, and the + // narrow it triggers, before the kill below: otherwise that narrow can + // still be in flight and happen to catch the pane's death anyway, + // masking the gap this test exists to catch. + settle_stream(&watched, &mut output, "mcp-inactive-window-marker").await; watched.kill().await.expect("the watched pane is killed"); From 3bb2f4711f712acaa5ce88ef3ade01d0f037a61f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 17:45:16 -0500 Subject: [PATCH 031/117] Listing(fix[create]): Report a missing socket directory honestly create_one (new-session, new-window, split-window) treated tmux's exit 0 with empty stdout as "the object was created but tmux's answer could not be decoded" and wrapped a hardcoded message in AfterEffect. A socket path under a directory that does not exist gets three answers from tmux, and only one of them was handled: 3.2a exits 0 and says nothing at all; 3.3a through 3.7c exit 0 after printing "error creating (No such file or directory)" on stderr; next-3.9 prints that and exits 1. The guard required stderr to be non-empty, so 3.2a fell through to hydration, which found no object and reported a partial effect -- for a command that created nothing. An empty stdout is the whole signal: a creating command that worked always prints the object it made. Take that alone as the refusal, and say plainly that tmux gave no reason when it gave none, rather than inventing one. Added Error::NoEffect for exactly this shape: exit 0, empty stdout, whether or not stderr said anything. Its Display carries tmux's stderr text when there is any, without formatting the exit code, which is always 0 here and reads as a contradiction next to "rejected" if it did. tmux-mcp's default dedicated socket already avoids this: it names its socket with -L (DEFAULT_SOCKET via socket_name), which tmux creates the parent tmux-/ directory for itself, the same as any -L or default socket. Only an explicit --socket / -S path an operator gives is exposed to this, and per the round's contract that path is reported rather than having a directory invented for it. --- crates/libtmux/docs/public-api.txt | 3 ++ crates/libtmux/src/error.rs | 23 +++++++++++++ crates/libtmux/src/error/classification.rs | 2 ++ crates/libtmux/src/internal/listing.rs | 17 ++++++++++ crates/libtmux/tests/server_command.rs | 39 ++++++++++++++++++++++ crates/tmux-mcp/src/tools/error.rs | 2 +- 6 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index d8359a8d..f64fc199 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -1899,6 +1899,8 @@ struct_field libtmux::Error::InvalidServerConfiguration::kind: libtmux::ServerCo struct_field libtmux::Error::InvalidVersionOutput::output_len: usize struct_field libtmux::Error::LinkGone::kind: libtmux::ObjectKind struct_field libtmux::Error::LinkGone::target: String +struct_field libtmux::Error::NoEffect::command: &'static str +struct_field libtmux::Error::NoEffect::stderr: String struct_field libtmux::Error::ObjectGone::id: String struct_field libtmux::Error::ObjectGone::kind: libtmux::ObjectKind struct_field libtmux::Error::OptionRejected::detail: String @@ -2182,6 +2184,7 @@ variant libtmux::Error::InvalidPlan variant libtmux::Error::InvalidServerConfiguration variant libtmux::Error::InvalidVersionOutput variant libtmux::Error::LinkGone +variant libtmux::Error::NoEffect variant libtmux::Error::ObjectGone variant libtmux::Error::OptionRejected variant libtmux::Error::OptionScopeMismatch diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index 48cb2331..9954e62b 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -942,6 +942,24 @@ pub enum Error { stderr: String, }, + /// A creating command exited 0 without creating anything. + /// + /// `tmux -S /missing/sock new-session ...` prints `error creating + /// (No such file or directory)` on stderr and exits 0 with empty + /// stdout: exit 0 usually means the command ran, so this is its own + /// variant rather than [`Self::CommandFailed`], which would print the + /// exit code as `Some(0)` beside the word "rejected" -- a contradiction + /// -- and rather than [`Self::AfterEffect`], which would claim an + /// effect that never happened. + #[non_exhaustive] + #[error("{command} had no effect: {stderr}")] + NoEffect { + /// The tmux command that exited 0 without effect. + command: &'static str, + /// The message tmux printed on stderr. + stderr: String, + }, + /// tmux listing output could not be decoded into typed snapshots. /// /// This reports a disagreement between the crate and the tmux that @@ -1579,6 +1597,11 @@ impl fmt::Debug for Error { .field("exit_code", exit_code) .field("stderr", stderr) .finish(), + Self::NoEffect { command, stderr } => formatter + .debug_struct("NoEffect") + .field("command", command) + .field("stderr", stderr) + .finish(), Self::ObjectGone { kind, id } => formatter .debug_struct("ObjectGone") .field("kind", kind) diff --git a/crates/libtmux/src/error/classification.rs b/crates/libtmux/src/error/classification.rs index 877bc783..3f73f02d 100644 --- a/crates/libtmux/src/error/classification.rs +++ b/crates/libtmux/src/error/classification.rs @@ -77,6 +77,7 @@ impl Error { Self::ClientSuspended { .. } => ErrorKind::Refused, Self::ServerGone { .. } => ErrorKind::ServerGone, Self::CommandFailed { .. } + | Self::NoEffect { .. } | Self::OutputLimitExceeded { .. } | Self::Overloaded { .. } | Self::SessionExists { .. } @@ -200,6 +201,7 @@ impl Error { .. } | Self::CommandFailed { .. } + | Self::NoEffect { .. } | Self::DecodeListing { .. } | Self::UnreadableAccessRule { .. } => false, #[cfg(feature = "plan")] diff --git a/crates/libtmux/src/internal/listing.rs b/crates/libtmux/src/internal/listing.rs index f8a935c1..1db91273 100644 --- a/crates/libtmux/src/internal/listing.rs +++ b/crates/libtmux/src/internal/listing.rs @@ -395,6 +395,23 @@ async fn create_one( target.as_deref(), )); } + // tmux can exit 0 having done nothing: `-S /missing/sock` prints + // `error creating ... (No such file or directory)` on stderr and exits + // 0 with empty stdout, though not every build writes that line -- an + // empty stdout alone already means nothing was created. Either way this + // is a plain refusal, not a partial effect, and tmux's own reason (when + // it gave one) is worth more than the generic message below. + if result.stdout().is_empty() { + let stderr = result.stderr_lossy(); + return Err(Error::NoEffect { + command: command_name, + stderr: if stderr.trim().is_empty() { + "tmux gave no reason".to_owned() + } else { + stderr.into_owned() + }, + }); + } hydrate(result.stdout()) .map_err(|error| error.after_effect(command_name))? diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index 4a6ef24b..99a49064 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -350,6 +350,45 @@ async fn public_capability_raw_command_and_shutdown_boundary_is_usable() { server.shutdown().await.expect("shutdown is idempotent"); } +/// tmux creates the parent directory of `-L`/the default socket itself, but +/// never one a `-S` path names, and it answers three different ways: +/// 3.2a exits **0** saying nothing at all, 3.3a through 3.7c exit **0** after +/// printing `error creating (No such file or directory)` on stderr, and +/// next-3.9 prints that and exits **1**. Nothing is created in any of them, so +/// none may read as a partial effect, and tmux's own reason -- when it gave one +/// -- must not be replaced by a generic message or a `Debug`-formatted exit +/// code. +#[tokio::test] +async fn a_missing_socket_directory_is_a_plain_refusal_not_a_partial_effect() { + let directory = tempfile::tempdir().expect("temporary directory"); + let server = Server::builder() + .socket_path(directory.path().join("missing-parent").join("sock")) + .build() + .expect("a socket under a missing directory is valid setup"); + + let error = server + .new_session("wont-exist") + .await + .expect_err("tmux creates nothing under a missing parent directory"); + + assert_ne!( + error.kind(), + libtmux::ErrorKind::PartialEffect, + "nothing was created, so this is not a partial effect: {error:?}", + ); + let message = error.to_string(); + assert!( + message.contains("error creating") || message.contains("gave no reason"), + "tmux's own reason survives, or the message says there was none: {message}", + ); + assert!( + !message.contains("Some(0)"), + "a Debug-formatted exit code does not leak into the message: {message}", + ); + + server.shutdown().await.expect("shutdown succeeds"); +} + #[tokio::test] async fn capability_probe_is_exact_shared_lazy_and_preserves_versions() { let directory = tempfile::tempdir().expect("temporary directory"); diff --git a/crates/tmux-mcp/src/tools/error.rs b/crates/tmux-mcp/src/tools/error.rs index 974da354..d9a18eb5 100644 --- a/crates/tmux-mcp/src/tools/error.rs +++ b/crates/tmux-mcp/src/tools/error.rs @@ -28,7 +28,7 @@ impl ToolError { /// /// For the one caller that reports a nested tool's own failure inside a /// batch item rather than as this tool's failure - /// ([`super::contract::TmuxTools::call_read_tools_batch`]), and for a + /// ([`crate::TmuxTools::call_read_tools_batch`]), and for a /// test that calls a tool directly and inspects the classification a /// wire client would otherwise read from `isError` content. #[must_use] From 3c5d8af6bd3e080dfcac053a49b2204e38c4232b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 17:47:51 -0500 Subject: [PATCH 032/117] MCP(fix[test]): Assert the layout guard's client-side refusal layout_input_shaped_like_a_flag_is_not_obeyed asserted that a flag-shaped select_layout value ("-E") reached tmux and was refused there, with "-E" in tmux's own message. Since select_layout now goes through Window::select_layout's guard, "-E" is refused client-side as an unrecognized layout before it ever reaches tmux, so tmux's message is no longer what the tool reports. Assert the client-side classification instead; the property under test -- a flag-shaped value is never obeyed as a flag -- still holds, more strongly than before. CI caught this in `just check` and `just compat` on the previous push. --- crates/tmux-mcp/tests/tools.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/tmux-mcp/tests/tools.rs b/crates/tmux-mcp/tests/tools.rs index 3f7aca15..86650a0b 100644 --- a/crates/tmux-mcp/tests/tools.rs +++ b/crates/tmux-mcp/tests/tools.rs @@ -152,6 +152,12 @@ async fn capture_can_include_scrollback() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A layout value shaped like a flag is refused, not obeyed. +/// +/// `select_layout` now goes through `Window::select_layout`'s guard, +/// which refuses `-E` client-side as a value that is not a +/// recognized layout -- it never reaches tmux at all, so it cannot be +/// misread there as the flag that spreads panes evenly either. #[tokio::test] async fn layout_input_shaped_like_a_flag_is_not_obeyed() { let guard = TestServer::builder().start().await.expect("tmux starts"); @@ -182,9 +188,9 @@ async fn layout_input_shaped_like_a_flag_is_not_obeyed() { }))) .await .map(|_| ()) - .expect_err("flag-shaped layout is data") + .expect_err("flag-shaped layout is refused before it reaches tmux") .into_error_data(); - assert!(error.message.contains("-E"), "{}", error.message); + assert_eq!(error.data.expect("classification")["kind"], "invalid_input"); let after: Vec = json(tools.list_panes().await.expect("panes"))["panes"] .as_array() .unwrap() From 45cd777937fdeb6d0ee2bb55adf47549d02c799f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 18:02:14 -0500 Subject: [PATCH 033/117] Server(feat[control]): Track owned control-client pids for exclusion tmux counts any control-mode client as attached the same as a human's terminal, so while this process's own wait_for_text, stream_output, or another observation connection is open, list-clients and every count derived from it (Session::is_attached, session_attached) include it. tmux-mcp's list_sessions read a session as attached while its own wait_for_text was the only thing there. Added Server::owns_control_client(pid), backed by a pid set on Core that spawn_control inserts into and a new drop guard on PersistentChild removes from. The guard lives on PersistentChild rather than on ControlSender or ControlEvents, because either half can be dropped while the other keeps the connection open, and the guard has to track the process, not a caller's handle to it. tmux-mcp's list_sessions, get_server_info, create_session, get_session_info, and rename_session now cross-reference list-clients against owns_control_client through a new foreign_attached_sessions helper, and fall back to the raw count only when that listing itself cannot be read, never fabricating false for a session that may be attached. get_server_info's tool description is corrected from three commands to four. list_sessions_excludes_this_processs_own_observation_client (tests/agent.rs) confirms the exclusion during a real in-flight wait_for_text -- proven against the raw attached count so the wait is shown to have actually attached, and confirmed to fail without the fix -- and that a genuinely foreign tmux -C client this test spawns directly is still reported attached, so the exclusion is not wider than the caller's own clients. owns_control_client_reports_its_own_spawned_connections (tests/control.rs) covers the libtmux-side tracking and release directly. --- crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/internal/core.rs | 32 ++++++++- crates/libtmux/src/internal/process.rs | 41 +++++++++++ crates/libtmux/src/server.rs | 35 +++++++++ crates/libtmux/tests/control.rs | 49 +++++++++++++ crates/tmux-mcp/TOOLS.md | 2 +- crates/tmux-mcp/src/tools/contract.rs | 12 +++- crates/tmux-mcp/src/tools/control.rs | 6 +- crates/tmux-mcp/src/tools/inspect.rs | 14 +++- crates/tmux-mcp/src/tools/mod.rs | 68 ++++++++++++++++-- crates/tmux-mcp/tests/agent.rs | 98 ++++++++++++++++++++++++++ 11 files changed, 344 insertions(+), 14 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index f64fc199..e5653449 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -528,6 +528,7 @@ function libtmux::Server::new: fn() -> Result function libtmux::Server::new_session: async fn(&self, options: impl Into) -> Result function libtmux::Server::option_names: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::options: async fn(&self) -> Result, libtmux::Error> +function libtmux::Server::owns_control_client: fn(&self, pid: u32) -> bool function libtmux::Server::pane_by_id: async fn(&self, id: &libtmux::PaneId) -> Result, libtmux::Error> function libtmux::Server::panes: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::panes_or_empty: async fn(&self) -> Vec diff --git a/crates/libtmux/src/internal/core.rs b/crates/libtmux/src/internal/core.rs index 3ade2e27..12fbea06 100644 --- a/crates/libtmux/src/internal/core.rs +++ b/crates/libtmux/src/internal/core.rs @@ -321,6 +321,15 @@ pub(crate) struct Core { next_request_id: AtomicU64, #[cfg(feature = "control-mode")] persistent_clients: PersistentClients, + /// PIDs of control clients this process itself spawned with + /// [`Self::spawn_control`], for as long as each is still running. + /// + /// Shared with every [`PersistentChild`] this spawns, which removes its + /// own entry on drop -- so this reflects processes, not connection + /// handles, and stays correct across [`crate::control::ControlSender`] + /// and [`crate::control::ControlEvents`] being dropped independently. + #[cfg(feature = "control-mode")] + control_client_pids: Arc>>, } impl Core { @@ -349,6 +358,8 @@ impl Core { next_request_id: AtomicU64::new(1), #[cfg(feature = "control-mode")] persistent_clients: PersistentClients::new(control_client_limits), + #[cfg(feature = "control-mode")] + control_client_pids: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), } } @@ -441,7 +452,26 @@ impl Core { tokio::time::Instant::now().checked_add(self.configuration.timeout), ) .await?; - PersistentChild::spawn(&self.configuration.launch, &request, reservation) + PersistentChild::spawn( + &self.configuration.launch, + &request, + reservation, + Arc::clone(&self.control_client_pids), + ) + } + + /// Report whether this process itself spawned the control client with + /// this pid, and it is still running. + /// + /// For a caller telling a human's attached client apart from a control + /// connection this same process opened to watch or wait on the server: + /// [`Self::spawn_control`] is the only thing that adds an entry. + #[cfg(feature = "control-mode")] + pub(crate) fn owns_control_client(&self, pid: u32) -> bool { + self.control_client_pids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&pid) } pub(crate) async fn shutdown(&self) -> Result<(), Error> { diff --git a/crates/libtmux/src/internal/process.rs b/crates/libtmux/src/internal/process.rs index 2cafa88c..f94370e6 100644 --- a/crates/libtmux/src/internal/process.rs +++ b/crates/libtmux/src/internal/process.rs @@ -395,6 +395,29 @@ impl Drop for PersistentRegistration { } } +/// Removes this control client's pid from the shared owned set on drop. +/// +/// Lives on [`PersistentChild`] rather than on either half a caller splits +/// it into ([`crate::control::ControlSender`], [`crate::control::ControlEvents`]): +/// either can be dropped while the other keeps the connection, and the +/// underlying `Child` -- what this pid actually names -- lives exactly as +/// long as `PersistentChild` does. +#[cfg(feature = "control-mode")] +struct ControlClientPidGuard { + pids: Arc>>, + pid: u32, +} + +#[cfg(feature = "control-mode")] +impl Drop for ControlClientPidGuard { + fn drop(&mut self) { + self.pids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.pid); + } +} + #[cfg(feature = "control-mode")] pub(crate) struct PersistentChild { // The group guard must drop while the unreaped leader still anchors its PGID. @@ -405,6 +428,10 @@ pub(crate) struct PersistentChild { command: CommandSummary, // Admission remains active until process cleanup has finished. _registration: PersistentRegistration, + // Removes this pid from the owned set once dropped; its ordering + // relative to the other fields does not matter, since nothing here reads + // it back. + _pid_guard: Option, } #[cfg(feature = "control-mode")] @@ -413,6 +440,7 @@ impl PersistentChild { launch: &LaunchContext, request: &CommandRequest, reservation: PersistentReservation, + control_client_pids: Arc>>, ) -> Result { validate_request(launch, request)?; let mut command = launch.command(request.argv()); @@ -422,6 +450,18 @@ impl PersistentChild { .stderr(Stdio::null()); let child = command.spawn().map_err(Error::control_mode)?; let process_group = ProcessGroupGuard::new(child.id()); + // `child.id()` is `None` only once the child has already been + // reaped, which cannot be true immediately after `spawn` returns. + let pid_guard = child.id().map(|pid| { + control_client_pids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(pid); + ControlClientPidGuard { + pids: control_client_pids, + pid, + } + }); Ok(Self { process_group, @@ -430,6 +470,7 @@ impl PersistentChild { request_id: request.request_id(), command: request.summary().clone(), _registration: reservation.registration, + _pid_guard: pid_guard, }) } diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 077a3532..5b3f2fb7 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -399,6 +399,41 @@ impl Server { self.core.spawn_control(session).await } + /// Report whether this process itself opened the control client with + /// this pid, and it is still running. + /// + /// [`crate::Pane::stream_output`], [`crate::Client::pid`] and friends can + /// all open or report a control-mode connection; tmux counts any of them + /// as an attached client the same as a human's terminal. A caller telling + /// its own observation apart from an attached human reads every + /// [`crate::Client::pid`] it cares about through this rather than + /// tracking one connection it happened to keep, because more than one may + /// be open at once. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// let guard = libtmux::test::TestServer::new().await?; + /// let server = guard.server(); + /// + /// // Nothing has opened a control client yet. + /// assert!(!server.owns_control_client(std::process::id())); + /// + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "control-mode")] + #[must_use] + pub fn owns_control_client(&self, pid: u32) -> bool { + self.core.owns_control_client(pid) + } + /// Construct a server from the captured default endpoint context. /// /// # Errors diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index b02b4706..16e644d9 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -119,6 +119,55 @@ async fn commands_travel_down_one_connection() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// `Server::owns_control_client` reports a pid this process itself spawned +/// with `ControlMode::attach`, and releases it once that connection ends. +/// +/// The pid this checks is discovered independently, through +/// `Server::clients`, rather than plumbed out of the connection: a caller +/// telling its own observation apart from a human's attached client has +/// only every listed client's pid to go on. +#[tokio::test] +async fn owns_control_client_reports_its_own_spawned_connections() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("owns-control-client") + .await + .expect("session"); + + assert!( + server.clients().await.expect("clients list").is_empty(), + "nothing is attached before any connection opens", + ); + + let control = ControlMode::attach(server, session.id()) + .await + .expect("control mode attaches"); + + let clients = server.clients().await.expect("clients list"); + let pids: Vec = clients.iter().map(libtmux::Client::pid).collect(); + assert_eq!(pids.len(), 1, "exactly one client is attached: {pids:?}"); + let pid = pids[0]; + assert!( + server.owns_control_client(pid), + "the connection this process just spawned is its own", + ); + assert!( + !server.owns_control_client(pid.wrapping_add(1)), + "an arbitrary other pid is not", + ); + + control.shutdown().await.expect("control mode shuts down"); + + libtmux::test::retry_until(Duration::from_secs(5), async || { + !server.owns_control_client(pid) + }) + .await + .expect("the pid is released once the connection actually ends"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[tokio::test] async fn stream_reports_server_shutdown_once_before_eof() { let guard = TestServer::builder().start().await.expect("tmux starts"); diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 073a8cc3..5a0c6141 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -171,7 +171,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. Return metad ## `get_server_info` -Inspect tmux metadata; accepts no client-supplied executable input. Report every session with its windows and panes, in one call. Prefer this over calling the three listing tools separately: it costs tmux three commands rather than one per object. +Inspect tmux metadata; accepts no client-supplied executable input. Report every session with its windows and panes, in one call. Prefer this over calling the three listing tools separately: it costs tmux four commands rather than one per object. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 83861042..aba26437 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -439,11 +439,15 @@ impl TmuxTools { Parameters(crate::SessionArgs { session }): Parameters, ) -> Result, ToolError> { let session = self.find_session(&session).await?; + let foreign_attached = self.foreign_attached_sessions().await; Ok(Json(SessionView { id: session.id().to_string(), name: lossy(session.name()), windows: session.window_count(), - attached: session.is_attached(), + attached: foreign_attached.as_ref().map_or_else( + || session.is_attached(), + |set| set.contains(&session.id().to_string()), + ), })) } @@ -588,11 +592,15 @@ impl TmuxTools { .rename(libtmux::escape_format(name)) .await .map_err(|error| tmux_error(&error))?; + let foreign_attached = self.foreign_attached_sessions().await; Ok(Json(SessionView { id: session.id().to_string(), name: lossy(session.name()), windows: session.window_count(), - attached: session.is_attached(), + attached: foreign_attached.as_ref().map_or_else( + || session.is_attached(), + |set| set.contains(&session.id().to_string()), + ), })) } diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index b47a9740..f258174d 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -245,12 +245,16 @@ impl TmuxTools { .new_session(options) .await .map_err(|e| tmux_error(&e))?; + let foreign_attached = self.foreign_attached_sessions().await; Ok(Json(SessionView { id: session.id().to_string(), name: lossy(session.name()), windows: session.window_count(), - attached: session.is_attached(), + attached: foreign_attached.as_ref().map_or_else( + || session.is_attached(), + |set| set.contains(&session.id().to_string()), + ), })) } diff --git a/crates/tmux-mcp/src/tools/inspect.rs b/crates/tmux-mcp/src/tools/inspect.rs index f3b4d65f..af48073e 100644 --- a/crates/tmux-mcp/src/tools/inspect.rs +++ b/crates/tmux-mcp/src/tools/inspect.rs @@ -113,7 +113,11 @@ impl TmuxTools { )] pub async fn list_sessions(&self) -> Result, ToolError> { let sessions = self.server.sessions().await.map_err(|e| tmux_error(&e))?; - Ok(Json(Self::render_sessions(&sessions))) + let foreign_attached = self.foreign_attached_sessions().await; + Ok(Json(Self::render_sessions( + &sessions, + foreign_attached.as_ref(), + ))) } /// List every window on the server, one row per session link. @@ -145,18 +149,22 @@ impl TmuxTools { name = "get_server_info", description = "Report every session with its windows and panes, in one call. \ Prefer this over calling the three listing tools separately: \ - it costs tmux three commands rather than one per object.", + it costs tmux four commands rather than one per object.", title = "Describe Server", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}; always_load) )] pub async fn describe(&self) -> Result, ToolError> { let tree = self.server.hierarchy().await.map_err(|e| tmux_error(&e))?; + let foreign_attached = self.foreign_attached_sessions().await; let sessions: Vec<_> = tree .iter() .map(|branch| Branch { id: branch.session.id().to_string(), name: lossy(branch.session.name()), - attached: branch.session.is_attached(), + attached: foreign_attached.as_ref().map_or_else( + || branch.session.is_attached(), + |set| set.contains(&branch.session.id().to_string()), + ), windows: branch .windows .iter() diff --git a/crates/tmux-mcp/src/tools/mod.rs b/crates/tmux-mcp/src/tools/mod.rs index 2f075224..72cb6c63 100644 --- a/crates/tmux-mcp/src/tools/mod.rs +++ b/crates/tmux-mcp/src/tools/mod.rs @@ -5,6 +5,7 @@ mod inspect; mod observe; mod pane_input; +use std::collections::BTreeSet; use std::path::Path; use std::time::Duration; @@ -62,20 +63,75 @@ pub(super) fn router() -> rmcp::handler::server::router::tool::ToolRouter Sessions { + /// + /// `foreign_attached` overrides `Session::is_attached` when it is + /// `Some`: see [`Self::foreign_attached_sessions`]. + pub(super) fn render_sessions( + sessions: &[libtmux::Session], + foreign_attached: Option<&BTreeSet>, + ) -> Sessions { Sessions { sessions: sessions .iter() - .map(|session| SessionView { - id: session.id().to_string(), - name: lossy(session.name()), - windows: session.window_count(), - attached: session.is_attached(), + .map(|session| { + let id = session.id().to_string(); + let attached = foreign_attached + .map_or_else(|| session.is_attached(), |set| set.contains(&id)); + SessionView { + id, + name: lossy(session.name()), + windows: session.window_count(), + attached, + } }) .collect(), } } + /// The sessions with a client attached that this process did not open + /// for its own observation. + /// + /// While `wait_for_text`, `stream_output`, or any other control + /// connection this server opens is live, tmux counts it as an attached + /// client the same as a human's terminal: `Session::is_attached` alone + /// cannot tell the two apart. `Server::owns_control_client` resolves + /// each client this reads from `list-clients` by pid, so a session + /// reads as attached only when something else is there too. + /// + /// `None` when the listing itself could not be read: an empty server + /// reports `no current target` for a server-wide listing, and every + /// caller here falls back to `Session::is_attached` rather than + /// answering `false` for a session that may well be attached. + pub(super) async fn foreign_attached_sessions(&self) -> Option> { + let result = self + .server + .cmd( + libtmux::Command::new("list-clients") + .arg("-F") + .arg("#{session_id} #{client_pid}"), + ) + .await + .ok()?; + if !result.success() { + return None; + } + + let mut sessions = BTreeSet::new(); + for line in result.stdout_lossy().lines() { + let mut fields = line.split(' '); + let (Some(session), Some(pid)) = (fields.next(), fields.next()) else { + continue; + }; + let Ok(pid) = pid.parse::() else { + continue; + }; + if !self.server.owns_control_client(pid) { + sessions.insert(session.to_owned()); + } + } + Some(sessions) + } + /// Read what the last command in a pane printed. /// /// tmux records where a prompt and its output begin from the OSC 133 diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index 9afbce0b..6a54c588 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -2783,6 +2783,104 @@ async fn wait_and_cursor_tools_observe_live_output() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// `list_sessions` must not read a session as attached while this server's +/// own `wait_for_text` is the only client there. +/// +/// tmux really does count the wait's control connection as an attached +/// client, so the test waits for that raw count to prove the wait is +/// attached before asserting `list_sessions` excludes it -- the property +/// under test is the exclusion, not a delay long enough for a client to +/// show up. +#[tokio::test] +async fn list_sessions_excludes_this_processs_own_observation_client() { + let (guard, tools, pane) = typing_fixture("watch-excludes-own-client").await; + let name = "watch-excludes-own-client"; + + let listed_before = json(tools.list_sessions().await.expect("sessions")); + assert_eq!(listed_before["sessions"][0]["attached"], false); + + let waiting = tokio::spawn({ + let tools = tools.clone(); + let pane = pane.clone(); + async move { + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["mcp-rs2-11-never-matches"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + } + }); + + let raw_attached = libtmux::test::retry_until(Duration::from_secs(3), async || { + guard + .server() + .session(name) + .await + .is_ok_and(|session| session.is_some_and(|found| found.is_attached())) + }) + .await; + assert!( + raw_attached.is_ok(), + "the wait's own control client attaches, which is what tmux itself counts", + ); + + let listed_during = json(tools.list_sessions().await.expect("sessions")); + assert_eq!( + listed_during["sessions"][0]["attached"], false, + "this server's own observation client is not an attached client: {listed_during}", + ); + + waiting + .await + .expect("the wait task joins") + .expect("wait_for_text answers"); + + // The other half of D2: excluding this server's own clients must not + // widen into excluding every client. A foreign control connection this + // test spawns directly, outside TmuxTools, still reads as attached. + let executable = guard + .server() + .resolved_tmux_executable() + .expect("fixture tmux resolves"); + let mut foreign = std::process::Command::new(executable) + .arg("-S") + .arg(guard.server().socket_path()) + .arg("-C") + .arg("attach") + .arg("-t") + .arg(name) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("a foreign control client starts"); + + let listed_with_foreign = libtmux::test::retry_until(Duration::from_secs(5), async || { + json( + tools + .list_sessions() + .await + .expect("sessions answers while a foreign client is attached"), + )["sessions"][0]["attached"] + == true + }) + .await; + let _ = foreign.kill(); + let _ = foreign.wait(); + assert!( + listed_with_foreign.is_ok(), + "a genuinely foreign client is not excluded", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// `send_keys` then `wait_for_text` for a pattern the typed line's own echo /// already shows must not report `matched` (RS-4). /// From ef0dce9fa58d45972db6bf2d6784bc322b9808c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 18:18:06 -0500 Subject: [PATCH 034/117] Wait(fix[echo]): Tell a pending line apart from real output read_present_at_entry was a screen-snapshot check with no notion of a pending input line: it matched a pattern anywhere on the visible screen, so the same marker sent moments earlier with send_keys and its own command's printed output -- "echo MARKER"'s own echo and its own output -- were indistinguishable. Waiting again for the same marker after submitting it reported the identical present_at_entry shape as before submitting it. Added Screen, which reads a pane's cursor row before its screen (two round trips, not atomic, but wrong only in the safe direction: a line arriving between them can only push the cursor down and exclude more from `above`, never less) and splits the visible rows into `above` (completed output) and `pending` (the row still being typed into). read_present_at_entry now reports PresentAtEntry for a match in `above` and the new WaitOutcome::Pending for one only in `pending`. wait_on_output's read loop gets the same discrimination for output that arrives after attaching, which is where the sharper edge lives: the kernel echoes typed keys at once and a shell's line editor re-prints the buffer when it starts reading, both genuinely new bytes on the connection that can still be the pane's own unsubmitted line. A stream match is confirmed against the current screen before being reported Matched; unconfirmed, the wait keeps running rather than giving up, since the real submission may still arrive before the deadline. reconcile_deadline's own promotion (a chunk arriving the same instant the deadline elapses) needed the identical confirmation, or it reintroduced the same gap at the one call site the read loop's guard does not cover. Four tests in tests/agent.rs cover this: typing a marker without Enter still reports Pending; waiting again after submitting reports the command's own output, confirmed against the confirmed literal fix; a match that arrives genuinely after the wait attaches is still Matched (the no-regression case); and typing a marker while a wait is already open, without submitting it, must not be reported Matched. Each was run against the unfixed code first and shown to fail for the stated reason. send_then_wait_does_not_match_the_commands_own_echo's expected outcome moves from present_at_entry to pending to match. --- crates/tmux-mcp/src/exec.rs | 157 ++++++++++++++++++----- crates/tmux-mcp/src/exec/tests.rs | 1 + crates/tmux-mcp/tests/agent.rs | 198 +++++++++++++++++++++++++++++- 3 files changed, 323 insertions(+), 33 deletions(-) diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index 5767be9e..b9bbebc4 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -76,16 +76,25 @@ pub enum RunOutcome { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WaitOutcome { - /// A wanted pattern was already on the pane's screen before this began - /// watching, rather than in output that arrived afterward. + /// A wanted pattern was already in the pane's output before this began + /// watching, on a row above the one still being typed into. /// /// A wait only sees what a pane writes after it starts, so this is never - /// folded into [`Self::Matched`]: the same pattern sent moments earlier - /// with `send_keys` can already be sitting there as the shell's own echo - /// of the typed command, and a caller that treated that as a fresh match - /// would act before the command it sent had necessarily run. Choosing a - /// pattern not already visible waits for one that has not happened yet. + /// folded into [`Self::Matched`]: the same pattern printed moments + /// earlier -- an earlier command's own output, or the shell's echo of a + /// line that has already been submitted -- can already be sitting there, + /// and a caller that treated that as a fresh match would act on + /// something that happened before this call, not because of it. PresentAtEntry, + /// A wanted pattern's only occurrence is the row still being typed + /// into: text this server (or a person sharing the pane) sent and has + /// not submitted, not anything that has run. + /// + /// Submit it, then wait again: the next wait sees the command's own + /// output on a row above a new one still being typed into, which is + /// [`Self::PresentAtEntry`] or [`Self::Matched`] depending on when it + /// arrived, never this. + Pending, /// A pattern matched, in output that arrived after the wait attached. Matched, /// A stop pattern matched, so the wait ended early. @@ -266,14 +275,74 @@ pub(crate) async fn wait_for_text_with_limits( let _ = output.shutdown().await; return Ok(view); } - wait_on_output(output, patterns, stops, timeout, cancelled).await + wait_on_output(pane, output, patterns, stops, timeout, cancelled).await +} + +/// One pane's screen, split at the row still being typed into. +/// +/// Two tmux round trips read this, not one: the cursor row first, then the +/// screen. They are not atomic, so a line arriving between the two can only +/// move the cursor down and make `pending` cover a later row -- excluding +/// more from `above`, never less -- which is the safe direction to be wrong +/// in. +struct Screen { + /// Every visible row above the one still being typed into, each + /// terminated with a newline: completed output, never text this server + /// or a person sent and has not submitted. + above: Vec, + /// The row still being typed into, terminated with a newline to match + /// `above`'s rows. + pending: Vec, +} + +impl Screen { + /// Capture `pane`'s current screen, split at its cursor row. + /// + /// `None` when the pane cannot be read; the same failure surfaces again + /// from whatever the caller does next. + async fn capture(pane: &Pane) -> Option { + let cursor_row: usize = pane + .format("#{cursor_y}") + .await + .ok()? + .to_string_lossy() + .trim() + .parse() + .ok()?; + let lines = pane.capture_with(CaptureOptions::visible()).await.ok()?; + // A cursor row past the last captured line is conservative rather + // than a decode failure: treat every visible row as still pending. + let pending_row = cursor_row.min(lines.len().saturating_sub(1)); + + let mut above = Vec::new(); + for line in lines.iter().take(pending_row) { + above.extend_from_slice(line.as_bytes()); + above.push(b'\n'); + } + let mut pending = lines + .get(pending_row) + .map_or_else(Vec::new, |line| line.as_bytes().to_vec()); + pending.push(b'\n'); + + Some(Self { above, pending }) + } + + /// Both halves, in screen order, for a view that reports the whole + /// thing rather than only whichever half matched. + fn whole(&self) -> Vec { + let mut all = self.above.clone(); + all.extend_from_slice(&self.pending); + all + } } -/// Report a wanted pattern already on the pane's screen, before any stream -/// attaches to watch for one arriving. +/// Report a wanted pattern already in the pane's output, or still only on +/// the row being typed into, before any stream attaches to watch for one +/// arriving. /// -/// See [`WaitOutcome::PresentAtEntry`] for why this is a distinct outcome -/// from [`WaitOutcome::Matched`] rather than a flag alongside it. +/// See [`WaitOutcome::PresentAtEntry`] and [`WaitOutcome::Pending`] for why +/// these are distinct outcomes from [`WaitOutcome::Matched`] rather than a +/// flag alongside it. async fn read_present_at_entry( pane: &Pane, patterns: &Patterns, @@ -286,30 +355,36 @@ async fn read_present_at_entry( // A screen that cannot be read is not a reason to refuse to wait; the // same failure surfaces from the attach right after this. - let Ok(lines) = pane.capture_with(CaptureOptions::visible()).await else { + let Some(screen) = Screen::capture(pane).await else { return Ok(None); }; - let mut screen = Vec::new(); - for line in &lines { - screen.extend_from_slice(line.as_bytes()); - screen.push(b'\n'); - } - let Some((index, source)) = patterns.first_match(&screen) else { + + let outcome = patterns + .first_match(&screen.above) + .map(|found| (WaitOutcome::PresentAtEntry, found)) + .or_else(|| { + patterns + .first_match(&screen.pending) + .map(|found| (WaitOutcome::Pending, found)) + }); + let Some((outcome, (index, source))) = outcome else { return Ok(None); }; + let whole = screen.whole(); Ok(Some(WaitView { pane: pane.id().to_string(), - outcome: WaitOutcome::PresentAtEntry, + outcome, matched_index: Some(index), matched_pattern: Some(source.to_owned()), - text: String::from_utf8_lossy(&screen).into_owned(), - bytes: screen.len(), + text: String::from_utf8_lossy(&whole).into_owned(), + bytes: whole.len(), })) } /// The read loop [`wait_for_text_with_limits`] runs once attached. async fn wait_on_output( + pane: &Pane, mut output: libtmux::control::PaneOutput, patterns: &Patterns, stops: &Patterns, @@ -354,10 +429,22 @@ async fn wait_on_output( break; } } else if let Some((index, source)) = patterns.first_match(&text) { - outcome = WaitOutcome::Matched; - matched_index = Some(index); - matched_pattern = Some(source.to_owned()); - break; + // Fresh bytes on this connection are not necessarily a + // submitted line: the kernel echoes what was typed at + // once, and a shell's line editor re-prints the buffer + // when it starts reading, both genuinely new output that + // can still be sitting on the row being typed into. + // Confirmed only once the *current* screen shows the + // pattern above that row. + let confirmed = Screen::capture(pane) + .await + .is_some_and(|screen| patterns.first_match(&screen.above).is_some()); + if confirmed { + outcome = WaitOutcome::Matched; + matched_index = Some(index); + matched_pattern = Some(source.to_owned()); + break; + } } if text.len() > OUTPUT_LIMIT { @@ -391,9 +478,23 @@ async fn wait_on_output( // A chunk can arrive in the same instant the deadline elapses; without // this, that race would report `Deadline` while `text` already holds a - // match, which is exactly the shape DOTNET-10 hit in another port. - let (outcome, matched_index, matched_pattern) = + // match, the same shape the .NET port hit. + let (mut outcome, mut matched_index, mut matched_pattern) = reconcile_deadline(outcome, matched_index, matched_pattern, patterns, &text); + // `reconcile_deadline` reads the same accumulated buffer the main loop + // does, and is subject to the same trap: the promotion it just made can + // still be the pane's own not-yet-submitted line racing the deadline, + // not a genuine match. Confirmed the same way, against the row still + // being typed into, or the promotion is undone. + if matches!(outcome, WaitOutcome::Matched) + && Screen::capture(pane) + .await + .is_none_or(|screen| patterns.first_match(&screen.above).is_none()) + { + outcome = WaitOutcome::Deadline; + matched_index = None; + matched_pattern = None; + } Ok(WaitView { pane: pane_id, diff --git a/crates/tmux-mcp/src/exec/tests.rs b/crates/tmux-mcp/src/exec/tests.rs index 97f23a1c..5db6f7e0 100644 --- a/crates/tmux-mcp/src/exec/tests.rs +++ b/crates/tmux-mcp/src/exec/tests.rs @@ -1020,6 +1020,7 @@ async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() let cancelled = CancellationToken::new(); let error = wait_on_output( + &pane, output, &patterns, &stops, diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index 6a54c588..e8f12101 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -2882,11 +2882,13 @@ async fn list_sessions_excludes_this_processs_own_observation_client() { } /// `send_keys` then `wait_for_text` for a pattern the typed line's own echo -/// already shows must not report `matched` (RS-4). +/// already shows must not report `matched`. /// /// The line is typed without Enter and the wait begins only once the echo is -/// on screen, so the pattern is present at entry on every run rather than on -/// whichever runs the shell happened to echo first. +/// on screen, so the pattern sits on the row still being typed into on every +/// run rather than on whichever runs the shell happened to echo first, and +/// is reported `pending` rather than `present_at_entry`: nothing has +/// run yet. #[tokio::test] async fn send_then_wait_does_not_match_the_commands_own_echo() { let (guard, tools, pane) = typing_fixture("send-then-wait").await; @@ -2924,8 +2926,194 @@ async fn send_then_wait_does_not_match_the_commands_own_echo() { .expect("wait answers"), ); assert_eq!( - waited["outcome"], "present_at_entry", - "a pattern already on screen before the wait attached must not read as a fresh match: {waited}", + waited["outcome"], "pending", + "a pattern only on the row still being typed into is not a match: {waited}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// Waiting again for the same marker after submitting it reports the +/// command's own output, not the same `pending` answer as before it ran. +/// +/// `echo MCPMARKER2` puts the marker in both the typed line and the line's +/// own output, which is exactly the shape that used to make both waits +/// answer identically: `present_at_entry`/`pending` is a screen-snapshot +/// check with no notion of a row still being typed into, so it could not +/// tell "only the unsubmitted echo" apart from "the command's own output, +/// already printed." +#[tokio::test] +async fn waiting_again_after_submitting_reports_the_output_not_the_echo() { + let (guard, tools, pane) = typing_fixture("send-then-wait-again").await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo MCPMARKER2", + "enter": false + }))) + .await + .expect("input is sent"); + let waited = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MCPMARKER2"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait answers"), + ); + assert_eq!(waited["outcome"], "pending", "{waited}"); + + tools + .send_keys(args(serde_json::json!({"pane": pane, "enter": true}))) + .await + .expect("the line is submitted"); + + let waited_again = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MCPMARKER2"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait answers"), + ); + assert_eq!( + waited_again["outcome"], "present_at_entry", + "the command genuinely ran, so this is its output, not the unsubmitted echo: {waited_again}", + ); + let text = waited_again["text"].as_str().expect("text field"); + assert!( + text.matches("MCPMARKER2").count() >= 2, + "the report includes both the echoed command line and its own printed output: {text:?}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A pattern that arrives genuinely after the wait attached is still +/// reported `matched`, even though the same text sat unsubmitted on the +/// pending line moments earlier: a later confirmed match must not be +/// swallowed by the earlier pending one. +#[tokio::test] +async fn a_match_after_the_wait_attaches_is_still_reported() { + let (guard, tools, pane) = typing_fixture("wait-then-submit").await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo MCPMARKER3", + "enter": false + }))) + .await + .expect("input is sent"); + libtmux::test::retry_until(Duration::from_secs(5), async || { + guard + .server() + .cmd(Command::new("capture-pane").arg("-p").arg("-t").arg(&pane)) + .await + .is_ok_and(|captured| captured.stdout_lossy().contains("MCPMARKER3")) + }) + .await + .expect("the shell echoes the typed line"); + + let waiting = tokio::spawn({ + let tools = tools.clone(); + let pane = pane.clone(); + async move { + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["submitted-MCPMARKER3"], + "seconds": 10 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + } + }); + tokio::time::sleep(Duration::from_millis(200)).await; + + tools + .send_keys(args(serde_json::json!({"pane": pane, "enter": true}))) + .await + .expect("the line is submitted"); + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo submitted-MCPMARKER3", + "enter": true + }))) + .await + .expect("a second command runs and prints the awaited marker"); + + let waited = json(waiting.await.expect("wait joins").expect("wait answers")); + assert_eq!(waited["outcome"], "matched", "{waited}"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// Typing a marker without submitting it while a wait is already attached +/// must not report `matched`, even though the kernel's echo of the typed +/// keys is genuinely new output on this connection. +/// +/// This is the same trap `send_then_wait_does_not_match_the_commands_own_echo` +/// closes at attach time, reproduced against the stream-reading loop +/// instead of the entry screen: `patterns.first_match` on the accumulated +/// stream bytes alone cannot tell "the pane's own not-yet-submitted line" +/// apart from real output, only a check against the current screen's row +/// still being typed into can. +#[tokio::test] +async fn typing_a_marker_without_submitting_while_a_wait_is_open_is_not_matched() { + let (guard, tools, pane) = typing_fixture("type-while-waiting").await; + + let waiting = tokio::spawn({ + let tools = tools.clone(); + let pane = pane.clone(); + async move { + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MCPMARKER4"], + "seconds": 3 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + } + }); + // The wait must be attached before the marker is typed: the point is a + // pattern arriving as fresh stream output, not one already on the + // screen `read_present_at_entry` reads before this task even starts. + tokio::time::sleep(Duration::from_millis(200)).await; + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo MCPMARKER4", + "enter": false + }))) + .await + .expect("input is sent, not submitted"); + + let waited = json(waiting.await.expect("wait joins").expect("wait answers")); + assert_eq!( + waited["outcome"], "deadline", + "typed but never submitted text must not be reported matched: {waited}", ); guard.shutdown().await.expect("tmux fixture shuts down"); From d035909951575076036304580273194604f8d697 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 18:22:12 -0500 Subject: [PATCH 035/117] Server(fix[channels]): Document lock_channel's cancellation hazard cmd_wait_for_unlock hands a wait-for channel to the next queued locker with no mechanism to skip one whose client already disconnected, so cancelling lock_channel while it is queued behind another locker -- not while it holds the lock -- wedges the channel permanently for every later caller: a tmux defect, not something this crate can prevent. wait_for_channel's doc claims that dropping the dispatch leaves the channel usable, which is true for that non-locking form (an idempotent latch check) but does not carry over to lock_channel (a queue) with nothing in either doc comment saying so. Documented the hazard on lock_channel, pointed unlock_channel at it, and scoped wait_for_channel's existing claim to its own non-locking form so a reader does not misapply it. real_tmux_compat_cancelling_a_queued_lock_wedges_the_channel pins the defect directly: lock uncontested and never unlock, cancel a second, genuinely queued lock (kill_on_drop kills its tmux subprocess), then bound a third at 800ms and require it does not resolve. Verified a clean lock/unlock/lock cycle resolves immediately by contrast, so the bound is proven to catch the specific cancel-while-queued shape rather than timing out on its own. Measured on 3.2a, 3.7c, and master. --- crates/libtmux/src/server/channels.rs | 22 ++++++++++-- crates/libtmux/tests/commands.rs | 51 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/libtmux/src/server/channels.rs b/crates/libtmux/src/server/channels.rs index 0b8767f5..ac50b0ed 100644 --- a/crates/libtmux/src/server/channels.rs +++ b/crates/libtmux/src/server/channels.rs @@ -37,6 +37,17 @@ impl Server { /// Lock a `wait-for` channel, blocking later lock attempts on it. /// + /// Dropping this future while it is still queued behind another locker + /// leaves the channel permanently locked if that locker's process ends + /// without calling [`Self::unlock_channel`], and every future call here + /// for the same channel blocks forever: `cmd_wait_for_unlock` hands a + /// released lock to the next queued locker with no mechanism to skip one + /// whose client already disconnected. This is a tmux defect + /// (`cmd-wait-for.c`), not something this crate can protect against, and + /// the non-locking [`Self::wait_for_channel`]'s claim that dropping it is + /// safe does not carry over to this call: that form is an idempotent + /// latch check, not a queue. Measured directly on 3.2a, 3.7c, and master. + /// /// # Errors /// /// Returns an error when tmux refuses the channel name. @@ -54,6 +65,10 @@ impl Server { /// Unlock a `wait-for` channel. /// + /// Always call this from whatever locked with [`Self::lock_channel`], + /// including on an error path: a locker that ends without unlocking can + /// wedge the channel for everyone else. See that method's hazard note. + /// /// # Errors /// /// Returns an error when tmux refuses the channel name. @@ -154,9 +169,10 @@ impl Server { Ok(ChannelWait::TimedOut) } Ok(Err(error)) => Err(error), - // Dropping the dispatch kills the tmux client that was waiting. - // The server is unaffected and the channel stays usable, measured - // by killing a waiter outright and signalling it afterwards. + // Dropping the dispatch kills the waiting tmux client; the + // channel stays usable, measured by killing a waiter and + // signalling it after. True only for this idempotent-latch + // form -- `Self::lock_channel`'s queue has no such guarantee. Err(_elapsed) => Ok(ChannelWait::TimedOut), } } diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index ee94fffe..b73d5867 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -406,6 +406,57 @@ async fn wait_for_channels_lock_and_release() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Cancelling a queued `lock_channel` wedges the channel for every later +/// locker: a tmux defect, not something this crate can protect against. See +/// [`Server::lock_channel`]'s hazard note. +/// +/// `cmd_wait_for_unlock` hands a channel to the next queued locker with no +/// mechanism to skip one whose client already disconnected, so killing a +/// locker while it is queued -- not while it holds the lock -- corrupts the +/// channel for everyone behind it, even though nobody ever unlocked it +/// explicitly. Pinned with a tight bound so this fails loudly, not +/// silently, if a tmux release ever fixes the defect. +#[tokio::test] +async fn real_tmux_compat_cancelling_a_queued_lock_wedges_the_channel() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + + // Uncontested: takes the lock and returns at once. Never unlocked. + server + .lock_channel("wedge") + .await + .expect("the first lock is uncontested"); + + // Contested: queues behind the holder above, then is cancelled while + // still queued -- what `kill_on_drop` is for, killing the underlying + // `tmux wait-for -L` subprocess rather than leaving it running. + let cancelled = tokio::time::timeout( + libtmux::test::scaled(Duration::from_secs(2)), + server.lock_channel("wedge"), + ) + .await; + assert!( + cancelled.is_err(), + "the second lock is genuinely contested and queues", + ); + + // A third locker has nothing ahead of it but the first holder -- the + // second never acquired the channel, only queued -- yet tmux hands it + // to the dead second locker anyway and never notices it is gone, so + // this wedges rather than resolving at a sensible bound. + let wedged = tokio::time::timeout( + libtmux::test::scaled(Duration::from_millis(800)), + server.lock_channel("wedge"), + ) + .await; + assert!( + wedged.is_err(), + "cancelling the queued locker corrupts the channel for good, per the defect this pins", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// Waiting blocks until something signals, rather than returning at once. /// /// The latch makes the easy case indistinguishable from a broken one: a wait From e1ef718e948c26dbe276778592a6e1e4d0015ad9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 18:27:19 -0500 Subject: [PATCH 036/117] MCP(fix[manifest]): Put a tool's own text before its safety sentence finish_route prepended the generated, coarse safety sentence before every tool's own description text, so tools sharing a (toolset, process_reach, output_classes) bucket shared the identical opening sentence -- fifteen of them for "Change tmux state; no client-supplied executable input." alone. A caller that truncates a tool list to the first sentence, a common summary strategy, could not tell these tools apart. Swapped the order: a tool's own text now comes first and the safety sentence trails it, matching the idempotency check that already guarded against double-prepending (now ends_with rather than starts_with). every_tools_first_sentence_is_distinct (policy.rs) requires every advertised tool have a unique first sentence, confirmed to fail on the previous ordering with the same seven collision groups the finding reports. TOOLS.md is regenerated (LIBTMUX_RERECORD=1) to match, and the two existing tests asserting the description shape (registered_routes_are_the_capability_manifest, descriptions_annotations_and_manifest_metadata_survive_the_wire) move from starts_with/prefix checks to ends_with/suffix checks on the same opener text. --- crates/tmux-mcp/TOOLS.md | 90 +++++++++++++++---------------- crates/tmux-mcp/src/manifest.rs | 8 ++- crates/tmux-mcp/src/policy.rs | 47 ++++++++++++++-- crates/tmux-mcp/tests/protocol.rs | 21 ++++---- 4 files changed, 107 insertions(+), 59 deletions(-) diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 5a0c6141..04e0dcfa 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -51,7 +51,7 @@ the detached MCP surface to expose modal human-client operations. ## `call_read_tools_batch` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Call a serial batch of at most sixteen enabled inspect tools. One approval for this batch covers every enabled nested name; inner tools do not receive separate client approval. The complete JSON-RPC response line, including its request ID and newline, is capped at 1,000,000 bytes; truncated payloads and omitted bytes are explicit. +Call a serial batch of at most sixteen enabled inspect tools. One approval for this batch covers every enabled nested name; inner tools do not receive separate client approval. The complete JSON-RPC response line, including its request ID and newline, is capped at 1,000,000 bytes; truncated payloads and omitted bytes are explicit. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` @@ -66,7 +66,7 @@ Read pane output; accepts no client-supplied executable input. Returned content ## `capture_pane` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Read a pane's contents. Reads the visible screen by default; set history to reach output that has scrolled off, or give a start and end line. Set last_command to get only what the last command printed, which is usually what you want and is far shorter -- it needs tmux 3.7 and a shell that marks its prompts, and says so when it cannot. +Read a pane's contents. Reads the visible screen by default; set history to reach output that has scrolled off, or give a start and end line. Set last_command to get only what the last command printed, which is usually what you want and is far shorter -- it needs tmux 3.7 and a shell that marks its prompts, and says so when it cannot. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` @@ -81,7 +81,7 @@ Read pane output; accepts no client-supplied executable input. Returned content ## `capture_since` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Read what a pane wrote since the previous call. The first call, with no cursor, starts watching and returns a cursor; later calls pass it back and receive only what is new. Use this to follow a pane over several turns without re-reading the whole screen. The answer says missed=true if the cursor no longer names retained output, including when the pane outran the buffer, its live tail was evicted, or the server restarted. Starting a tail owns a retained observer until the tail is evicted or the server stops. +Read what a pane wrote since the previous call. The first call, with no cursor, starts watching and returns a cursor; later calls pass it back and receive only what is new. Use this to follow a pane over several turns without re-reading the whole screen. The answer says missed=true if the cursor no longer names retained output, including when the pane outran the buffer, its live tail was evicted, or the server restarted. Starting a tail owns a retained observer until the tail is evicted or the server stops. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` @@ -96,7 +96,7 @@ Read pane output; accepts no client-supplied executable input. Returned content ## `clear_pane_scrollback` -Delete tmux state; accepts no command payload. Discard a pane's scrollback, so the next capture_pane returns only what happens next. Use this before running something whose output you want to read cleanly: it is far cheaper than reading past the old output every time. The visible screen is left alone. +Discard a pane's scrollback, so the next capture_pane returns only what happens next. Use this before running something whose output you want to read cleanly: it is far cheaper than reading past the old output every time. The visible screen is left alone. Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -111,7 +111,7 @@ Delete tmux state; accepts no command payload. Discard a pane's scrollback, so t ## `create_session` -Start a pane's configured process; accepts no command payload. Create a new detached tmux session +Create a new detached tmux session Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -126,7 +126,7 @@ Start a pane's configured process; accepts no command payload. Create a new deta ## `create_window` -Start a pane's configured process; accepts no command payload. Create a window running its configured process +Create a window running its configured process Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -141,7 +141,7 @@ Start a pane's configured process; accepts no command payload. Create a window r ## `find_pane_by_position` -Inspect tmux metadata; accepts no client-supplied executable input. Find the pane touching a named window corner +Find the pane touching a named window corner Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -156,7 +156,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. Find the pan ## `get_pane_info` -Inspect tmux metadata; accepts no client-supplied executable input. Return metadata for one pane +Return metadata for one pane Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -171,7 +171,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. Return metad ## `get_server_info` -Inspect tmux metadata; accepts no client-supplied executable input. Report every session with its windows and panes, in one call. Prefer this over calling the three listing tools separately: it costs tmux four commands rather than one per object. +Report every session with its windows and panes, in one call. Prefer this over calling the three listing tools separately: it costs tmux four commands rather than one per object. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -186,7 +186,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. Report every ## `get_session_info` -Inspect tmux metadata; accepts no client-supplied executable input. Return metadata for one session +Return metadata for one session Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -201,7 +201,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. Return metad ## `get_tmux_variables` -Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. Read a bounded set of tmux variables against one pane +Read a bounded set of tmux variables against one pane Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. - Toolset: `inspect` - Process reach: `none` @@ -216,7 +216,7 @@ Read configured tmux commands; accepts no client-supplied executable input. Retu ## `get_window_info` -Inspect tmux metadata; accepts no client-supplied executable input. Return metadata for one window +Return metadata for one window Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -231,7 +231,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. Return metad ## `kill_pane` -Delete tmux state; accepts no command payload. Kill a pane. Killing a window's last pane closes the window +Kill a pane. Killing a window's last pane closes the window Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -246,7 +246,7 @@ Delete tmux state; accepts no command payload. Kill a pane. Killing a window's l ## `kill_session` -Delete tmux state; accepts no command payload. Kill a tmux session and everything in it +Kill a tmux session and everything in it Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -261,7 +261,7 @@ Delete tmux state; accepts no command payload. Kill a tmux session and everythin ## `kill_window` -Delete tmux state; accepts no command payload. Kill a window, closing it in every session that links it +Kill a window, closing it in every session that links it Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -276,7 +276,7 @@ Delete tmux state; accepts no command payload. Kill a window, closing it in ever ## `list_panes` -Inspect tmux metadata; accepts no client-supplied executable input. List every pane on the server +List every pane on the server Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -291,7 +291,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. List every p ## `list_sessions` -Inspect tmux metadata; accepts no client-supplied executable input. List every tmux session on the server +List every tmux session on the server Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -306,7 +306,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. List every t ## `list_windows` -Inspect tmux metadata; accepts no client-supplied executable input. List every window on the server. A window linked into several sessions appears once per link, so an id can repeat with a different session_id. +List every window on the server. A window linked into several sessions appears once per link, so an id can repeat with a different session_id. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -321,7 +321,7 @@ Inspect tmux metadata; accepts no client-supplied executable input. List every w ## `move_window` -Change tmux state; no client-supplied executable input. Move one window to a session and index +Move one window to a session and index Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -336,7 +336,7 @@ Change tmux state; no client-supplied executable input. Move one window to a ses ## `paste_text` -Send input to a pane's program; a shell that receives it runs it with your user's permissions. Put text into a pane through a tmux paste buffer instead of typing it key by key. Use this for anything long or awkward: send_keys types the text, so a shell reading it can react to each character, and a bracketed-paste aware program treats a paste as one block. Optional Enter is appended to that same block. Empty text without Enter is a guarded buffer-free no-op. Paste targets only the named pane, even when synchronized input is enabled. A dead, input-disabled, mode-owned, terminal-attended, or inherited-caller target is refused before setup and again immediately before paste. The private buffer is deleted after setup, refusal, and paste outcomes; observations can still race with tmux. +Put text into a pane through a tmux paste buffer instead of typing it key by key. Use this for anything long or awkward: send_keys types the text, so a shell reading it can react to each character, and a bracketed-paste aware program treats a paste as one block. Optional Enter is appended to that same block. Empty text without Enter is a guarded buffer-free no-op. Paste targets only the named pane, even when synchronized input is enabled. A dead, input-disabled, mode-owned, terminal-attended, or inherited-caller target is refused before setup and again immediately before paste. The private buffer is deleted after setup, refusal, and paste outcomes; observations can still race with tmux. Send input to a pane's program; a shell that receives it runs it with your user's permissions. - Toolset: `execute` - Process reach: `pane-input` @@ -351,7 +351,7 @@ Send input to a pane's program; a shell that receives it runs it with your user' ## `rename_session` -Change tmux state; no client-supplied executable input. Rename one session +Rename one session Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -366,7 +366,7 @@ Change tmux state; no client-supplied executable input. Rename one session ## `rename_window` -Change tmux state; no client-supplied executable input. Rename one window +Rename one window Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -381,7 +381,7 @@ Change tmux state; no client-supplied executable input. Rename one window ## `resize_pane` -Change tmux state; no client-supplied executable input. Move one edge of a pane by a number of rows or columns +Move one edge of a pane by a number of rows or columns Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -396,7 +396,7 @@ Change tmux state; no client-supplied executable input. Move one edge of a pane ## `resize_window` -Change tmux state; no client-supplied executable input. Resize one window to exact cell dimensions +Resize one window to exact cell dimensions Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -411,7 +411,7 @@ Change tmux state; no client-supplied executable input. Resize one window to exa ## `respawn_pane` -Start a pane's configured process; accepts no command payload. Restart a pane's configured process with no command payload +Restart a pane's configured process with no command payload Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -426,7 +426,7 @@ Start a pane's configured process; accepts no command payload. Restart a pane's ## `run_shell_command` -Run a shell command in a pane with your user's permissions. Run a shell command in a pane, wait for it to finish, and report its exit status with everything it wrote. This is the tool for "run this and tell me if it worked". Output is read from the pane's live stream, so nothing is missed and the shell prompt is not included. The command runs in a subshell, so cd and export do not persist and invalid syntax completes with a nonzero status. Valid inherited Bash and zsh ERR and DEBUG traps remain visible to the command while parent-shell traps and options remain unchanged. It requires one configured input recipient and observes its mode, liveness, input-off state, attended-client state, cohort, inherited-caller relation, known POSIX shell, and resolved route before watcher setup and again before dispatch. A process-wide endpoint-and-pane reservation blocks other MCP pane input until the completion marker or pane closure is proved. The resolved tmux executable and socket path must contain no ASCII terminal-control bytes. The reservation serializes this MCP's input, but tmux observations can still race with dispatch. The pane shell, tmux server, and configuration must be trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops this request while its watcher keeps the reservation until completion is proved. +Run a shell command in a pane, wait for it to finish, and report its exit status with everything it wrote. This is the tool for "run this and tell me if it worked". Output is read from the pane's live stream, so nothing is missed and the shell prompt is not included. The command runs in a subshell, so cd and export do not persist and invalid syntax completes with a nonzero status. Valid inherited Bash and zsh ERR and DEBUG traps remain visible to the command while parent-shell traps and options remain unchanged. It requires one configured input recipient and observes its mode, liveness, input-off state, attended-client state, cohort, inherited-caller relation, known POSIX shell, and resolved route before watcher setup and again before dispatch. A process-wide endpoint-and-pane reservation blocks other MCP pane input until the completion marker or pane closure is proved. The resolved tmux executable and socket path must contain no ASCII terminal-control bytes. The reservation serializes this MCP's input, but tmux observations can still race with dispatch. The pane shell, tmux server, and configuration must be trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops this request while its watcher keeps the reservation until completion is proved. Run a shell command in a pane with your user's permissions. - Toolset: `execute` - Process reach: `pane-command` @@ -441,7 +441,7 @@ Run a shell command in a pane with your user's permissions. Run a shell command ## `search_panes` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Search what panes are displaying with Rust's linear-time regex engine. Accept at most 4,096 pattern bytes; search at most 64 panes, 8,192 lines, and 1 MiB; spend at most 250 ms matching and five seconds capturing. Report the pane and line of every match. Use this to find where something is -- which pane has the failing test, which one printed the error -- instead of capturing panes one at a time. Searches the visible screen by default; set history to include scrollback. +Search what panes are displaying with Rust's linear-time regex engine. Accept at most 4,096 pattern bytes; search at most 64 panes, 8,192 lines, and 1 MiB; spend at most 250 ms matching and five seconds capturing. Report the pane and line of every match. Use this to find where something is -- which pane has the failing test, which one printed the error -- instead of capturing panes one at a time. Searches the visible screen by default; set history to include scrollback. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` @@ -456,7 +456,7 @@ Read pane output; accepts no client-supplied executable input. Returned content ## `select_layout` -Change tmux state; no client-supplied executable input. Rearrange a window's panes into a named layout, or into a layout string tmux gave you earlier. Use even-horizontal, even-vertical, main-horizontal, main-vertical or tiled. +Rearrange a window's panes into a named layout, or into a layout string tmux gave you earlier. Use even-horizontal, even-vertical, main-horizontal, main-vertical or tiled. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -471,7 +471,7 @@ Change tmux state; no client-supplied executable input. Rearrange a window's pan ## `select_pane` -Change tmux state; no client-supplied executable input. Select a pane, making it its window's active pane. Give a direction to move relative to it instead: up, down, left, and right follow the layout, last returns to the previously active pane, and next and previous step through the window in order. +Select a pane, making it its window's active pane. Give a direction to move relative to it instead: up, down, left, and right follow the layout, last returns to the previously active pane, and next and previous step through the window in order. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -486,7 +486,7 @@ Change tmux state; no client-supplied executable input. Select a pane, making it ## `select_window` -Change tmux state; no client-supplied executable input. Select a window, making it its session's active window. Give a direction to move relative to it instead: next and previous step through the session in index order, and last returns to the previously active window. +Select a window, making it its session's active window. Give a direction to move relative to it instead: next and previous step through the session in index order, and last returns to the previously active window. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -501,7 +501,7 @@ Change tmux state; no client-supplied executable input. Select a window, making ## `send_keys` -Send input to a pane's program; a shell that receives it runs it with your user's permissions. Type text into a pane, press named keys in it, or both. `text` is sent literally, so C-c in it types those three characters. Use `keys` for anything without a character of its own -- C-c to interrupt a running command, Escape, Up, C-d -- which are tmux key names and are interpreted. Text, keys, and optional Enter keep that order in one tmux dispatch. Before input, the configured synchronized-pane cohort is observed; a dead, input-disabled, mode-owned, terminal-attended, or inherited-caller member refuses the whole call. Returned pane IDs describe configured membership, not confirmed delivery. The observation can race with tmux processing the input. +Type text into a pane, press named keys in it, or both. `text` is sent literally, so C-c in it types those three characters. Use `keys` for anything without a character of its own -- C-c to interrupt a running command, Escape, Up, C-d -- which are tmux key names and are interpreted. Text, keys, and optional Enter keep that order in one tmux dispatch. Before input, the configured synchronized-pane cohort is observed; a dead, input-disabled, mode-owned, terminal-attended, or inherited-caller member refuses the whole call. Returned pane IDs describe configured membership, not confirmed delivery. The observation can race with tmux processing the input. Send input to a pane's program; a shell that receives it runs it with your user's permissions. - Toolset: `execute` - Process reach: `pane-input` @@ -516,7 +516,7 @@ Send input to a pane's program; a shell that receives it runs it with your user' ## `send_keys_batch` -Send input to a pane's program; a shell that receives it runs it with your user's permissions. Send an ordered batch of input operations to panes. Each executed row repeats send_keys' effective synchronized-cohort, dead-pane, input-off, pane-mode, attended-client, and inherited-caller preflight, then crosses one tmux dispatch. These observations can race with tmux processing the input. +Send an ordered batch of input operations to panes. Each executed row repeats send_keys' effective synchronized-cohort, dead-pane, input-off, pane-mode, attended-client, and inherited-caller preflight, then crosses one tmux dispatch. These observations can race with tmux processing the input. Send input to a pane's program; a shell that receives it runs it with your user's permissions. - Toolset: `execute` - Process reach: `pane-input` @@ -531,7 +531,7 @@ Send input to a pane's program; a shell that receives it runs it with your user' ## `set_history_limit` -Change tmux state; no client-supplied executable input. Set the scrollback history limit for a session or its global default +Set the scrollback history limit for a session or its global default Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -546,7 +546,7 @@ Change tmux state; no client-supplied executable input. Set the scrollback histo ## `set_mouse_enabled` -Change tmux state; no client-supplied executable input. Set mouse handling for a session or the global session default +Set mouse handling for a session or the global session default Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -561,7 +561,7 @@ Change tmux state; no client-supplied executable input. Set mouse handling for a ## `set_pane_title` -Change tmux state; no client-supplied executable input. Set one pane's title +Set one pane's title Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -576,7 +576,7 @@ Change tmux state; no client-supplied executable input. Set one pane's title ## `set_synchronize_panes` -Change tmux state; no client-supplied executable input. Set the window default for synchronized pane input. Individual pane overrides still determine the effective configured cohort, so enabling this can amplify what one send_keys call reaches. +Set the window default for synchronized pane input. Individual pane overrides still determine the effective configured cohort, so enabling this can amplify what one send_keys call reaches. Change tmux state; no client-supplied executable input. - Toolset: `execute` - Process reach: `none` @@ -591,7 +591,7 @@ Change tmux state; no client-supplied executable input. Set the window default f ## `show_environment` -Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets. Read the environment tmux hands to processes it starts, for the server or for one session. This is not the environment of anything already running: a pane started before a change keeps what it was given. +Read the environment tmux hands to processes it starts, for the server or for one session. This is not the environment of anything already running: a pane started before a change keeps what it was given. Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets. - Toolset: `inspect` - Process reach: `none` @@ -606,7 +606,7 @@ Read the tmux environment; accepts no client-supplied executable input. Returned ## `show_hooks` -Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. List the hooks tmux runs when something happens on the server, such as a pane exiting. This tool does not set hooks. Hooks set through another path remain in their server or session until unset; configuration files persist them across server restarts. Reach for this when tmux does something no tool here asked for. +List the hooks tmux runs when something happens on the server, such as a pane exiting. This tool does not set hooks. Hooks set through another path remain in their server or session until unset; configuration files persist them across server restarts. Reach for this when tmux does something no tool here asked for. Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. - Toolset: `inspect` - Process reach: `none` @@ -621,7 +621,7 @@ Read configured tmux commands; accepts no client-supplied executable input. Retu ## `show_option` -Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. Read a tmux option, such as history-limit or a user option like @theme. Name the scope the option lives in; global-session is what tmux uses when a command names no target. +Read a tmux option, such as history-limit or a user option like @theme. Name the scope the option lives in; global-session is what tmux uses when a command names no target. Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. - Toolset: `inspect` - Process reach: `none` @@ -636,7 +636,7 @@ Read configured tmux commands; accepts no client-supplied executable input. Retu ## `signal_channel` -Change tmux state; no client-supplied executable input. Signal a tmux wait-for channel, releasing every current waiter. With no waiter, one signal is latched; signalling the same channel again clears that latch. +Signal a tmux wait-for channel, releasing every current waiter. With no waiter, one signal is latched; signalling the same channel again clears that latch. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -651,7 +651,7 @@ Change tmux state; no client-supplied executable input. Signal a tmux wait-for c ## `snapshot_pane` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Read pane content with cursor position, mode state, and scroll position in one reply. The state query and capture are separate, so the result is not atomic. Prefer this over capture_pane when you need to reason about where the pane is rather than only what it says -- a cursor at column zero on a fresh line is a shell waiting, and a pane in a mode may route keys to tmux instead of the workload. +Read pane content with cursor position, mode state, and scroll position in one reply. The state query and capture are separate, so the result is not atomic. Prefer this over capture_pane when you need to reason about where the pane is rather than only what it says -- a cursor at column zero on a fresh line is a shell waiting, and a pane in a mode may route keys to tmux instead of the workload. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` @@ -666,7 +666,7 @@ Read pane output; accepts no client-supplied executable input. Returned content ## `split_window` -Start a pane's configured process; accepts no command payload. Split a window and start the configured process with no command payload +Split a window and start the configured process with no command payload Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -681,7 +681,7 @@ Start a pane's configured process; accepts no command payload. Split a window an ## `swap_pane` -Change tmux state; no client-supplied executable input. Swap the positions of two panes +Swap the positions of two panes Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -696,7 +696,7 @@ Change tmux state; no client-supplied executable input. Swap the positions of tw ## `wait_for_channel` -Change tmux state; no client-supplied executable input. Block until something signals a tmux wait-for channel. A pending signal is consumed. Pair this with a shell command that ends in `tmux wait-for -S ` to synchronise with work this server did not start. +Block until something signals a tmux wait-for channel. A pending signal is consumed. Pair this with a shell command that ends in `tmux wait-for -S ` to synchronise with work this server did not start. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -711,7 +711,7 @@ Change tmux state; no client-supplied executable input. Block until something si ## `wait_for_text` -Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. +Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/manifest.rs b/crates/tmux-mcp/src/manifest.rs index 13ff2eb6..3ab5f3f0 100644 --- a/crates/tmux-mcp/src/manifest.rs +++ b/crates/tmux-mcp/src/manifest.rs @@ -460,13 +460,17 @@ fn finish_route( ) -> Result { let opener = row.controlled_opener(); let remainder = route.attr.description.as_deref().unwrap_or("").trim(); + // The tool's own text goes first, so a caller that truncates to the + // first sentence can still tell tools apart: the safety sentence, + // shared by every tool in the same (toolset, process_reach, + // output_classes) bucket, trails it instead. route.attr.description = Some( - if remainder.starts_with(opener) { + if remainder.ends_with(opener) { remainder.to_owned() } else if remainder.is_empty() { opener.to_owned() } else { - format!("{opener} {remainder}") + format!("{remainder} {opener}") } .into(), ); diff --git a/crates/tmux-mcp/src/policy.rs b/crates/tmux-mcp/src/policy.rs index b9565be8..cc426990 100644 --- a/crates/tmux-mcp/src/policy.rs +++ b/crates/tmux-mcp/src/policy.rs @@ -547,12 +547,53 @@ mod tests { assert_eq!(names, reported); assert!(listed.iter().all(|tool| { let description = tool.description.as_deref().expect("description"); - resolved.report.tools.iter().any(|row| { - row.name == tool.name && description.starts_with(row.controlled_opener()) - }) + resolved + .report + .tools + .iter() + .any(|row| row.name == tool.name && description.ends_with(row.controlled_opener())) })); } + /// A caller that reads only up to a tool's first sentence -- a common + /// truncation or summary strategy -- must still be able to tell tools + /// apart. + /// + /// `finish_route` used to prepend the coarse, capability-keyed safety + /// sentence before a tool's own description; several tools sharing a + /// `(toolset, process_reach, output_classes)` bucket then shared the + /// byte-identical opener. + #[test] + fn every_tools_first_sentence_is_distinct() { + let selection = + Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface"); + let resolved = crate::manifest::resolve(crate::tools::router(), &selection) + .expect("complete manifest"); + + let mut by_first_sentence: std::collections::HashMap<&str, Vec<&str>> = + std::collections::HashMap::new(); + for tool in &resolved.report.tools { + let first_sentence = tool + .description + .split(". ") + .next() + .unwrap_or(&tool.description); + by_first_sentence + .entry(first_sentence) + .or_default() + .push(tool.name.as_str()); + } + let collisions: Vec<_> = by_first_sentence + .into_iter() + .filter(|(_, names)| names.len() > 1) + .collect(); + assert!( + collisions.is_empty(), + "tools sharing a first sentence, indistinguishable by a caller that reads only that \ + far: {collisions:?}", + ); + } + #[test] fn configured_value_reads_disclose_configured_command_output() { let selection = Selection::parse(Some("inspect"), None, None).expect("selection"); diff --git a/crates/tmux-mcp/tests/protocol.rs b/crates/tmux-mcp/tests/protocol.rs index 2f8e2f93..dd732152 100644 --- a/crates/tmux-mcp/tests/protocol.rs +++ b/crates/tmux-mcp/tests/protocol.rs @@ -131,16 +131,19 @@ async fn descriptions_annotations_and_manifest_metadata_survive_the_wire() { for tool in listed { let description = tool.description.expect("controlled description"); + // The safety sentence trails the tool's own text, so a + // caller that truncates to the first sentence still sees something + // that names what the tool does. assert!( - description.starts_with("Inspect tmux metadata;") - || description.starts_with("Read pane output;") - || description.starts_with("Read the tmux environment;") - || description.starts_with("Read configured tmux commands;") - || description.starts_with("Change tmux state;") - || description.starts_with("Start a pane's configured process;") - || description.starts_with("Send input to a pane's program;") - || description.starts_with("Run a shell command in a pane") - || description.starts_with("Delete tmux state;"), + description.ends_with("Inspect tmux metadata; accepts no client-supplied executable input.") + || description.ends_with("Returned content may be sensitive or untrusted.") + || description.ends_with("Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets.") + || description.ends_with("Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration.") + || description.ends_with("Change tmux state; no client-supplied executable input.") + || description.ends_with("Start a pane's configured process; accepts no command payload.") + || description.ends_with("Send input to a pane's program; a shell that receives it runs it with your user's permissions.") + || description.ends_with("Run a shell command in a pane with your user's permissions.") + || description.ends_with("Delete tmux state; accepts no command payload."), "{}: {description}", tool.name, ); From e6fffda9fcf125f52af7ac66f45a1fbb5b143a8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 18:35:39 -0500 Subject: [PATCH 037/117] Docs(fix[examples]): Stop counting the shipped examples Three sources disagreed on how many example programs ship: README.md said "six", Cargo.toml declared seven [[example]] blocks, and examples/ held eight .rs files. inspect.rs predates or postdates whichever count was last updated and was never given its own block, so it ran fine (cargo auto-discovers it) while staying invisible to anyone reading the manifest for what the crate ships. Added inspect's [[example]] block (no required-features, matching what it actually needs) and removed the count from README.md rather than fixing it to a fourth number that will just as quietly go stale. every_example_file_has_its_own_cargo_toml_block (new test file, examples_manifest.rs) parses Cargo.toml's declared block names and compares them against examples/*.rs, in both directions. Confirmed it fails on the pre-fix manifest, naming inspect as the file with no block. --- README.md | 4 +- crates/libtmux/Cargo.toml | 3 ++ crates/libtmux/tests/examples_manifest.rs | 58 +++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 crates/libtmux/tests/examples_manifest.rs diff --git a/README.md b/README.md index 09389e77..5ef2bac6 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ You may be looking for: transport switches, testing - [`tmux-mcp`](crates/tmux-mcp/README.md) — the MCP server, a **separate package**, if you want an agent to drive tmux -- [Examples](crates/libtmux/examples) — six programs that run and clean up - after themselves, from reading a server to watching one over control mode +- [Examples](crates/libtmux/examples) — programs that run and clean up after + themselves, from reading a server to watching one over control mode - [Design notes](crates/libtmux/docs/design.md) — why it is shaped this way - [Parity ledger](crates/libtmux/docs/parity.md) — capability-by-capability against Python libtmux diff --git a/crates/libtmux/Cargo.toml b/crates/libtmux/Cargo.toml index 6c9f4f4b..371ec03d 100644 --- a/crates/libtmux/Cargo.toml +++ b/crates/libtmux/Cargo.toml @@ -31,6 +31,9 @@ include = [ [lib] path = "src/lib.rs" +[[example]] +name = "inspect" + [[example]] name = "scratch" required-features = ["test-support"] diff --git a/crates/libtmux/tests/examples_manifest.rs b/crates/libtmux/tests/examples_manifest.rs new file mode 100644 index 00000000..6daaedb0 --- /dev/null +++ b/crates/libtmux/tests/examples_manifest.rs @@ -0,0 +1,58 @@ +//! Every shipped example has its own `[[example]]` block. +//! +//! `cargo` auto-discovers a file under `examples/` with no explicit block, so +//! one is easy to add without ever declaring it: `inspect.rs` did, and +//! `README.md`'s "six programs" (seven declared blocks, eight files) is what +//! that gap left behind. A file with no block still runs -- this is +//! about the block being the one place a later feature gate belongs, not +//! about anything failing today. + +use std::collections::BTreeSet; +use std::path::Path; + +#[test] +fn every_example_file_has_its_own_cargo_toml_block() { + let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let manifest = std::fs::read_to_string(&manifest_path) + .unwrap_or_else(|error| panic!("{} reads: {error}", manifest_path.display())); + + let mut declared = BTreeSet::new(); + let mut lines = manifest.lines(); + while let Some(line) = lines.next() { + if line.trim() != "[[example]]" { + continue; + } + let name = lines + .by_ref() + .map(str::trim) + .find(|line| !line.is_empty()) + .and_then(|line| line.strip_prefix("name")) + .and_then(|rest| rest.trim_start().strip_prefix('=')) + .map_or_else( + || panic!("an [[example]] block with no name in {manifest_path:?}"), + |rest| rest.trim().trim_matches('"').to_owned(), + ); + declared.insert(name); + } + + let examples_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples"); + let on_disk: BTreeSet = std::fs::read_dir(&examples_dir) + .unwrap_or_else(|error| panic!("{} reads: {error}", examples_dir.display())) + .map(|entry| entry.expect("directory entry reads")) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "rs")) + .map(|entry| { + entry + .path() + .file_stem() + .expect("a .rs file has a stem") + .to_string_lossy() + .into_owned() + }) + .collect(); + + assert_eq!( + on_disk, declared, + "every file under examples/ needs its own [[example]] block in Cargo.toml, even with no \ + required-features, so a later feature gate is never silently missing one", + ); +} From e68726cde43bda957223bc2ab0117fb9f83d49ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 17 Sep 2026 19:16:21 -0500 Subject: [PATCH 038/117] Server(test[create]): Separate undecodable output from none at all The unit test ran both through one loop and asserted a partial effect for each. Output tmux could not decode is one: something was made and the error cannot say what. No output at all is not, and the difference decides whether a caller may retry. --- crates/libtmux/src/internal/listing.rs | 68 ++++++++++++++++++-------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/crates/libtmux/src/internal/listing.rs b/crates/libtmux/src/internal/listing.rs index 1db91273..dfaf688c 100644 --- a/crates/libtmux/src/internal/listing.rs +++ b/crates/libtmux/src/internal/listing.rs @@ -684,26 +684,52 @@ mod tests { #[tokio::test] async fn successful_creation_marks_decode_and_missing_object_failures() { - for stdout in [b"malformed\n".as_slice(), b"".as_slice()] { - let executor = Arc::new(CreationExecutor { - calls: AtomicUsize::new(0), - stdout, - }); - let core = Core::from_executor_for_test(executor.clone()); - - let error = create_session(&core, |_format| Command::new("new-session")) - .await - .expect_err("tmux succeeded but did not describe the created session"); - - assert_eq!(executor.calls.load(Ordering::SeqCst), 2); - assert_eq!(error.kind(), ErrorKind::PartialEffect); - assert!(matches!( - error, - Error::AfterEffect { - operation: "new-session", - .. - } - )); - } + let executor = Arc::new(CreationExecutor { + calls: AtomicUsize::new(0), + stdout: b"malformed\n".as_slice(), + }); + let core = Core::from_executor_for_test(executor.clone()); + + let error = create_session(&core, |_format| Command::new("new-session")) + .await + .expect_err("tmux succeeded but did not describe the created session"); + + assert_eq!(executor.calls.load(Ordering::SeqCst), 2); + assert_eq!(error.kind(), ErrorKind::PartialEffect); + assert!(matches!( + error, + Error::AfterEffect { + operation: "new-session", + .. + } + )); + } + + /// Output tmux could not decode is a partial effect: something was made and + /// this cannot say what. No output at all is not, and the difference is not + /// cosmetic -- a caller told an effect may be outstanding cannot safely + /// retry. A creating command that worked always prints the object it made, + /// so nothing printed means nothing made, which is what every tmux does for + /// a socket path under a directory that does not exist. + #[tokio::test] + async fn creation_that_printed_nothing_is_a_refusal_not_a_partial_effect() { + let executor = Arc::new(CreationExecutor { + calls: AtomicUsize::new(0), + stdout: b"".as_slice(), + }); + let core = Core::from_executor_for_test(executor.clone()); + + let error = create_session(&core, |_format| Command::new("new-session")) + .await + .expect_err("tmux succeeded but described no session"); + + assert_ne!(error.kind(), ErrorKind::PartialEffect); + assert!(matches!( + error, + Error::NoEffect { + command: "new-session", + .. + } + )); } } From 0bba1b1cd6663db2c9ad7299ddadf2bd11a96ff0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 17:52:57 -0500 Subject: [PATCH 039/117] API(fix[names]): Send caller text to tmux as text, not as a format tmux expands a name through its format machinery before it checks it, so `#(command)` in a session name, a window name, a pane title, an option name or a `-c` start directory ran a shell for whoever wrote the text. That is coherent for tmux, whose caller is a person who could run the command anyway. It is not for a library, whose caller passes text from an argument, a request field or a configuration file. `escape_format` existed but was opt-in, and nothing in the crate called it: both sibling crates applied it by hand at 15 sites, which is the shape of an API that has the default the wrong way round. An earlier round already recorded the cost of that shape, when `escape_name`'s narrow name let the start directory beside it go unescaped. Every expanding argument now takes `TmuxArg`, whose every `From` impl escapes, so a new sink is safe by construction rather than by memory. `TmuxArg::format` opts back into expansion for a template the program itself wrote, which is how `#{pane_current_path}` is still reachable. Option names and `plan` operations escape internally: a plan is data a program did not write. Callers passing `&str`, `String`, `OsString` or `Path` are unchanged -- the whole workspace builds untouched across 403 call sites -- so the break is only for a caller that escaped first, and that one now escapes twice. `tests/format_injection.rs` asserts the stored value equals what was asked, one test per sink. The substitution is `#(echo pwned)` rather than a marker file: a `#()` job runs asynchronously, so waiting on its side effect is a race, while the stored value is already final. Shown capable of failing by dropping the escape from the plan renderer, which fails tmux-workspace's own "a name from the file ran a command". --- crates/libtmux/docs/migration.md | 40 ++++++ crates/libtmux/docs/parity.md | 1 + crates/libtmux/docs/public-api.txt | 39 +++-- crates/libtmux/src/internal/options.rs | 21 +-- crates/libtmux/src/lib.rs | 39 +++-- crates/libtmux/src/pane.rs | 6 +- crates/libtmux/src/plan/ops/panes.rs | 3 +- crates/libtmux/src/plan/ops/sessions.rs | 7 +- crates/libtmux/src/plan/ops/windows.rs | 7 +- crates/libtmux/src/server.rs | 27 ++-- crates/libtmux/src/session.rs | 25 ++-- crates/libtmux/src/target.rs | 85 ++++++++++- crates/libtmux/src/window.rs | 14 +- crates/libtmux/tests/format_injection.rs | 175 +++++++++++++++++++++++ crates/libtmux/tests/hierarchy.rs | 50 +++---- crates/tmux-mcp/src/tools/contract.rs | 12 +- crates/tmux-mcp/src/tools/control.rs | 4 +- crates/tmux-mcp/src/tools/inspect.rs | 13 +- crates/tmux-workspace/src/lib.rs | 18 +-- 19 files changed, 465 insertions(+), 121 deletions(-) create mode 100644 crates/libtmux/tests/format_injection.rs diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index 6e1e7f81..b2e597ec 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -1,5 +1,45 @@ # Migrating from 0.1.0-alpha.11 +## Names, titles and start directories are text + +Every argument tmux expands as a format now takes `TmuxArg`, and every +conversion into it escapes. `&str`, `String`, `OsString`, `Path` and +`&TmuxText` all convert, so ordinary calls are unchanged: + +```no_run +# async fn names(server: &libtmux::Server) -> Result<(), libtmux::Error> { +// Unchanged, and now stored as written rather than expanded. +server.new_session("release#1").await?; +# Ok(()) +# } +``` + +Two changes to make: + +- Drop any `escape_format` you applied before calling one of these. The sink + escapes now, so passing pre-escaped text stores the escape characters. +- Where you meant a format, say so with `TmuxArg::format`. This is the one way + to reach `#{pane_current_path}` in a start directory, and it belongs only + around a template the program itself wrote. + +```no_run +# async fn split(pane: &libtmux::Pane) -> Result<(), libtmux::Error> { +use libtmux::{SplitDirection, SplitOptions, TmuxArg}; + +pane.split( + SplitOptions::new(SplitDirection::Below) + .start_directory(TmuxArg::format("#{pane_current_path}")), +) +.await?; +# Ok(()) +# } +``` + +The sinks: `NewSessionOptions::{new, window_name, start_directory}`, +`NewWindowOptions::{new, start_directory}`, `SplitOptions::start_directory`, +`Session::rename`, `Window::rename`, `Pane::set_title`. Option names and the +`plan` operations escape internally and need no change at the call site. + ## Control events `ControlEvents` yields `Result`. `ControlEvents::next_event` diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 37356d1b..b1abc1f2 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -711,6 +711,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | Python behavior | Intentional Rust behavior | Delivery slice | Status | | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------- | +| A name, a title, an option name, and a `-c` start directory reach tmux as formats, so `#(command)` in any of them runs a shell for whoever wrote the text. | Every such argument takes `TmuxArg`, whose `From` impls escape, so text a program did not write arrives as itself; `TmuxArg::format` opts into expansion for a template the program wrote. Option names and `plan` operations escape internally. Covered by [`tests/format_injection.rs`](../tests/format_injection.rs), one test per sink, and by the compatibility test in [`tests/hierarchy.rs`](../tests/hierarchy.rs) that proves tmux still expands when asked. | Foundation | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | | An unset option and an unknown option are one failure. | `Server::typed_option` reports `None` for unset built-ins and absent user options; the `@` prefix determines which rule applies. | Options, hooks, and advanced command families | `implemented` | | Creating an object returns a handle that must be looked up again to be useful. | `Server::new_session`, `Session::new_window`, and `Pane::split` hydrate handles from each creating command's `-P -F` output in one round trip. | Object mutations and interactions | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index e5653449..04378b48 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -375,18 +375,18 @@ function libtmux::Layout::minimum_release: const fn(self) -> libtmux::ReleaseVer function libtmux::ListingDecodeError::field_name: const fn(&self) -> Option<&'static str> function libtmux::ListingDecodeError::row: const fn(&self) -> Option function libtmux::NewSessionOptions::command: fn(self, command: impl Into) -> Self -function libtmux::NewSessionOptions::new: fn(name: impl Into) -> Self +function libtmux::NewSessionOptions::new: fn(name: impl Into) -> Self function libtmux::NewSessionOptions::size: fn(self, width: u32, height: u32) -> Self -function libtmux::NewSessionOptions::start_directory: fn(self, directory: impl Into) -> Self -function libtmux::NewSessionOptions::window_name: fn(self, name: impl Into) -> Self +function libtmux::NewSessionOptions::start_directory: fn(self, directory: impl Into) -> Self +function libtmux::NewSessionOptions::window_name: fn(self, name: impl Into) -> Self function libtmux::NewWindowOptions::command: fn(self, command: impl Into) -> Self function libtmux::NewWindowOptions::environment: fn(self, name: impl Into, value: impl Into) -> Self function libtmux::NewWindowOptions::index: const fn(self, index: i32) -> Self -function libtmux::NewWindowOptions::new: fn(name: impl Into) -> Self +function libtmux::NewWindowOptions::new: fn(name: impl Into) -> Self function libtmux::NewWindowOptions::placement: const fn(self, placement: libtmux::WindowPlacement) -> Self function libtmux::NewWindowOptions::replace_existing: const fn(self) -> Self function libtmux::NewWindowOptions::select: const fn(self) -> Self -function libtmux::NewWindowOptions::start_directory: fn(self, directory: impl Into) -> Self +function libtmux::NewWindowOptions::start_directory: fn(self, directory: impl Into) -> Self function libtmux::NewWindowOptions::unnamed: const fn() -> Self function libtmux::OptionSchema::accepts: fn(&self, scope: libtmux::OptionScope) -> bool function libtmux::OptionSchema::kind: const fn(&self) -> libtmux::OptionKind @@ -448,7 +448,7 @@ function libtmux::Pane::send_prefix: async fn(&self) -> Result<(), libtmux::Erro function libtmux::Pane::session_id: const fn(&self) -> &libtmux::SessionId function libtmux::Pane::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::set_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> -function libtmux::Pane::set_title: async fn(&mut self, title: impl Into) -> Result<&mut Self, libtmux::Error> +function libtmux::Pane::set_title: async fn(&mut self, title: impl Into) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::split: async fn(&self, options: impl Into) -> Result function libtmux::Pane::stream_output: async fn(&self) -> Result function libtmux::Pane::stream_output_with_limits: async fn(&self, limits: libtmux::ControlLimits) -> Result @@ -615,7 +615,7 @@ function libtmux::Session::path: fn(&self) -> &libtmux::TmuxText function libtmux::Session::previous_window: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::refresh: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Session::refreshed: async fn(&self) -> Result -function libtmux::Session::rename: async fn(&mut self, name: impl Into) -> Result<&mut Self, libtmux::Error> +function libtmux::Session::rename: async fn(&mut self, name: impl Into) -> Result<&mut Self, libtmux::Error> function libtmux::Session::search_windows: async fn>(&self, matcher: M) -> Result, libtmux::Error> function libtmux::Session::search_windows_or_empty: async fn>(&self, matcher: M) -> Vec function libtmux::Session::set_environment: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> @@ -648,8 +648,11 @@ function libtmux::SplitOptions::full: const fn(self) -> Self function libtmux::SplitOptions::new: const fn(direction: libtmux::SplitDirection) -> Self function libtmux::SplitOptions::select: const fn(self) -> Self function libtmux::SplitOptions::size: const fn(self, size: libtmux::PaneSize) -> Self -function libtmux::SplitOptions::start_directory: fn(self, directory: impl Into) -> Self +function libtmux::SplitOptions::start_directory: fn(self, directory: impl Into) -> Self function libtmux::SplitOptions::zoom: const fn(self) -> Self +function libtmux::TmuxArg::as_os_str: fn(&self) -> &OsStr +function libtmux::TmuxArg::format: fn(template: impl Into) -> Self +function libtmux::TmuxArg::literal: fn(text: impl AsRef) -> Self function libtmux::TmuxText::as_bytes: fn(&self) -> &[u8] function libtmux::TmuxText::as_flag: fn(&self) -> Option function libtmux::TmuxText::as_str: fn(&self) -> Result<&str, std::str::Utf8Error> @@ -700,7 +703,7 @@ function libtmux::Window::panes_or_empty: async fn(&self) -> Vec function libtmux::Window::previous_layout: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Window::refresh: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Window::refreshed: async fn(&self) -> Result -function libtmux::Window::rename: async fn(&mut self, name: impl Into) -> Result<&mut Self, libtmux::Error> +function libtmux::Window::rename: async fn(&mut self, name: impl Into) -> Result<&mut Self, libtmux::Error> function libtmux::Window::resize: async fn(&mut self, width: u32, height: u32) -> Result<&mut Self, libtmux::Error> function libtmux::Window::resize_by: async fn(&mut self, direction: libtmux::ResizeDirection, cells: u32) -> Result<&mut Self, libtmux::Error> function libtmux::Window::respawn: async fn(&mut self, command: Option>, kill: bool) -> Result<&mut Self, libtmux::Error> @@ -1035,6 +1038,7 @@ impl Clone for libtmux::SessionTarget impl Clone for libtmux::SessionTree impl Clone for libtmux::SplitDirection impl Clone for libtmux::SplitOptions +impl Clone for libtmux::TmuxArg impl Clone for libtmux::TmuxText impl Clone for libtmux::TmuxVersion impl Clone for libtmux::Window @@ -1207,6 +1211,7 @@ impl Debug for libtmux::SessionTree impl Debug for libtmux::SessionTreeFields impl Debug for libtmux::SplitDirection impl Debug for libtmux::SplitOptions +impl Debug for libtmux::TmuxArg impl Debug for libtmux::TmuxText impl Debug for libtmux::TmuxVersion impl Debug for libtmux::Window @@ -1358,6 +1363,7 @@ impl Eq for libtmux::SessionName impl Eq for libtmux::SessionNameError impl Eq for libtmux::SessionTarget impl Eq for libtmux::SplitDirection +impl Eq for libtmux::TmuxArg impl Eq for libtmux::TmuxText impl Eq for libtmux::TmuxVersion impl Eq for libtmux::Window @@ -1404,11 +1410,21 @@ impl Error for libtmux::query::MultipleItemsError impl Error for libtmux::test::RetryTimeout impl Error for libtmux::test::TestServerError impl From<&OsStr> for libtmux::LayoutSpec +impl From<&OsStr> for libtmux::TmuxArg +impl From<&OsString> for libtmux::TmuxArg +impl From<&Path> for libtmux::TmuxArg +impl From<&PathBuf> for libtmux::TmuxArg +impl From<&String> for libtmux::TmuxArg impl From<&libtmux::TmuxText> for libtmux::LayoutSpec +impl From<&libtmux::TmuxText> for libtmux::TmuxArg impl From<&str> for libtmux::LayoutSpec +impl From<&str> for libtmux::TmuxArg impl From<&str> for libtmux::TmuxText impl From for libtmux::LayoutSpec +impl From for libtmux::TmuxArg +impl From for libtmux::TmuxArg impl From for libtmux::LayoutSpec +impl From for libtmux::TmuxArg impl From for libtmux::TmuxText impl From> for libtmux::TmuxText impl From for libtmux::LayoutSpec @@ -1464,6 +1480,7 @@ impl Hash for libtmux::Session impl Hash for libtmux::SessionId impl Hash for libtmux::SessionName impl Hash for libtmux::SessionTarget +impl Hash for libtmux::TmuxArg impl Hash for libtmux::TmuxText impl Hash for libtmux::Window impl Hash for libtmux::WindowId @@ -1495,6 +1512,7 @@ impl Ord for libtmux::ReleaseVersion impl Ord for libtmux::SessionId impl Ord for libtmux::SessionName impl Ord for libtmux::SessionTarget +impl Ord for libtmux::TmuxArg impl Ord for libtmux::TmuxText impl Ord for libtmux::WindowId impl Ord for libtmux::WindowTarget @@ -1551,6 +1569,7 @@ impl PartialEq for libtmux::SessionName impl PartialEq for libtmux::SessionNameError impl PartialEq for libtmux::SessionTarget impl PartialEq for libtmux::SplitDirection +impl PartialEq for libtmux::TmuxArg impl PartialEq for libtmux::TmuxText impl PartialEq for libtmux::TmuxVersion impl PartialEq for libtmux::Window @@ -1597,6 +1616,7 @@ impl PartialOrd for libtmux::ReleaseVersion impl PartialOrd for libtmux::SessionId impl PartialOrd for libtmux::SessionName impl PartialOrd for libtmux::SessionTarget +impl PartialOrd for libtmux::TmuxArg impl PartialOrd for libtmux::TmuxText impl PartialOrd for libtmux::TmuxVersion impl PartialOrd for libtmux::WindowId @@ -1791,6 +1811,7 @@ struct libtmux::SessionTree struct libtmux::SessionTreeFields struct libtmux::SparseValues struct libtmux::SplitOptions +struct libtmux::TmuxArg struct libtmux::TmuxText struct libtmux::TmuxVersion struct libtmux::Window diff --git a/crates/libtmux/src/internal/options.rs b/crates/libtmux/src/internal/options.rs index 5784ab71..67f8320d 100644 --- a/crates/libtmux/src/internal/options.rs +++ b/crates/libtmux/src/internal/options.rs @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use std::ffi::OsString; use std::os::unix::ffi::OsStringExt as _; +use crate::escape_format; use crate::formats::TmuxText; use crate::hooks::IndexedHooks; use crate::hooks::ReplaceMode; @@ -82,7 +83,7 @@ pub(crate) async fn get( .apply(Command::new("show-options")) .arg("-v") .arg("--") - .arg(OsString::from(name)); + .arg(escape_format(name)); let result = core.execute(command).await?; if !result.success() { @@ -163,7 +164,7 @@ pub(crate) async fn set( Some(name), command .arg("--") - .arg(OsString::from(name)) + .arg(escape_format(name)) .sensitive_arg(value.into()), ) .await @@ -181,7 +182,7 @@ pub(crate) async fn unset(core: &Core, scope: Scope<'_>, name: &str) -> Result<( .apply(Command::new("set-option")) .arg("-u") .arg("--") - .arg(OsString::from(name)), + .arg(escape_format(name)), ) .await } @@ -206,10 +207,12 @@ pub(crate) async fn set_hook( ) -> Result<(), Error> { ensure_scope(core, scope, name).await?; + // Every valid hook name is `[a-z-]+`, so escaping is a no-op on one and + // protection if tmux ever expands here as it does for an option name. let slot = if name.contains('[') { - OsString::from(name) + escape_format(name) } else { - OsString::from(format!("{name}[0]")) + escape_format(format!("{name}[0]")) }; run( @@ -237,7 +240,7 @@ pub(crate) async fn unset_hook(core: &Core, scope: Scope<'_>, name: &str) -> Res .apply(Command::new("set-hook")) .arg("-u") .arg("--") - .arg(OsString::from(name)), + .arg(escape_format(name)), ) .await } @@ -450,7 +453,7 @@ async fn slots_of(core: &Core, scope: Scope<'_>, name: &str) -> Result Result<(), libtmux::Error> { +//! use libtmux::{NewSessionOptions, TmuxArg}; +//! +//! // Text is text, whatever is in it. +//! server.new_session("release#1").await?; +//! +//! // Expansion is opt-in, and reads as such. Only for a template the +//! // program itself wrote. +//! server +//! .new_session(NewSessionOptions::new("build").start_directory( +//! TmuxArg::format("#{pane_current_path}"), +//! )) +//! .await?; +//! # Ok(()) +//! # } +//! ``` //! //! Expansion is not the only way the name you asked for is not the name you //! get. tmux releases through 3.6b rewrite `:` and `.` in a session name to @@ -356,7 +373,7 @@ pub use snapshot::PaneProgressState; pub use snapshot::{ClientFields, PaneFields, SessionFields, WindowFields}; pub use target::{ PaneId, PaneTarget, ServerGeneration, ServerIdentity, SessionId, SessionName, SessionNameError, - SessionTarget, WindowId, WindowTarget, escape_format, + SessionTarget, TmuxArg, WindowId, WindowTarget, escape_format, }; pub use version::{ReleaseSuffix, ReleaseVersion, TmuxVersion, since}; pub use window::{ diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 5ee8cb92..b1fc1fa5 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -17,7 +17,7 @@ use crate::snapshot::{PaneFields, PaneInfo}; use crate::target::{PaneId, ServerIdentity, SessionId, WindowId}; use crate::version::TmuxVersion; use crate::window::Window; -use crate::{Command, CommandResult, Error, ObjectKind}; +use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; mod observe; mod settings; @@ -846,7 +846,7 @@ impl Pane { /// # Errors /// /// Returns an error when tmux refuses the title. - pub async fn set_title(&mut self, title: impl Into) -> Result<&mut Self, Error> { + pub async fn set_title(&mut self, title: impl Into) -> Result<&mut Self, Error> { listing::mutate( &self.core, "select-pane", @@ -854,7 +854,7 @@ impl Pane { .arg("-t") .arg(self.id().to_string()) .arg("-T") - .sensitive_arg(title.into()), + .sensitive_arg(title.into().into_os_string()), ) .await?; diff --git a/crates/libtmux/src/plan/ops/panes.rs b/crates/libtmux/src/plan/ops/panes.rs index af28449b..d97b50cd 100644 --- a/crates/libtmux/src/plan/ops/panes.rs +++ b/crates/libtmux/src/plan/ops/panes.rs @@ -4,6 +4,7 @@ //! a window but the object it produces, and everything that follows it in a //! plan, is a pane. +use crate::escape_format; use std::ffi::OsString; use std::fmt; @@ -177,7 +178,7 @@ impl SplitWindow { command = command.arg("-d"); } if let Some(directory) = &self.start_directory { - command = command.arg("-c").arg(directory.clone()); + command = command.arg("-c").arg(escape_format(directory)); } for (name, value) in &self.environment { command = command.arg("-e").sensitive_arg(assignment(name, value)); diff --git a/crates/libtmux/src/plan/ops/sessions.rs b/crates/libtmux/src/plan/ops/sessions.rs index 0e5a47cf..b85312f9 100644 --- a/crates/libtmux/src/plan/ops/sessions.rs +++ b/crates/libtmux/src/plan/ops/sessions.rs @@ -1,5 +1,6 @@ //! Operations that make or address a session. +use crate::escape_format; use std::ffi::OsString; use super::SESSION_FORMAT; @@ -94,12 +95,12 @@ impl NewSession { .arg("-F") .arg(SESSION_FORMAT) .arg("-s") - .arg(self.name.clone()); + .arg(escape_format(&self.name)); if let Some(directory) = &self.start_directory { - command = command.arg("-c").arg(directory.clone()); + command = command.arg("-c").arg(escape_format(directory)); } if let Some(name) = &self.window_name { - command = command.arg("-n").arg(name.clone()); + command = command.arg("-n").arg(escape_format(name)); } command } diff --git a/crates/libtmux/src/plan/ops/windows.rs b/crates/libtmux/src/plan/ops/windows.rs index aba65fe2..2973d1f1 100644 --- a/crates/libtmux/src/plan/ops/windows.rs +++ b/crates/libtmux/src/plan/ops/windows.rs @@ -1,5 +1,6 @@ //! Operations that make or address a window. +use crate::escape_format; use std::ffi::OsString; use std::fmt; @@ -157,10 +158,10 @@ impl NewWindow { command = command.arg("-d"); } if let Some(name) = &self.name { - command = command.arg("-n").arg(name.clone()); + command = command.arg("-n").arg(escape_format(name)); } if let Some(directory) = &self.start_directory { - command = command.arg("-c").arg(directory.clone()); + command = command.arg("-c").arg(escape_format(directory)); } for (name, value) in &self.environment { command = command.arg("-e").sensitive_arg(assignment(name, value)); @@ -271,7 +272,7 @@ impl RenameWindow { .arg("-t") .arg(self.target.token(resolve)?) .arg("--") - .arg(self.name.clone()), + .arg(escape_format(&self.name)), ) } } diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 5b3f2fb7..82a7dc3e 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -19,7 +19,7 @@ use crate::pane::Pane; use crate::session::Session; use crate::{ Command, CommandChain, CommandResult, EngineCapabilities, Error, ReleaseSuffix, ReleaseVersion, - ServerConfigurationErrorKind, ServerGeneration, ServerIdentity, + ServerConfigurationErrorKind, ServerGeneration, ServerIdentity, TmuxArg, }; mod builder; @@ -1961,9 +1961,9 @@ mod tests { #[must_use = "options describe a session but do not create one"] #[derive(Clone)] pub struct NewSessionOptions { - name: OsString, - start_directory: Option, - window_name: Option, + name: TmuxArg, + start_directory: Option, + window_name: Option, command: Option, width: Option, height: Option, @@ -1984,7 +1984,9 @@ impl fmt::Debug for NewSessionOptions { impl NewSessionOptions { /// Describe a session with the given name. - pub fn new(name: impl Into) -> Self { + /// + /// The name is sent literally. [`TmuxArg::format`] opts into expansion. + pub fn new(name: impl Into) -> Self { Self { name: name.into(), start_directory: None, @@ -1997,15 +1999,15 @@ impl NewSessionOptions { /// Set the working directory for the session's first window. /// - /// tmux expands this as a format, so [`crate::escape_format`] belongs - /// around text a program did not write. - pub fn start_directory(mut self, directory: impl Into) -> Self { + /// The directory is sent literally. [`TmuxArg::format`] opts into + /// expansion, which is how `#{pane_current_path}` is asked for. + pub fn start_directory(mut self, directory: impl Into) -> Self { self.start_directory = Some(directory.into()); self } - /// Name the session's first window. - pub fn window_name(mut self, name: impl Into) -> Self { + /// Name the session's first window, literally. + pub fn window_name(mut self, name: impl Into) -> Self { self.window_name = Some(name.into()); self } @@ -2042,12 +2044,12 @@ impl NewSessionOptions { .arg("-F") .arg(print_format) .arg("-s") - .arg(self.name); + .arg(self.name.into_os_string()); if let Some(directory) = self.start_directory { command = command.arg("-c").arg(directory.into_os_string()); } if let Some(name) = self.window_name { - command = command.arg("-n").arg(name); + command = command.arg("-n").arg(name.into_os_string()); } if let (Some(width), Some(height)) = (self.width, self.height) { command = command @@ -2065,6 +2067,7 @@ impl NewSessionOptions { impl> From for NewSessionOptions { fn from(name: T) -> Self { + let name: OsString = name.into(); Self::new(name) } } diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index 31d405f0..268f71fe 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -19,7 +19,7 @@ use crate::snapshot::SessionFields; use crate::snapshot::SessionInfo; use crate::target::{ServerIdentity, SessionId}; use crate::window::Window; -use crate::{Command, CommandResult, Error, ObjectKind}; +use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; /// What a session's environment holds for one name. /// @@ -537,7 +537,7 @@ impl Session { /// tmux expands the name as a format before it checks it, so `#(command)` /// in one runs a shell command. See [the crate documentation][crate#a-name-reaches-tmux-as-a-format] /// before passing text a caller supplied. - pub async fn rename(&mut self, name: impl Into) -> Result<&mut Self, Error> { + pub async fn rename(&mut self, name: impl Into) -> Result<&mut Self, Error> { listing::mutate( &self.core, "rename-session", @@ -545,7 +545,7 @@ impl Session { .arg("-t") .arg(self.id().to_string()) .arg("--") - .arg(name.into()), + .arg(name.into().into_os_string()), ) .await?; @@ -959,8 +959,8 @@ impl FilterSchema for Session { #[must_use = "options describe a window but do not create one"] #[derive(Clone)] pub struct NewWindowOptions { - name: Option, - start_directory: Option, + name: Option, + start_directory: Option, command: Option, index: Option, placement: Option, @@ -1041,8 +1041,10 @@ impl NewWindowOptions { } } - /// Describe a window with the given name. - pub fn new(name: impl Into) -> Self { + /// Describe a window with the given name, sent literally. + /// + /// [`TmuxArg::format`] opts into expansion. + pub fn new(name: impl Into) -> Self { Self { name: Some(name.into()), ..Self::unnamed() @@ -1051,9 +1053,9 @@ impl NewWindowOptions { /// Set the window's working directory. /// - /// tmux expands this as a format, so [`crate::escape_format`] belongs - /// around text a program did not write. - pub fn start_directory(mut self, directory: impl Into) -> Self { + /// The directory is sent literally. [`TmuxArg::format`] opts into + /// expansion, which is how `#{pane_current_path}` is asked for. + pub fn start_directory(mut self, directory: impl Into) -> Self { self.start_directory = Some(directory.into()); self } @@ -1132,7 +1134,7 @@ impl NewWindowOptions { command = command.arg("-k"); } if let Some(name) = self.name { - command = command.arg("-n").arg(name); + command = command.arg("-n").arg(name.into_os_string()); } if let Some(directory) = self.start_directory { command = command.arg("-c").arg(directory.into_os_string()); @@ -1151,6 +1153,7 @@ impl NewWindowOptions { impl> From for NewWindowOptions { fn from(name: T) -> Self { + let name: OsString = name.into(); Self::new(name) } } diff --git a/crates/libtmux/src/target.rs b/crates/libtmux/src/target.rs index 037938f7..3deeb256 100644 --- a/crates/libtmux/src/target.rs +++ b/crates/libtmux/src/target.rs @@ -18,10 +18,9 @@ use crate::error::IdParseError; /// itself -- which also makes a directory whose name really contains `#` /// reachable, where passing it through unescaped does not. /// -/// This is not applied for you, because such text is sometimes meant as a -/// format. Use it for what a program did not write: an argument, a request -/// field, a configuration file. Passing that through unescaped gives whoever -/// wrote it a shell. +/// Every typed argument that tmux expands takes [`TmuxArg`], which applies +/// this on conversion, so a caller reaches for this directly only when +/// building a raw [`crate::Command`] of their own. /// /// # Examples /// @@ -764,3 +763,81 @@ pub enum SessionNameError { separator: char, }, } + +/// Text bound for a tmux argument that tmux expands as a format. +/// +/// tmux runs its format machinery over a session or window name, a pane +/// title, and a `-c` start directory before it uses them, so `#(command)` in +/// caller text runs a shell. Every such argument in this crate takes this +/// type, and every conversion into it escapes: text a program did not write +/// arrives as itself, and asking for expansion is a visible call to +/// [`TmuxArg::format`]. +/// +/// # Examples +/// +/// ``` +/// use libtmux::TmuxArg; +/// +/// // The ordinary path. `From` escapes, so tmux stores what was asked. +/// let literal = TmuxArg::from("release#1"); +/// assert_eq!(literal.as_os_str(), "release##1"); +/// +/// // Expansion is opt-in and reads as such. +/// let expanded = TmuxArg::format("#{pane_current_path}"); +/// assert_eq!(expanded.as_os_str(), "#{pane_current_path}"); +/// ``` +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct TmuxArg(OsString); + +impl TmuxArg { + /// Send `text` literally, escaping what tmux would otherwise expand. + #[must_use] + pub fn literal(text: impl AsRef) -> Self { + Self(escape_format(text)) + } + + /// Send `template` for tmux to expand as a format. + /// + /// Only for a template the program itself wrote. A template built from an + /// argument, a request field, or a configuration file hands whoever wrote + /// it a shell. + #[must_use] + pub fn format(template: impl Into) -> Self { + Self(template.into()) + } + + /// Borrow the bytes as they will reach tmux, escaped or not. + #[must_use] + pub fn as_os_str(&self) -> &OsStr { + &self.0 + } + + pub(crate) fn into_os_string(self) -> OsString { + self.0 + } +} + +macro_rules! tmux_arg_from { + ($($type:ty),* $(,)?) => { + $( + impl From<$type> for TmuxArg { + fn from(text: $type) -> Self { + Self::literal(text) + } + } + )* + }; +} + +// One impl per source type rather than a blanket `impl>`, +// which would collide with the reflexive `From` in core and take +// `TmuxArg::format`'s result back through the escaper. +tmux_arg_from!( + &str, String, &String, OsString, &OsString, &OsStr, PathBuf, &PathBuf, &Path +); + +impl From<&crate::formats::TmuxText> for TmuxArg { + fn from(text: &crate::formats::TmuxText) -> Self { + Self::literal(OsStr::from_bytes(text.as_bytes())) + } +} diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 33c63dd8..50b82869 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -17,7 +17,7 @@ use crate::snapshot::WindowProjection; #[cfg(feature = "query")] use crate::snapshot::{WindowFields, WindowInfo}; use crate::target::{ServerIdentity, SessionId, WindowId}; -use crate::{Command, CommandResult, Error, ObjectKind}; +use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; mod navigation; mod settings; @@ -374,7 +374,7 @@ impl Window { /// tmux expands the name as a format before it checks it, so `#(command)` /// in one runs a shell command. See [the crate documentation][crate#a-name-reaches-tmux-as-a-format] /// before passing text a caller supplied. - pub async fn rename(&mut self, name: impl Into) -> Result<&mut Self, Error> { + pub async fn rename(&mut self, name: impl Into) -> Result<&mut Self, Error> { listing::mutate( &self.core, "rename-window", @@ -382,7 +382,7 @@ impl Window { .arg("-t") .arg(self.id().to_string()) .arg("--") - .arg(name.into()), + .arg(name.into().into_os_string()), ) .await?; @@ -1747,7 +1747,7 @@ impl ResizeDirection { #[derive(Clone)] pub struct SplitOptions { direction: SplitDirection, - start_directory: Option, + start_directory: Option, command: Option, size: Option, environment: Vec<(OsString, OsString)>, @@ -1789,9 +1789,9 @@ impl SplitOptions { /// Set the new pane's working directory. /// - /// tmux expands this as a format, so [`crate::escape_format`] belongs - /// around text a program did not write. - pub fn start_directory(mut self, directory: impl Into) -> Self { + /// The directory is sent literally. [`TmuxArg::format`] opts into + /// expansion, which is how `#{pane_current_path}` is asked for. + pub fn start_directory(mut self, directory: impl Into) -> Self { self.start_directory = Some(directory.into()); self } diff --git a/crates/libtmux/tests/format_injection.rs b/crates/libtmux/tests/format_injection.rs new file mode 100644 index 00000000..663a44ff --- /dev/null +++ b/crates/libtmux/tests/format_injection.rs @@ -0,0 +1,175 @@ +//! Caller text must reach tmux literally at every sink tmux expands. +//! +//! tmux runs its format machinery over a name, a title and a start directory +//! before it uses them, so `#(command)` in caller text runs a shell. Each test +//! below asks for a value holding one and asserts tmux stored what was asked. +//! The substitution runs `echo`, so an unescaped pass is visible in the stored +//! value rather than only in a side effect: tmux runs `#()` asynchronously, so +//! a marker file checked straight after the call reads absent whether or not +//! the expansion happened, while the stored value is already final. + +#![cfg(feature = "test-support")] +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use libtmux::test::TestServer; +use libtmux::{NewSessionOptions, NewWindowOptions, SplitDirection, SplitOptions}; +use tempfile::TempDir; + +/// Holds a `#()` substitution, and is a legal tmux name. +const HOSTILE: &str = "evil#(echo pwned)"; + +#[tokio::test] +async fn a_session_name_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + + let session = guard + .server() + .new_session(HOSTILE) + .await + .expect("the session is created"); + + assert_eq!(session.name().to_string_lossy(), HOSTILE); + + guard.shutdown().await.expect("the fixture shuts down"); +} + +#[tokio::test] +async fn a_renamed_session_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + + let mut session = guard + .server() + .new_session("work") + .await + .expect("the session is created"); + session.rename(HOSTILE).await.expect("the session renames"); + + assert_eq!(session.name().to_string_lossy(), HOSTILE); + + guard.shutdown().await.expect("the fixture shuts down"); +} + +#[tokio::test] +async fn a_window_name_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("work") + .await + .expect("the session is created"); + + let created = session + .new_window(HOSTILE) + .await + .expect("the window is created"); + assert_eq!(created.name().to_string_lossy(), HOSTILE); + + let mut renamed = session + .new_window("second") + .await + .expect("the window is created"); + renamed.rename(HOSTILE).await.expect("the window renames"); + assert_eq!(renamed.name().to_string_lossy(), HOSTILE); + + guard.shutdown().await.expect("the fixture shuts down"); +} + +#[tokio::test] +async fn a_first_window_name_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + + let session = guard + .server() + .new_session(NewSessionOptions::new("work").window_name(HOSTILE)) + .await + .expect("the session is created"); + + let window = session + .active_window() + .await + .expect("the window is read") + .expect("a session has a window"); + assert_eq!(window.name().to_string_lossy(), HOSTILE); + + guard.shutdown().await.expect("the fixture shuts down"); +} + +#[tokio::test] +async fn a_pane_title_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("work") + .await + .expect("the session is created"); + let mut pane = session.panes().await.expect("panes list").remove(0); + + pane.set_title(HOSTILE).await.expect("the title is set"); + + assert_eq!(pane.title().to_string_lossy(), HOSTILE); + + guard.shutdown().await.expect("the fixture shuts down"); +} + +#[tokio::test] +async fn a_start_directory_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("work") + .await + .expect("the session is created"); + + // A directory that really holds a `#`, which is also what an unescaped + // pass would mangle. tmux reports the path it started in. + let scratch = TempDir::new().expect("a scratch directory"); + let directory = scratch.path().join("dir#(echo pwned)"); + std::fs::create_dir_all(&directory).expect("the directory is created"); + + let window = session + .new_window(NewWindowOptions::new("cwd").start_directory(&directory)) + .await + .expect("the window is created"); + let pane = window + .active_pane() + .await + .expect("the pane is read") + .expect("a window has a pane"); + + assert_eq!( + pane.current_path().map(|path| path.to_string_lossy()), + Some(directory.to_string_lossy()), + ); + + guard.shutdown().await.expect("the fixture shuts down"); +} + +#[tokio::test] +async fn a_split_start_directory_reaches_tmux_literally() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("work") + .await + .expect("the session is created"); + let pane = session.panes().await.expect("panes list").remove(0); + + let scratch = TempDir::new().expect("a scratch directory"); + let directory = scratch.path().join("split#(echo pwned)"); + std::fs::create_dir_all(&directory).expect("the directory is created"); + + let split = pane + .split(SplitOptions::new(SplitDirection::Below).start_directory(&directory)) + .await + .expect("the pane splits") + .refreshed() + .await + .expect("the pane is read back"); + + assert_eq!( + split.current_path().map(|path| path.to_string_lossy()), + Some(directory.to_string_lossy()), + ); + + guard.shutdown().await.expect("the fixture shuts down"); +} diff --git a/crates/libtmux/tests/hierarchy.rs b/crates/libtmux/tests/hierarchy.rs index ab1e81dc..72f55ff2 100644 --- a/crates/libtmux/tests/hierarchy.rs +++ b/crates/libtmux/tests/hierarchy.rs @@ -7,7 +7,7 @@ use libtmux::test::TestServer; use libtmux::{Client, Command, ErrorKind, NewSessionOptions, NewWindowOptions, Pane, Server}; -use libtmux::{ServerGoneKind, Session}; +use libtmux::{ServerGoneKind, Session, TmuxArg}; use libtmux::{SplitDirection, SplitOptions, Window}; use static_assertions::assert_impl_all; @@ -854,30 +854,44 @@ async fn real_tmux_compat_an_empty_field_does_not_fail_the_listing() { guard.shutdown().await.expect("tmux fixture shuts down"); } -/// A name reaches tmux as a format rather than as text. +/// A name reaches tmux as text, and as a format only when asked. /// /// tmux expands `-s` through `format_single` before it validates the result /// (`cmd-new-session.c`), which is what makes `#(command)` in a name run a /// shell command: `clean_name` neutralises `#(` only for a name arriving from /// a pane's own output, never for one a command supplied. That is coherent for /// tmux, whose caller is a person who could run the command anyway, and it is -/// a trust boundary this crate's callers have to be told about, because their -/// names come from arguments and request fields. +/// the reason this crate escapes on the way in: its callers' names come from +/// arguments and request fields. /// -/// The expansion is what this asserts, because it is what can be observed -/// without a race. A `#()` job runs asynchronously and nothing bounds how long -/// it takes, so waiting for the file one writes is a guess with a number on -/// it: this test failed exactly that way at load average 21. The execution -/// follows from the expansion and is documented rather than gated. +/// Both directions are asserted here because each is evidence for the other: +/// that tmux still expands is what makes the escaping load-bearing rather than +/// decorative. Expansion is observed through the stored value, never through a +/// `#()` job's side effect -- that job is asynchronous and unbounded, and +/// waiting on the file it writes failed exactly that way at load average 21. #[tokio::test] -async fn real_tmux_compat_a_name_reaches_tmux_as_a_format() { +async fn real_tmux_compat_a_name_reaches_tmux_as_text() { let guard = TestServer::builder().start().await.expect("tmux starts"); let server = guard.server(); + // The ordinary path: what the caller passed is what tmux stored. + let literal = server + .new_session("#{version}") + .await + .expect("tmux accepts the escaped name"); + assert_eq!( + literal.name().as_bytes(), + b"#{version}", + "a name is escaped on the way out, so tmux stores the text it was given", + ); + // `#{version}` rather than anything about the session: tmux expands the // name before the session it would describe exists, which is why a // templated name so often expands to nothing. - let Ok(expanded) = server.new_session("#{version}").await else { + let Ok(expanded) = server + .new_session(NewSessionOptions::new(TmuxArg::format("#{version}"))) + .await + else { // A release that refuses the name is protecting the caller from all // of this, and there is nothing left to observe. guard.shutdown().await.expect("tmux fixture shuts down"); @@ -886,25 +900,13 @@ async fn real_tmux_compat_a_name_reaches_tmux_as_a_format() { assert_ne!( expanded.name().as_bytes(), b"#{version}", - "tmux expanded the format rather than storing the text it was given", + "an opted-in format is expanded by tmux rather than stored as text", ); assert!( !expanded.name().as_bytes().is_empty(), "the expansion had a value to put there", ); - // The escaped form is the same text with the expansion turned off, so the - // pair is what proves the first one was expanded rather than mangled. - let literal = server - .new_session("##{version}") - .await - .expect("tmux accepts the escaped name"); - assert_eq!( - literal.name().as_bytes(), - b"#{version}", - "an escaped `##` reaches tmux as a literal `#`", - ); - guard.shutdown().await.expect("tmux fixture shuts down"); } diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index aba26437..da1422e1 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -589,7 +589,7 @@ impl TmuxTools { ) -> Result, ToolError> { let mut session = self.find_session(&session).await?; session - .rename(libtmux::escape_format(name)) + .rename(name) .await .map_err(|error| tmux_error(&error))?; let foreign_attached = self.foreign_attached_sessions().await; @@ -621,7 +621,7 @@ impl TmuxTools { ) -> Result, ToolError> { let mut window = self.find_window(&window).await?; window - .rename(libtmux::escape_format(name)) + .rename(name) .await .map_err(|error| tmux_error(&error))?; Ok(Json(Self::one_window(&window))) @@ -716,7 +716,7 @@ impl TmuxTools { Parameters(PaneTitleArgs { pane, title }): Parameters, ) -> Result, ToolError> { let mut pane = self.find_pane(&pane).await?; - pane.set_title(libtmux::escape_format(title)) + pane.set_title(title) .await .map_err(|error| tmux_error(&error))?; let socket = self.socket(); @@ -807,12 +807,12 @@ impl TmuxTools { }): Parameters, ) -> Result, ToolError> { let session = self.find_session(&session).await?; - let mut options = name.map(libtmux::escape_format).map_or_else( + let mut options = name.map(libtmux::TmuxArg::from).map_or_else( libtmux::NewWindowOptions::unnamed, libtmux::NewWindowOptions::new, ); if let Some(directory) = start_directory { - options = options.start_directory(libtmux::escape_format(directory)); + options = options.start_directory(directory); } let window = session .new_window(options) @@ -865,7 +865,7 @@ impl TmuxTools { options = options.size(PaneSize::Percent(percent)); } if let Some(directory) = start_directory { - options = options.start_directory(libtmux::escape_format(directory)); + options = options.start_directory(directory); } let created = self .find_pane(&pane) diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index f258174d..cf3df4ec 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -235,9 +235,9 @@ impl TmuxTools { start_directory, }): Parameters, ) -> Result, ToolError> { - let mut options = NewSessionOptions::new(libtmux::escape_format(name)); + let mut options = NewSessionOptions::new(name); if let Some(directory) = start_directory { - options = options.start_directory(libtmux::escape_format(directory)); + options = options.start_directory(directory); } let session = self diff --git a/crates/tmux-mcp/src/tools/inspect.rs b/crates/tmux-mcp/src/tools/inspect.rs index af48073e..6de5b414 100644 --- a/crates/tmux-mcp/src/tools/inspect.rs +++ b/crates/tmux-mcp/src/tools/inspect.rs @@ -487,14 +487,13 @@ impl TmuxTools { let scope = self .option_scope(scope.as_deref(), target.as_deref()) .await?; - let literal_name = libtmux::escape_format(&name).to_string_lossy().into_owned(); let value = match scope { - OptionScope::Server => self.server.get_option(&literal_name).await, - OptionScope::GlobalSession => self.server.get_global_option(&literal_name).await, - OptionScope::GlobalWindow => self.server.get_global_window_option(&literal_name).await, - OptionScope::Session(session) => session.get_option(&literal_name).await, - OptionScope::Window(window) => window.get_option(&literal_name).await, - OptionScope::Pane(pane) => pane.get_option(&literal_name).await, + OptionScope::Server => self.server.get_option(&name).await, + OptionScope::GlobalSession => self.server.get_global_option(&name).await, + OptionScope::GlobalWindow => self.server.get_global_window_option(&name).await, + OptionScope::Session(session) => session.get_option(&name).await, + OptionScope::Window(window) => window.get_option(&name).await, + OptionScope::Pane(pane) => pane.get_option(&name).await, } .map_err(|e| tmux_error(&e))?; diff --git a/crates/tmux-workspace/src/lib.rs b/crates/tmux-workspace/src/lib.rs index 3ea8cf47..3d0aaf24 100644 --- a/crates/tmux-workspace/src/lib.rs +++ b/crates/tmux-workspace/src/lib.rs @@ -42,7 +42,7 @@ use libtmux::plan::{ KillWindow, NewSession, NewWindow, PaneSlot, Plan, Planner, SelectLayout, SelectPane, SelectWindow, SendKeys, SessionSlot, SetEnvironment, SetOption, Slot, SplitWindow, }; -use libtmux::{Server, Session, SessionId, escape_format}; +use libtmux::{Server, Session, SessionId}; /// A failure while building a workspace. #[derive(Debug, thiserror::Error)] @@ -179,7 +179,7 @@ impl<'server> WorkspaceBuilder<'server> { let directory = pane.start_directory.as_deref().or(directory); let mut split = SplitWindow::new(window); if let Some(directory) = directory { - split = split.start_directory(escape_format(directory)); + split = split.start_directory(directory); } for (name, value) in config.environment.iter().chain(&pane.environment) { split = split.environment(name.as_str(), value.as_str()); @@ -297,12 +297,12 @@ impl<'server> WorkspaceBuilder<'server> { } fn session_op(workspace: &Workspace) -> NewSession { - // A workspace file is not this program's own text. tmux expands a - // name and a start directory alike as formats, so an unescaped - // `#(command)` in either would run a shell for whoever wrote the file. - let mut session = NewSession::new(escape_format(workspace.session_name.as_str())); + // A workspace file is not this program's own text, and every sink + // below escapes what it is given, so a `#(command)` in one arrives as + // the characters someone typed rather than as a shell command. + let mut session = NewSession::new(workspace.session_name.as_str()); if let Some(directory) = workspace.start_directory.as_deref() { - session = session.start_directory(escape_format(directory)); + session = session.start_directory(directory); } session } @@ -315,10 +315,10 @@ impl<'server> WorkspaceBuilder<'server> { ) -> NewWindow { let mut window = NewWindow::new(session); if let Some(name) = config.window_name.as_deref() { - window = window.name(escape_format(name)); + window = window.name(name); } if let Some(directory) = directory { - window = window.start_directory(escape_format(directory)); + window = window.start_directory(directory); } if let Ok(index) = u32::try_from(config.window_index.unwrap_or(-1)) { window = window.index(index); From ace8b4ddd8a7e8a73979c29a7213e86c7f63459f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 18:02:59 -0500 Subject: [PATCH 040/117] API(add): Reach a pane's session, a scope's parts, a session's environment Three gaps a caller works around today, each with the workaround in this repository or its siblings. `NewSessionOptions::environment`: `NewWindowOptions` and `SplitOptions` both set environment variables for the process they start, and the session builder did not, so the one creation that has no parent to inherit from was the one that could not be given anything. tmux has taken `new-session -e` since 3.2, below this crate's 3.2a floor, so no version gate. `Pane::session`: `Window::session` existed and the pane equivalent did not, leaving callers to read `session_id` and look it up. It re-reads tmux for the same reason `Window::session` does -- a session renamed since discovery should report as it is now. `ScopeError::{into_operation, into_value, tmux_error}`: the type held four variants and no accessors, so every caller wrote a four-arm match to reach a value the type already had, including the operation result that `Cleanup` retains precisely so it is not lost. Also `#[non_exhaustive]`, which it should have been: it is the one public enum this branch added without it, in a crate carrying 37 elsewhere. Each test shown capable of failing: dropping the `-e` rendering times out the wait for `seen=set-here`, and inverting the session match in `Pane::session` reports the session as gone. --- crates/libtmux/docs/parity.md | 3 ++ crates/libtmux/docs/public-api.txt | 5 ++ crates/libtmux/src/error/scoped.rs | 79 ++++++++++++++++++++++++++++++ crates/libtmux/src/pane.rs | 34 +++++++++++++ crates/libtmux/src/server.rs | 18 +++++++ crates/libtmux/tests/mutations.rs | 48 ++++++++++++++++++ 6 files changed, 187 insertions(+) diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index b1abc1f2..63a3e977 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -712,6 +712,9 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | Python behavior | Intentional Rust behavior | Delivery slice | Status | | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------- | | A name, a title, an option name, and a `-c` start directory reach tmux as formats, so `#(command)` in any of them runs a shell for whoever wrote the text. | Every such argument takes `TmuxArg`, whose `From` impls escape, so text a program did not write arrives as itself; `TmuxArg::format` opts into expansion for a template the program wrote. Option names and `plan` operations escape internally. Covered by [`tests/format_injection.rs`](../tests/format_injection.rs), one test per sink, and by the compatibility test in [`tests/hierarchy.rs`](../tests/hierarchy.rs) that proves tmux still expands when asked. | Foundation | `implemented` | +| A scope's failure collapses creation, the operation and cleanup into one error type, so a caller converts or loses one of them. | `ScopeError::{into_operation, into_value, tmux_error}` reach each without a match, and `Cleanup` keeps the value the operation produced. `#[non_exhaustive]`, so a later variant is not a break. | Object mutations and interactions | `implemented` | +| `new_session` cannot set environment variables for the process it starts, though `new_window` and `split_window` can. | `NewSessionOptions::environment`, matching `NewWindowOptions` and `SplitOptions`. tmux has taken `new-session -e` since 3.2, below this crate's floor, so it needs no version gate. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | +| Reaching a pane's session means reading `session_id` and looking it up. | `Pane::session`, mirroring `Window::session`: it re-reads tmux, so a session renamed since discovery reports as it is now. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | | An unset option and an unknown option are one failure. | `Server::typed_option` reports `None` for unset built-ins and absent user options; the `@` prefix determines which rule applies. | Options, hooks, and advanced command families | `implemented` | | Creating an object returns a handle that must be looked up again to be useful. | `Server::new_session`, `Session::new_window`, and `Pane::split` hydrate handles from each creating command's `-P -F` output in one round trip. | Object mutations and interactions | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 04378b48..d41e8978 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -375,6 +375,7 @@ function libtmux::Layout::minimum_release: const fn(self) -> libtmux::ReleaseVer function libtmux::ListingDecodeError::field_name: const fn(&self) -> Option<&'static str> function libtmux::ListingDecodeError::row: const fn(&self) -> Option function libtmux::NewSessionOptions::command: fn(self, command: impl Into) -> Self +function libtmux::NewSessionOptions::environment: fn(self, name: impl Into, value: impl Into) -> Self function libtmux::NewSessionOptions::new: fn(name: impl Into) -> Self function libtmux::NewSessionOptions::size: fn(self, width: u32, height: u32) -> Self function libtmux::NewSessionOptions::start_directory: fn(self, directory: impl Into) -> Self @@ -445,6 +446,7 @@ function libtmux::Pane::send_key_names: async fn(&self, keys: I) -> Result function libtmux::Pane::send_keys: async fn(&self, keys: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::send_line: async fn(&self, text: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::send_prefix: async fn(&self) -> Result<(), libtmux::Error> +function libtmux::Pane::session: async fn(&self) -> Result, libtmux::Error> function libtmux::Pane::session_id: const fn(&self) -> &libtmux::SessionId function libtmux::Pane::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::set_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> @@ -476,6 +478,9 @@ function libtmux::ReleaseVersion::major: const fn(self) -> u16 function libtmux::ReleaseVersion::minor: const fn(self) -> u16 function libtmux::ReleaseVersion::new: const fn(major: u16, minor: u16, suffix: libtmux::ReleaseSuffix) -> Self function libtmux::ReleaseVersion::suffix: const fn(self) -> libtmux::ReleaseSuffix +function libtmux::ScopeError::into_operation: fn(self) -> Option +function libtmux::ScopeError::into_value: fn(self) -> Option +function libtmux::ScopeError::tmux_error: const fn(&self) -> Option<&libtmux::Error> function libtmux::Server::access_rules: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::append_array_option: async fn(&self, name: &str, index: u32, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::array_option: async fn(&self, name: &str) -> Result, libtmux::Error> diff --git a/crates/libtmux/src/error/scoped.rs b/crates/libtmux/src/error/scoped.rs index 60888ebb..85cf588f 100644 --- a/crates/libtmux/src/error/scoped.rs +++ b/crates/libtmux/src/error/scoped.rs @@ -47,6 +47,7 @@ use super::Error; /// # Ok(()) /// # } /// ``` +#[non_exhaustive] pub enum ScopeError { /// The resource could not be created; the operation did not run. Creation(Error), @@ -69,6 +70,84 @@ pub enum ScopeError { }, } +impl ScopeError { + /// Take the operation's own error, when the operation is what failed. + /// + /// `None` for a creation failure, where the operation never ran, and for + /// a cleanup-only failure, where it succeeded. + /// + /// # Examples + /// + /// ``` + /// use libtmux::ScopeError; + /// + /// let failed: ScopeError<(), _> = ScopeError::Operation("no route"); + /// assert_eq!(failed.into_operation(), Some("no route")); + /// ``` + pub fn into_operation(self) -> Option { + match self { + Self::Operation(operation) | Self::OperationAndCleanup { operation, .. } => { + Some(operation) + } + Self::Creation(_) | Self::Cleanup { .. } => None, + } + } + + /// Take the value the operation produced before cleanup failed. + /// + /// `None` unless the operation succeeded, which is the one case where a + /// result would otherwise be lost: the outer `Result` is `Err` either way. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// // The work is done even when tearing the session down failed, so the + /// // value is worth keeping rather than discarding with the error. + /// let outcome = server + /// .with_session("build", async |session| { + /// Ok::<_, libtmux::Error>(session.id().to_string()) + /// }) + /// .await; + /// + /// let id = match outcome { + /// Ok(id) => Some(id), + /// Err(error) => error.into_value(), + /// }; + /// # let _ = id; + /// # Ok(()) + /// # } + /// ``` + pub fn into_value(self) -> Option { + match self { + Self::Cleanup { value, .. } => Some(value), + Self::Creation(_) | Self::Operation(_) | Self::OperationAndCleanup { .. } => None, + } + } + + /// Borrow the tmux error from creation or cleanup. + /// + /// `None` for [`Self::Operation`], whose `E` is the caller's own type. + /// + /// # Examples + /// + /// ``` + /// use libtmux::ScopeError; + /// + /// let failed: ScopeError<(), &str> = ScopeError::Operation("no route"); + /// assert!(failed.tmux_error().is_none()); + /// ``` + #[must_use] + pub const fn tmux_error(&self) -> Option<&Error> { + match self { + Self::Creation(error) + | Self::Cleanup { cleanup: error, .. } + | Self::OperationAndCleanup { cleanup: error, .. } => Some(error), + Self::Operation(_) => None, + } + } +} + impl fmt::Debug for ScopeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index b1fc1fa5..84f5a895 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -11,6 +11,7 @@ use crate::internal::core::Core; use crate::internal::listing; #[cfg(feature = "query")] use crate::query::{FilterSchema, Filterable}; +use crate::session::Session; use crate::snapshot::PaneProjection; #[cfg(feature = "query")] use crate::snapshot::{PaneFields, PaneInfo}; @@ -429,6 +430,39 @@ impl Pane { .map(|projection| Window::new(Arc::clone(&self.core), projection))) } + /// Return the session this pane was reached through. + /// + /// This re-reads tmux rather than the snapshot, so a session renamed or + /// removed since discovery is reported as it is now. `Ok(None)` means the + /// session no longer exists. + /// + /// A window can be linked into several sessions, so this is the session + /// this handle was reached through rather than the pane's only one; the + /// pane itself belongs to exactly one window. + /// + /// # Errors + /// + /// Returns an error when the session listing fails. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(pane: &libtmux::Pane) -> Result<(), libtmux::Error> { + /// if let Some(session) = pane.session().await? { + /// println!("{}", session.name().to_string_lossy()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn session(&self) -> Result, Error> { + let infos = listing::sessions(&self.core, None).await?; + + Ok(infos + .into_iter() + .find(|info| info.session_id() == self.session_id()) + .map(|info| Session::new(Arc::clone(&self.core), info))) + } + /// Split this pane, putting a new one beside it. /// /// [`Window::split`] divides whichever pane is active; this divides the diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 82a7dc3e..6fa2421c 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1965,6 +1965,7 @@ pub struct NewSessionOptions { start_directory: Option, window_name: Option, command: Option, + environment: Vec<(OsString, OsString)>, width: Option, height: Option, } @@ -1976,6 +1977,7 @@ impl fmt::Debug for NewSessionOptions { .field("has_start_directory", &self.start_directory.is_some()) .field("has_window_name", &self.window_name.is_some()) .field("has_command", &self.command.is_some()) + .field("environment_count", &self.environment.len()) .field("width", &self.width) .field("height", &self.height) .finish_non_exhaustive() @@ -1992,6 +1994,7 @@ impl NewSessionOptions { start_directory: None, window_name: None, command: None, + environment: Vec::new(), width: None, height: None, } @@ -2018,6 +2021,16 @@ impl NewSessionOptions { self } + /// Set an environment variable for the process the new session starts. + /// + /// Call this more than once for more than one variable. tmux applies + /// these to the new process only, not to the session. The value is not + /// format-expanded, so it needs no escaping. + pub fn environment(mut self, name: impl Into, value: impl Into) -> Self { + self.environment.push((name.into(), value.into())); + self + } + /// Set the initial size, which a detached session would otherwise default. /// /// tmux 3.2a accepts this and sets `default-size` as asked, but still @@ -2058,6 +2071,11 @@ impl NewSessionOptions { .arg("-y") .arg(height.to_string()); } + for (name, value) in self.environment { + command = command + .arg("-e") + .sensitive_arg(crate::window::assignment(&name, &value)); + } if let Some(shell_command) = self.command { command = command.sensitive_arg(shell_command); } diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index d4d1b759..8e5b3a64 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -2085,3 +2085,51 @@ async fn a_rendered_window_target_survives_a_renumber() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +#[tokio::test] +async fn a_new_session_carries_environment_to_its_first_process() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + + let session = guard + .server() + .new_session(NewSessionOptions::new("env").environment("LIBTMUX_RS", "set-here")) + .await + .expect("the session is created"); + let pane = session.panes().await.expect("panes list").remove(0); + + wait_for_prompt(&pane).await; + pane.send_line("printf 'seen=%s\\n' \"$LIBTMUX_RS\"") + .await + .expect("the pane accepts the line"); + + assert_eq!( + pane.wait_for_text("seen=set-here", Duration::from_secs(10)) + .await + .expect("the pane is readable"), + libtmux::PaneWait::Arrived, + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn a_pane_reaches_the_session_it_was_found_through() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("reach") + .await + .expect("the session is created"); + let pane = session.panes().await.expect("panes list").remove(0); + + let reached = pane + .session() + .await + .expect("the session listing is readable") + .expect("the session still exists"); + + assert_eq!(reached.id(), session.id()); + assert_eq!(text(Some(reached.name())), b"reach".to_vec()); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From 2f0716e967bb96e0312f4196e2fc6ba4d3c4096e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 18:07:09 -0500 Subject: [PATCH 041/117] Tests(add[names]): Find a session by the hostile name it was created with Escaping on the way out would be worth little if it made the name unreachable on the way back. `Server::session` and `has_session` scan a listing and compare client-side rather than handing the name to tmux as a target, which is what makes the round trip hold; this pins it. --- crates/libtmux/tests/format_injection.rs | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/libtmux/tests/format_injection.rs b/crates/libtmux/tests/format_injection.rs index 663a44ff..1e1cc6a1 100644 --- a/crates/libtmux/tests/format_injection.rs +++ b/crates/libtmux/tests/format_injection.rs @@ -33,6 +33,34 @@ async fn a_session_name_reaches_tmux_literally() { guard.shutdown().await.expect("the fixture shuts down"); } +/// Creating and then finding by the same name has to agree, or escaping on the +/// way out would have made the name unreachable on the way back. +#[tokio::test] +async fn a_hostile_name_round_trips_through_lookup() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + + let created = server + .new_session(HOSTILE) + .await + .expect("the session is created"); + + assert!( + server + .has_session(HOSTILE) + .await + .expect("the listing reads") + ); + let found = server + .session(HOSTILE) + .await + .expect("the listing reads") + .expect("the session is found by the name it was given"); + assert_eq!(found.id(), created.id()); + + guard.shutdown().await.expect("the fixture shuts down"); +} + #[tokio::test] async fn a_renamed_session_reaches_tmux_literally() { let guard = TestServer::builder().start().await.expect("tmux starts"); From 621d63c5ee619ee5fdd139ec5f32cc5a5a5e448b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 18:16:54 -0500 Subject: [PATCH 042/117] Window(fix[layout]): Resolve a preset against the release that is running Two ways the layout presets were gated against the wrong question. `SavedLayout::classify` filtered the prefix candidates with `TmuxVersion::meets`, which is a floor check: it clamps a development identifier to the crate's minimum supported release rather than reading the release number it carries. So on `next-3.9` or `master` -- trees that certainly have the mirrored pair -- both mirrored presets were filtered out, `main-vertical-mirrored` was unrecognized, and `main-h` resolved as a unique preset where 3.5 onward makes it ambiguous. The exact-name match above it filtered by nothing at all, so the same `main-vertical-mirrored` was accepted on 3.2a and sent to a tmux with no such layout. Both now ask `has_behavior` through one `Layout::is_in`. `meets`'s own rustdoc has warned about exactly this since it was written -- "a call site gating a specific capability behind a release above MIN_SUPPORTED almost always wants that instead" -- and this call site was written anyway, so prose is not the remedy: `meets_is_only_ever_asked_about_the_floor` reads the crate's sources and fails on any `.meets(` that names something other than MIN_SUPPORTED. Both gates shown capable of failing by restoring `meets` here: the behaviour test reports `main-h` as unique, and the source gate names the line. --- crates/libtmux/src/window.rs | 61 ++++++++++++++++++++++++++++++++- crates/libtmux/tests/version.rs | 55 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 50b82869..bb86c78e 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -1282,6 +1282,15 @@ impl Layout { } } + /// Whether this release's tree carries the preset. + /// + /// `has_behavior`, not `meets`: a development identifier carries what its + /// release number implies, and clamping one to the crate's floor hides a + /// preset the running tmux has. + pub(crate) fn is_in(self, version: &crate::TmuxVersion) -> bool { + version.has_behavior(&self.minimum_release()) + } + /// The first tmux release that arranges panes this way. /// /// The mirrored pair arrived in 3.5; the rest predate everything this @@ -1403,6 +1412,7 @@ impl From<&TmuxText> for LayoutSpec { } /// The shape of a saved layout value, read before it reaches tmux. +#[cfg_attr(test, derive(Debug, PartialEq))] enum SavedLayout { /// A preset name, or a prefix of exactly one, passed as text rather than /// as a [`Layout`]. @@ -1437,6 +1447,7 @@ impl SavedLayout { }; if let Some(named) = Self::PRESETS .into_iter() + .filter(|named| named.is_in(version)) .find(|named| named.as_str() == text) { return Self::Preset(named); @@ -1451,7 +1462,7 @@ impl SavedLayout { if !text.is_empty() { let candidates: Vec = Self::PRESETS .into_iter() - .filter(|named| version.meets(&named.minimum_release())) + .filter(|named| named.is_in(version)) .filter(|named| named.as_str().starts_with(text)) .collect(); match candidates.as_slice() { @@ -1980,3 +1991,51 @@ mod split_option_tests { assert!(!summary.to_string().contains(secret)); } } + +#[cfg(test)] +mod layout_version_tests { + use std::ffi::OsStr; + + use super::{Layout, SavedLayout}; + use crate::TmuxVersion; + + fn classify(text: &str, raw: &[u8]) -> SavedLayout { + let version = TmuxVersion::parse_output(raw).expect("a parsable version"); + SavedLayout::classify(OsStr::new(text), &version) + } + + /// A development tree carries every preset its release number implies, so + /// resolving one must ask the behaviour question rather than the + /// conservative one: clamping a development identifier to the crate's + /// floor hides the mirrored pair on a tmux that has it, and silently + /// turns an ambiguous prefix back into a unique one. + #[test] + fn a_development_release_resolves_the_presets_it_has() { + for raw in [b"tmux next-3.9\n".as_slice(), b"tmux master\n".as_slice()] { + assert_eq!( + classify("main-vertical-mirrored", raw), + SavedLayout::Preset(Layout::MainVerticalMirrored), + "{} names a preset this tree has", + String::from_utf8_lossy(raw).trim(), + ); + assert!( + matches!(classify("main-h", raw), SavedLayout::Ambiguous(_)), + "{}: `main-h` is a prefix of two presets once the mirrored pair exists", + String::from_utf8_lossy(raw).trim(), + ); + } + } + + /// The floor still has five presets, so the same prefix is unique there. + #[test] + fn the_minimum_release_resolves_its_own_presets() { + assert_eq!( + classify("main-h", b"tmux 3.2a\n"), + SavedLayout::Preset(Layout::MainHorizontal), + ); + assert_eq!( + classify("main-vertical-mirrored", b"tmux 3.2a\n"), + SavedLayout::Unrecognized, + ); + } +} diff --git a/crates/libtmux/tests/version.rs b/crates/libtmux/tests/version.rs index 2e37405b..594200ac 100644 --- a/crates/libtmux/tests/version.rs +++ b/crates/libtmux/tests/version.rs @@ -1,5 +1,9 @@ //! Integration tests for tmux version parsing and ordering. +// Helpers outside a test function are not covered by clippy.toml's in-test +// exemptions, and this file has one. +#![allow(clippy::expect_used)] + use std::error::Error as StdError; use libtmux::{Error, ReleaseSuffix, ReleaseVersion, TmuxVersion}; @@ -309,3 +313,54 @@ fn error_is_a_thread_safe_static_standard_error() { assert_error::(); } + +/// `meets` answers the floor question and clamps a development identifier to +/// it, so gating a capability on it refuses a tmux that already has the +/// capability. Its own rustdoc has warned about that from the start, and +/// `SavedLayout::classify` gated the mirrored layout presets on it anyway: +/// on `next-3.9` both presets vanished and an ambiguous prefix read as +/// unique. Prose did not prevent that, so this does. A capability question +/// belongs to `has_behavior` or `require`. +#[test] +fn meets_is_only_ever_asked_about_the_floor() { + let mut offenders = Vec::new(); + + for entry in walk(std::path::Path::new("src")) { + let source = std::fs::read_to_string(&entry).expect("a readable source file"); + for (number, line) in source.lines().enumerate() { + let code = line.trim_start(); + // Doc examples show the trap on purpose. + if code.starts_with("///") || code.starts_with("//!") || code.starts_with("//") { + continue; + } + if code.contains(".meets(") && !code.contains("MIN_SUPPORTED") { + offenders.push(format!( + "{}:{}: {}", + entry.display(), + number + 1, + code.trim() + )); + } + } + } + + assert!( + offenders.is_empty(), + "`meets` gates a capability here; use `has_behavior` or `require`:\n{}", + offenders.join("\n"), + ); +} + +fn walk(directory: &std::path::Path) -> Vec { + let mut found = Vec::new(); + let entries = std::fs::read_dir(directory).expect("a readable directory"); + for entry in entries { + let path = entry.expect("a readable entry").path(); + if path.is_dir() { + found.extend(walk(&path)); + } else if path.extension().is_some_and(|extension| extension == "rs") { + found.push(path); + } + } + found +} From b96e92b02a600b6a98db3b6d9035e8773a4c48d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 18:23:07 -0500 Subject: [PATCH 043/117] Server(add[channels]): Hold a wait-for channel for one operation `lock_channel` and `unlock_channel` were two calls a caller had to pair by hand, and the failure mode is not local: a lock left held wedges every later locker on the server, not just the code that forgot. `with_channel_lock` joins the `with_*` family, so an early return, an error or a panic still releases the channel. It does not cover the other wedge. Dropping a *pending* lock while it is queued behind another locker leaves tmux holding a queue entry for nobody; that is a defect in `cmd-wait-for.c` and no scope reaches it. `lock_channel` documents it and keeps doing so. Shown capable of failing by dropping the unlock from the cleanup: the next `lock_channel` runs to the 30-second dispatch deadline instead of returning. --- crates/libtmux/docs/parity.md | 1 + crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/server/channels.rs | 62 ++++++++++++++++++++++++++ crates/libtmux/tests/server_command.rs | 31 +++++++++++++ 4 files changed, 95 insertions(+) diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 63a3e977..38c6386f 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -715,6 +715,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | A scope's failure collapses creation, the operation and cleanup into one error type, so a caller converts or loses one of them. | `ScopeError::{into_operation, into_value, tmux_error}` reach each without a match, and `Cleanup` keeps the value the operation produced. `#[non_exhaustive]`, so a later variant is not a break. | Object mutations and interactions | `implemented` | | `new_session` cannot set environment variables for the process it starts, though `new_window` and `split_window` can. | `NewSessionOptions::environment`, matching `NewWindowOptions` and `SplitOptions`. tmux has taken `new-session -e` since 3.2, below this crate's floor, so it needs no version gate. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | Reaching a pane's session means reading `session_id` and looking it up. | `Pane::session`, mirroring `Window::session`: it re-reads tmux, so a session renamed since discovery reports as it is now. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | +| A `wait-for` lock is taken and released by two calls, so a locker that returns early leaves the channel held and every later locker blocked. | `Server::with_channel_lock` pairs them, releasing on the error path as the other `with_*` scopes do. The queued-drop wedge is a tmux defect (`cmd-wait-for.c`) and stays documented on `Server::lock_channel`. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | | An unset option and an unknown option are one failure. | `Server::typed_option` reports `None` for unset built-ins and absent user options; the `@` prefix determines which rule applies. | Options, hooks, and advanced command families | `implemented` | | Creating an object returns a handle that must be looked up again to be useful. | `Server::new_session`, `Session::new_window`, and `Pane::split` hydrate handles from each creating command's `-P -F` output in one round trip. | Object mutations and interactions | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index d41e8978..007e4159 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -574,6 +574,7 @@ function libtmux::Server::wait_for_channel: async fn(&self, channel: &str, withi function libtmux::Server::window_by_id: async fn(&self, id: &libtmux::WindowId) -> Result, libtmux::Error> function libtmux::Server::windows: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::windows_or_empty: async fn(&self) -> Vec +function libtmux::Server::with_channel_lock: async fn(&self, channel: &str, operation: impl AsyncFnOnce(&Self) -> Result) -> Result> function libtmux::Server::with_session: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Session) -> Result) -> Result> function libtmux::ServerBuilder::build: fn(self) -> Result function libtmux::ServerBuilder::colors: const fn(self, colors: u16) -> Self diff --git a/crates/libtmux/src/server/channels.rs b/crates/libtmux/src/server/channels.rs index ac50b0ed..69746c9f 100644 --- a/crates/libtmux/src/server/channels.rs +++ b/crates/libtmux/src/server/channels.rs @@ -35,6 +35,68 @@ impl Server { .await } + /// Hold a `wait-for` channel for the length of an operation. + /// + /// [`Self::lock_channel`] and [`Self::unlock_channel`] as a pair, so a + /// locker that returns early, fails, or panics still releases the + /// channel: a lock left held wedges every later locker on the server. + /// + /// This does not cover the other way a channel wedges. Dropping a + /// *pending* lock while it is queued behind another locker leaves tmux + /// with a queue entry it will hand the lock to and nobody to take it; + /// that is a tmux defect (`cmd-wait-for.c`) and no scope can reach it. + /// See [`Self::lock_channel`]. + /// + /// # Errors + /// + /// [`crate::ScopeError::Creation`] when tmux refuses the lock, + /// `Operation` when the body fails, and `Cleanup` when the unlock fails + /// after the body succeeded. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// let guard = libtmux::test::TestServer::new().await?; + /// let server = guard.server(); + /// + /// let count = server + /// .with_channel_lock("deploy", async |server| { + /// Ok::<_, libtmux::Error>(server.sessions().await?.len()) + /// }) + /// .await?; + /// + /// // The channel is free again, so the next locker is not blocked. + /// server.lock_channel("deploy").await?; + /// server.unlock_channel("deploy").await?; + /// # let _ = count; + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + pub async fn with_channel_lock( + &self, + channel: &str, + operation: impl AsyncFnOnce(&Self) -> Result, + ) -> Result> { + let server = self.clone(); + let channel = channel.to_owned(); + let held = channel.clone(); + let unlocking = self.clone(); + + crate::internal::scoped::run( + "with-channel-lock", + async move { server.lock_channel(&held).await.map(|()| server.clone()) }, + move |_| async move { unlocking.unlock_channel(&channel).await }, + async |_| operation(self).await, + ) + .await + } + /// Lock a `wait-for` channel, blocking later lock attempts on it. /// /// Dropping this future while it is still queued behind another locker diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index 99a49064..866c7a4b 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -1179,3 +1179,34 @@ fn an_absent_tmux_variable_is_told_apart_from_a_malformed_one() { Server::from_env_value(Some("/tmp/libtmux-rs-dev/from-env.sock,7,$0")) .expect("a triple names a socket"); } + +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_channel_lock_is_released_when_the_body_fails() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + + let outcome: Result<(), _> = server + .with_channel_lock("deploy", async |_| Err::<(), _>("the body failed")) + .await; + assert!(matches!( + outcome, + Err(libtmux::ScopeError::Operation("the body failed")) + )); + + // A wedged channel would block this forever, so the fixture deadline is + // what would report it; taking the lock is the assertion. + server + .lock_channel("deploy") + .await + .expect("the channel was released"); + server + .unlock_channel("deploy") + .await + .expect("the channel unlocks"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From 7d0956b2e36da14ae4456ae7721d2aee8ce3e045 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 18:46:50 -0500 Subject: [PATCH 044/117] API(remove): Drop the eleven lenient listing twins Each `*_or_empty` answered "nothing is there" and "the listing failed" with the same empty vector. The README already warned what that costs -- a reconciler reading "no sessions" from an outage deletes everything -- and eleven methods whose whole purpose is to discard a reason are eleven ways to discard one by accident. Nothing outside this crate used them. `tmux-mcp` and `tmux-workspace` call the loud listings thirty times between them and the twins zero times, so the pair existed for a caller who never appeared. `sessions().await.unwrap_or_default()` is the replacement, and it puts the choice where the reader can see it. `trace_discarded` went with them: it existed to log what a twin threw away. `tests/lenient_listings.rs` asserted that each twin logged its discard. It is now `tests/listing_failures.rs` and asserts the stronger property: with the server gone, all eleven listings report the failure rather than answering empty. It keeps the positive control that each one answers while the server is up, so an error is the outage rather than a method that never worked. --- crates/libtmux/README.md | 11 +- crates/libtmux/docs/design.md | 20 ++-- crates/libtmux/docs/migration.md | 16 +++ crates/libtmux/docs/parity.md | 26 ++--- crates/libtmux/docs/public-api.txt | 11 -- crates/libtmux/examples/scratch.rs | 4 +- crates/libtmux/src/internal/listing.rs | 28 ----- crates/libtmux/src/lib.rs | 10 +- crates/libtmux/src/server.rs | 15 +-- crates/libtmux/src/server/discovery.rs | 81 +------------ crates/libtmux/src/session.rs | 44 +------ crates/libtmux/src/window/navigation.rs | 77 ------------ crates/libtmux/tests/commands.rs | 5 +- crates/libtmux/tests/filter_hierarchy.rs | 3 +- crates/libtmux/tests/hierarchy.rs | 22 ++-- crates/libtmux/tests/lenient_listings.rs | 143 ----------------------- crates/libtmux/tests/listing_failures.rs | 73 ++++++++++++ crates/libtmux/tests/mutations.rs | 2 +- crates/libtmux/tests/plan.rs | 2 +- 19 files changed, 152 insertions(+), 441 deletions(-) delete mode 100644 crates/libtmux/tests/lenient_listings.rs create mode 100644 crates/libtmux/tests/listing_failures.rs diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 31878daa..58e1b122 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -394,11 +394,12 @@ async fn main() -> Result<(), Box> { } ``` -Listings come in pairs, and the short name is the honest one. `sessions()` -returns `Result>`, so an unreachable tmux is an error rather than -an empty list. `sessions_or_empty()` collapses failure into no rows, which -suits a status line and nothing that reconciles state -- a reconciler reading -"no sessions" from an outage will happily delete everything. +A listing keeps the reason it failed. `sessions()` returns +`Result>`, so an unreachable tmux is an error rather than an empty +list. A caller that would rather show nothing writes +`sessions().await.unwrap_or_default()`, which suits a status line and nothing +that reconciles state -- a reconciler reading "no sessions" from an outage will +happily delete everything, and now has to say so at the call site. ## Filtering diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 9339a0f2..3ab4ac05 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -151,8 +151,8 @@ tmux. Its source is not copied into the crate. another clone, avoiding locks and hidden shared state. - A real-tmux guard created unique short socket paths, exposed the exact path, and removed the daemon and socket on drop. -- Loud list access and explicit `*_or_empty` access can coexist without making - raw command execution swallow failures. +- List access keeps the reason a listing failed without making raw command + execution swallow failures. - A failed command at the start of a tmux semicolon chain prevents later commands from executing. A control-mode implementation that predicts one result block per separator can wait forever for blocks tmux will never send. @@ -715,9 +715,9 @@ contract: ```no_run # async fn both(server: &libtmux::Server) -> Result<(), libtmux::Error> { -let lenient = server.sessions_or_empty().await; let loud = server.sessions().await?; -# let _ = (lenient, loud); +let quiet = server.sessions().await.unwrap_or_default(); +# let _ = (loud, quiet); # Ok(()) # } ``` @@ -1082,10 +1082,8 @@ unrecognized form classifies as `LinkGone` -- the reading that does not license discarding a live handle -- so the cost of being wrong is a distinction rather than a destroyed handle. -Listing accessors come in pairs, and the split is load-bearing here. The -`*_or_empty` form returns an empty `Vec` for any failure, which suits a status -line. The short form propagates, which is the whole reason it exists -- a -short form that quietly returned no rows for an unreachable daemon would make +A listing propagates, which is the whole reason it reads this way -- one +that quietly returned no rows for an unreachable daemon would make the pair meaningless. ## Cost of gathering the hierarchy @@ -1767,7 +1765,11 @@ a cleanup pass, a workspace builder -- "no sessions" read from an outage is an instruction to delete everything. So the names swapped. `sessions()` returns `Result`, and a caller who wants -the old behaviour writes `sessions_or_empty()`, which says what it does. The +the old behaviour writes `sessions().await.unwrap_or_default()`, which says what +it does at the call site rather than in the method name. An `_or_empty` twin of +each listing did exist for a while; neither consumer crate ever called one, and +eleven methods whose whole purpose is to discard a reason are eleven ways to +discard one by accident, so they went. The breaking change is cheap now and would not be later, which is the argument for doing it during an alpha rather than after one. diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index b2e597ec..010e8e0f 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -1,5 +1,21 @@ # Migrating from 0.1.0-alpha.11 +## The `_or_empty` listing twins are gone + +Replace `x_or_empty().await` with `x().await.unwrap_or_default()`: + +```no_run +# async fn listing(server: &libtmux::Server) -> Result<(), libtmux::Error> { +let sessions = server.sessions().await.unwrap_or_default(); +# let _ = sessions; +# Ok(()) +# } +``` + +Worth a moment's thought rather than a blind rewrite: the twins collapsed an +unreachable tmux into an empty list, and anything that reconciles state should +take the `?` instead. + ## Names, titles and start directories are text Every argument tmux expands as a format now takes `TmuxArg`, and every diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 38c6386f..b5036ea7 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -26,8 +26,8 @@ has a dedicated test. Rust test evidence is added only when a row advances beyond `planned`. The Rust hierarchy-collection policy is uniform. Each public listing has a -lenient `*_or_empty` accessor and a loud form under the short name: -`Server::sessions_or_empty` beside `Server::sessions`, and so on down the +loud accessor under the short name: `Server::sessions` keeps the reason it +failed, and a caller who prefers an empty list writes hierarchy. The lenient form returns an empty `Vec` when its tmux list operation fails and records the failure through tracing when enabled, so a caller that needs to tell "no rows" from "tmux unreachable" reaches for the @@ -514,10 +514,10 @@ Sources: `src/libtmux/server.py`, `docs/api/libtmux.server.md`, | `__enter__`, `__exit__` | Enter returns the Server. Exit calls `is_alive()` and then `kill()`; swallowed liveness errors can silently skip cleanup. | `Server::with_session` scopes one session and cleans it up whether the body succeeded or not; ordinary `Drop` is not destructive. There is no server-scoped form. | Discovery, traversal, refresh, and environment resolution | `planned` | | `is_alive`, `raise_if_dead` | One bool swallowing every exception, or unit with missing-executable and subprocess errors preserved. | `Server::is_alive` is the compatibility bool and `Server::check_alive` is the loud form. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `cmd` | One raw result with socket, config, color, and optional target flags. Ordinary tmux stderr remains data. | `Server::cmd(Command) -> Result`; covered by [`tests/server_command.rs`](../tests/server_command.rs). | Foundation | `implemented` | -| `attached_sessions` | Ordered `QueryList`, zero or more. It derives from `sessions`, so every `LibTmuxException` becomes empty. | `Server::attached_sessions_or_empty` lenient and `Server::attached_sessions` loud. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `sessions` | Ordered typed collection, zero or more. Every `LibTmuxException`, including executable and permission failures, becomes empty. | `Server::sessions_or_empty` lenient and `Server::sessions` loud, returning ordered `Vec`; covered by [`src/server.rs`](../src/server.rs) doctests and [`src/snapshot.rs`](../src/snapshot.rs) real-tmux tests. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `clients` | Ordered typed collection, zero or more. Every `LibTmuxException`, including executable and permission failures, becomes empty. | `Server::clients_or_empty` and `Server::clients`; covered by [`tests/commands.rs`](../tests/commands.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `windows`, `panes` | Ordered winlink-preserving collections, zero or more. Only recognized absent-daemon or missing-socket errors become empty; other errors propagate. | `Server::windows_or_empty` and `Server::panes_or_empty` lenient, `Server::windows` and `Server::panes` loud; intentional uniform delta. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `attached_sessions` | Ordered `QueryList`, zero or more. It derives from `sessions`, so every `LibTmuxException` becomes empty. | `Server::attached_sessions`, which keeps the reason it failed. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `sessions` | Ordered typed collection, zero or more. Every `LibTmuxException`, including executable and permission failures, becomes empty. | `Server::sessions`, returning ordered `Vec` and keeping the reason it failed; covered by [`src/server.rs`](../src/server.rs) doctests and [`src/snapshot.rs`](../src/snapshot.rs) real-tmux tests. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `clients` | Ordered typed collection, zero or more. Every `LibTmuxException`, including executable and permission failures, becomes empty. | `Server::clients`; covered by [`tests/commands.rs`](../tests/commands.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `windows`, `panes` | Ordered winlink-preserving collections, zero or more. Only recognized absent-daemon or missing-socket errors become empty; other errors propagate. | `Server::windows` and `Server::panes`, both loud: an outage is an error rather than an empty listing. Intentional uniform delta. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `search_sessions`, `search_windows`, `search_panes` | Native raw `-f`, ordered zero or more. Absent daemon becomes empty, other failures propagate, and malformed filters can look empty. | `Server::sessions`, `Server::windows`, and `Server::panes` return loud lists; `query::QueryIteratorExt::matching` performs typed local filtering. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `has_session` | One bool; validates the name and optionally forces exact matching. Ordinary nonzero status, including a dead server, becomes false; launch failures propagate. | `Server::has_session` validates a `SessionName` and returns a loud bool. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `kill`, `kill_session` | Unit or the same Server. `kill` ignores recognized already-dead errors. `kill_session` performs no documented name validation and accepts integers. | `Server::kill` and `Session::kill` are loud unit operations over typed handles. | Object mutations and interactions | `implemented` | @@ -542,7 +542,7 @@ Sources: `src/libtmux/server.py`, `docs/api/libtmux.server.md`, | `save_buffer`, `load_buffer` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Path` arguments; not written. `Server::cmd` reaches `save-buffer` and `load-buffer` meanwhile. | Options, hooks, and advanced command families | `planned` | | `source_file` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | `Server::source_file` is a loud typed request for enqueued source-file work. | Options, hooks, and advanced command families | `implemented` | | `if_shell` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | Typed request representing enqueued state; not written. `Server::cmd` reaches `if-shell` meanwhile. | Options, hooks, and advanced command families | `planned` | -| `list_clients` | Zero or more raw client lines with loud errors, overlapping the typed lenient `clients` property. | Typed `Server::clients` and `Server::clients_or_empty` are the whole of it; no raw line form is offered. | Options, hooks, and advanced command families | `implemented` | +| `list_clients` | Zero or more raw client lines with loud errors, overlapping the typed lenient `clients` property. | Typed `Server::clients` is the whole of it; no raw line form is offered. | Options, hooks, and advanced command families | `implemented` | | `switch_client`, `attach_session` | Unit; validate session names. Despite an optional annotation, `attach_session(None)` always raises `BadSessionName`. | Require a typed `SessionTarget`. | Object mutations and interactions | `implemented` | | `__eq__`, `__repr__` | Equality compares only socket name and path, so default-server handles compare equal. | Equality and hashing over normalized `ServerIdentity`; sanitized `Debug`; covered by [`tests/server_command.rs`](../tests/server_command.rs). | Foundation | `implemented` | | `kill_server`, `get_by_id`, `where`, `find_where`, `list_sessions`, `children` | Public names whose only behavior is raising `DeprecatedError`. | Omit. | Documentation, compatibility, and parity closure | `excluded` | @@ -561,8 +561,8 @@ Sources: `src/libtmux/session.py`, `docs/api/libtmux.session.md`, | `__enter__`, `__exit__` | Enter returns the Session. Exit kills only when the name exists and `has_session()` returns true; liveness errors can skip cleanup. | `Server::with_session`; ordinary `Drop` is not destructive. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `refresh` | Mutates in place and returns `None`. Missing ID raises `ValueError`; vanished target and server failures propagate. Empty fields can remain stale. | `Session::refresh(&mut self)` and `Session::refreshed(&self)`, replacing the snapshot atomically; covered by [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `from_session_id`, `from_env` | Exactly one Session. `from_env` follows the live pane instead of the stale session ID stored in `$TMUX`. | `Server::session_by_id`, because a lookup needs the connection the server owns, and `Session::from_env`, which resolves through `TMUX_PANE`; covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `windows`, `panes` | Ordered `QueryList`, zero or more; Python propagates every fetch error. | `Session::windows_or_empty` and `Session::panes_or_empty` lenient, `Session::windows` and `Session::panes` loud; intentional uniform delta. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `search_windows`, `search_panes` | Ordered native-filtered lists; errors propagate and malformed filters can look empty. | `search_windows_or_empty`/`search_panes_or_empty` lenient and `search_windows`/`search_panes` loud, taking the same `Matcher` a listing takes rather than a second query language. Filtering is client-side, consistent with `.matching()`; the expression stays compilable to a tmux `-f` predicate, so pushing one down later changes the cost and not the answer. Covered by [`tests/filter_hierarchy.rs`](../tests/filter_hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `windows`, `panes` | Ordered `QueryList`, zero or more; Python propagates every fetch error. | `Session::windows` and `Session::panes`, both loud: an outage is an error rather than an empty listing. Intentional uniform delta. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `search_windows`, `search_panes` | Ordered native-filtered lists; errors propagate and malformed filters can look empty. | `search_windows` and `search_panes`, both loud, taking the same `Matcher` a listing takes rather than a second query language. Filtering is client-side, consistent with `.matching()`; the expression stays compilable to a tmux `-f` predicate, so pushing one down later changes the cost and not the answer. Covered by [`tests/filter_hierarchy.rs`](../tests/filter_hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `cmd` | One raw result targeting the Session ID by default. An ID-less Session omits the target and can become server-scoped. | `Session::cmd`, placing `-t` after the subcommand rather than appending it: tmux stops reading flags at the first positional, so an appended target is taken as text and the command succeeds having acted on something else. Covered by [`tests/commands.rs`](../tests/commands.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `lock_session`, `detach_client` | Unit; stderr raises. Detach affects every client attached to the Session. | `Session::lock` and `Session::detach_clients` are loud; detaching an already-detached session succeeds. | Object mutations and interactions | `implemented` | | `last_window`, `next_window`, `previous_window`, `select_window` | Exactly one newly active Window. Command errors and zero or multiple active-window errors propagate. | `Session::next_window`, `Session::previous_window`, and `Session::last_window` return `Option`; `Window::select` covers `select_window`; covered by [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Object mutations and interactions | `implemented` | @@ -591,9 +591,9 @@ Sources: `src/libtmux/window.py`, `docs/api/libtmux.window.md`, | `__enter__`, `__exit__` | Enter returns the Window. Exit queries its parent Session and kills it if present; query or kill errors can propagate. | `Session::with_window`; no async cleanup in ordinary `Drop`. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `refresh`, `from_window_id`, `from_env` | Mutating unit or exactly one constructor. Missing ID raises `ValueError`; vanished target and server errors propagate. | `Window::refresh`, `Window::refreshed`, `Server::window_by_id`, and `Window::from_env`; covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `session` | Exactly one live canonical parent; errors propagate. | `Window::session`, re-reading tmux so a moved window reports where it is now; covered by [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `linked_sessions` | Deduplicated ordered `QueryList`, zero or more. Any list error becomes empty; Sessions that disappear between snapshots are dropped. | `Window::linked_sessions_or_empty` lenient and `Window::linked_sessions` loud. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `panes` | Ordered `QueryList`, zero or more; Python propagates list errors. | `Window::panes_or_empty` lenient and `Window::panes` loud; intentional uniform delta. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `search_panes` | Ordered native-filtered list; malformed filter can look empty and list errors propagate. | `search_windows_or_empty`/`search_panes_or_empty` lenient and `search_windows`/`search_panes` loud, taking the same `Matcher` a listing takes rather than a second query language. Filtering is client-side, consistent with `.matching()`; the expression stays compilable to a tmux `-f` predicate, so pushing one down later changes the cost and not the answer. Covered by [`tests/filter_hierarchy.rs`](../tests/filter_hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `linked_sessions` | Deduplicated ordered `QueryList`, zero or more. Any list error becomes empty; Sessions that disappear between snapshots are dropped. | `Window::linked_sessions`, which keeps the reason it failed. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `panes` | Ordered `QueryList`, zero or more; Python propagates list errors. | `Window::panes`, loud: an outage is an error rather than an empty listing. Intentional uniform delta. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| `search_panes` | Ordered native-filtered list; malformed filter can look empty and list errors propagate. | `search_windows` and `search_panes`, both loud, taking the same `Matcher` a listing takes rather than a second query language. Filtering is client-side, consistent with `.matching()`; the expression stays compilable to a tmux `-f` predicate, so pushing one down later changes the cost and not the answer. Covered by [`tests/filter_hierarchy.rs`](../tests/filter_hierarchy.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `cmd` | One raw result targeting the Window ID by default. | `Window::cmd`, placing `-t` after the subcommand rather than appending it: tmux stops reading flags at the first positional, so an appended target is taken as text and the command succeeds having acted on something else. Covered by [`tests/commands.rs`](../tests/commands.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `active_pane` | Zero or one active Pane; relationship errors propagate. | `Window::active_pane` returns zero or one pane loudly. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `select_pane`, `last_pane` | Zero or one newly selected Pane; command and relationship errors propagate. | `Window::last_pane` returns `Option` and `Pane::select` covers `select_pane`; covered by [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Object mutations and interactions | `implemented` | @@ -740,7 +740,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | Session, Window, and Pane equality ignore Server identity; Client compares its full snapshot. | `Session`, `Window`, `Pane`, and `Client` equality and hashing include normalized server identity plus stable object identity. | Discovery, traversal, refresh, and environment resolution | `implemented` | | Refresh mutates only returned nonempty fields and can retain stale values. | `Session::refresh`, `Window::refresh`, and `Pane::refresh` replace complete snapshots; cloned handles retain independent snapshots. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `new_session` temporarily deletes process-global `TMUX`, so it may route differently from raw commands on the same `Server`. | Domain operations reuse the captured `ServerIdentity` and never mutate process-global connection state. | Object mutations and interactions | `implemented` | -| Hierarchy collections have inconsistent failure contracts. | `Server::sessions` and `Server::sessions_or_empty` establish the loud/lenient pair repeated by hierarchy collections. | Discovery, traversal, refresh, and environment resolution | `implemented` | +| Hierarchy collections have inconsistent failure contracts. | `Server::sessions` establishes the one contract every hierarchy collection repeats: the reason a listing failed is kept, never collapsed into no rows. | Discovery, traversal, refresh, and environment resolution | `implemented` | | Python `QueryList` is mutable and has equality and `items()` defects. | `query::QueryIteratorExt` supplies matching and exact cardinality over user-owned ordered collections; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | | Python `QueryList` accepts dynamic suffixes and falls back from an unknown suffix to a nested exact lookup. | Dynamic value lookup, unknown-suffix handling, and `field__operator` parsing remain future ingress behavior rather than part of typed Rust authoring. | Discovery, traversal, refresh, and environment resolution | `planned` | | Python case-insensitive query suffixes compare `str.lower()` results. | `query::TextField::eq_ignore_case` uses Unicode 16.0 default case folding without normalization, so `"Straße"` and `"STRASSE"` match; covered by [`tests/query.rs`](../tests/query.rs). | Formats, snapshots, winlinks, and queries | `verified` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 007e4159..f4d169fd 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -485,7 +485,6 @@ function libtmux::Server::access_rules: async fn(&self) -> Result) -> Result<(), libtmux::Error> function libtmux::Server::array_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::attached_sessions: async fn(&self) -> Result, libtmux::Error> -function libtmux::Server::attached_sessions_or_empty: async fn(&self) -> Vec function libtmux::Server::bind_key: async fn(&self, table: &str, key: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::buffer: async fn(&self, name: &str) -> Result>, libtmux::Error> function libtmux::Server::buffer_names: async fn(&self) -> Result, libtmux::Error> @@ -497,7 +496,6 @@ function libtmux::Server::choose: async fn(&self, chooser: libtmux::Chooser, cli function libtmux::Server::clear_prompt_history: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Server::client: async fn(&self, name: impl AsRef<[u8]>) -> Result, libtmux::Error> function libtmux::Server::clients: async fn(&self) -> Result, libtmux::Error> -function libtmux::Server::clients_or_empty: async fn(&self) -> Vec function libtmux::Server::cmd: async fn(&self, command: libtmux::Command) -> Result function libtmux::Server::colors: fn(&self) -> Option function libtmux::Server::command_prompt: async fn(&self, client: Option<&libtmux::Client>, prompt: Option<&str>, command: impl Into) -> Result<(), libtmux::Error> @@ -536,7 +534,6 @@ function libtmux::Server::options: async fn(&self) -> Result bool function libtmux::Server::pane_by_id: async fn(&self, id: &libtmux::PaneId) -> Result, libtmux::Error> function libtmux::Server::panes: async fn(&self) -> Result, libtmux::Error> -function libtmux::Server::panes_or_empty: async fn(&self) -> Vec function libtmux::Server::prompt_history: async fn(&self, kind: libtmux::PromptKind) -> Result, libtmux::Error> function libtmux::Server::require_generation: async fn(&self, expected: libtmux::ServerGeneration) -> Result<(), libtmux::Error> function libtmux::Server::resolved_tmux_executable: fn(&self) -> Option @@ -545,7 +542,6 @@ function libtmux::Server::run_shell: async fn(&self, command: impl Into) -> Result, libtmux::Error> function libtmux::Server::session_by_id: async fn(&self, id: &libtmux::SessionId) -> Result, libtmux::Error> function libtmux::Server::sessions: async fn(&self) -> Result, libtmux::Error> -function libtmux::Server::sessions_or_empty: async fn(&self) -> Vec function libtmux::Server::set_array_option: async fn(&self, name: &str, index: u32, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::set_buffer: async fn(&self, name: Option<&str>, data: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::set_environment: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> @@ -573,7 +569,6 @@ function libtmux::Server::unset_option: async fn(&self, name: &str) -> Result<() function libtmux::Server::wait_for_channel: async fn(&self, channel: &str, within: Duration) -> Result function libtmux::Server::window_by_id: async fn(&self, id: &libtmux::WindowId) -> Result, libtmux::Error> function libtmux::Server::windows: async fn(&self) -> Result, libtmux::Error> -function libtmux::Server::windows_or_empty: async fn(&self) -> Vec function libtmux::Server::with_channel_lock: async fn(&self, channel: &str, operation: impl AsyncFnOnce(&Self) -> Result) -> Result> function libtmux::Server::with_session: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Session) -> Result) -> Result> function libtmux::ServerBuilder::build: fn(self) -> Result @@ -616,14 +611,12 @@ function libtmux::Session::next_window: async fn(&self) -> Result Result, libtmux::Error> function libtmux::Session::options: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::panes: async fn(&self) -> Result, libtmux::Error> -function libtmux::Session::panes_or_empty: async fn(&self) -> Vec function libtmux::Session::path: fn(&self) -> &libtmux::TmuxText function libtmux::Session::previous_window: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::refresh: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Session::refreshed: async fn(&self) -> Result function libtmux::Session::rename: async fn(&mut self, name: impl Into) -> Result<&mut Self, libtmux::Error> function libtmux::Session::search_windows: async fn>(&self, matcher: M) -> Result, libtmux::Error> -function libtmux::Session::search_windows_or_empty: async fn>(&self, matcher: M) -> Vec function libtmux::Session::set_environment: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Session::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Session::set_hooks: async fn(&self, name: &str, hooks: &libtmux::IndexedHooks, replace: libtmux::ReplaceMode) -> Result<(), libtmux::Error> @@ -636,7 +629,6 @@ function libtmux::Session::window: async fn(&self, name: impl AsRef<[u8]>) -> Re function libtmux::Session::window_at: async fn(&self, index: i32) -> Result, libtmux::Error> function libtmux::Session::window_count: fn(&self) -> u32 function libtmux::Session::windows: async fn(&self) -> Result, libtmux::Error> -function libtmux::Session::windows_or_empty: async fn(&self) -> Vec function libtmux::Session::with_window: async fn(&self, options: impl Into, operation: impl AsyncFnOnce(&libtmux::Window) -> Result) -> Result> function libtmux::SessionName::as_str: fn(&self) -> &str function libtmux::SessionName::new: fn(name: impl Into) -> Result @@ -696,7 +688,6 @@ function libtmux::Window::last_pane: async fn(&self) -> Result &libtmux::TmuxText function libtmux::Window::link_to: async fn(&self, session: &libtmux::Session, index: Option) -> Result<(), libtmux::Error> function libtmux::Window::linked_sessions: async fn(&self) -> Result, libtmux::Error> -function libtmux::Window::linked_sessions_or_empty: async fn(&self) -> Vec function libtmux::Window::move_to: async fn(&mut self, session: &libtmux::Session, index: i32) -> Result<&mut Self, libtmux::Error> function libtmux::Window::name: fn(&self) -> &libtmux::TmuxText function libtmux::Window::next_layout: async fn(&mut self) -> Result<&mut Self, libtmux::Error> @@ -705,7 +696,6 @@ function libtmux::Window::options: async fn(&self) -> Result Result, libtmux::Error> function libtmux::Window::pane_count: fn(&self) -> u32 function libtmux::Window::panes: async fn(&self) -> Result, libtmux::Error> -function libtmux::Window::panes_or_empty: async fn(&self) -> Vec function libtmux::Window::previous_layout: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Window::refresh: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Window::refreshed: async fn(&self) -> Result @@ -715,7 +705,6 @@ function libtmux::Window::resize_by: async fn(&mut self, direction: libtmux::Res function libtmux::Window::respawn: async fn(&mut self, command: Option>, kill: bool) -> Result<&mut Self, libtmux::Error> function libtmux::Window::rotate: async fn(&self, rotation: libtmux::Rotation) -> Result<(), libtmux::Error> function libtmux::Window::search_panes: async fn>(&self, matcher: M) -> Result, libtmux::Error> -function libtmux::Window::search_panes_or_empty: async fn>(&self, matcher: M) -> Vec function libtmux::Window::select: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Window::select_layout: async fn(&mut self, layout: impl Into) -> Result<&mut Self, libtmux::Error> function libtmux::Window::session: async fn(&self) -> Result, libtmux::Error> diff --git a/crates/libtmux/examples/scratch.rs b/crates/libtmux/examples/scratch.rs index 8269ecee..4566f1b1 100644 --- a/crates/libtmux/examples/scratch.rs +++ b/crates/libtmux/examples/scratch.rs @@ -71,13 +71,13 @@ async fn main() -> Result<(), Box> { // the only session, so tmux exited with it, and the loud form reports that // as the failure it is rather than as the empty listing this is asking for. assert!( - server.sessions_or_empty().await.is_empty(), + server.sessions().await.unwrap_or_default().is_empty(), "the scope cleaned up", ); println!( "sessions left behind: {}", - server.sessions_or_empty().await.len() + server.sessions().await.unwrap_or_default().len() ); server.shutdown().await?; diff --git a/crates/libtmux/src/internal/listing.rs b/crates/libtmux/src/internal/listing.rs index dfaf688c..2e11d6dc 100644 --- a/crates/libtmux/src/internal/listing.rs +++ b/crates/libtmux/src/internal/listing.rs @@ -136,34 +136,6 @@ fn decode_error(list_command: &'static str) -> impl Fn(FormatCodecError) -> Erro } } -/// Record that a lenient listing threw a failure away. -/// -/// The lenient forms return an empty vector for "nothing there" and for "the -/// listing failed", which is the trade they exist for. A caller who chose them -/// has said the reason does not change what they do -- but somebody reading a -/// log later still needs to be able to tell the two apart, and an empty vector -/// cannot. -/// -/// This lives here rather than beside any one caller because all eleven of -/// them need it. As a private associated function on `Server` it was reachable -/// only from that file, so five listings recorded their discard and six did -/// not, split by nothing but where the helper happened to sit. -#[cfg_attr( - not(feature = "tracing"), - expect( - unused_variables, - reason = "the cause has no sink when tracing is disabled" - ) -)] -pub(crate) fn trace_discarded(list_command: &'static str, error: &Error) { - #[cfg(feature = "tracing")] - tracing::debug!( - list_command, - error = %error, - "a lenient listing discarded a failure and returned empty", - ); -} - /// List sessions. pub(crate) async fn sessions(core: &Core, filter: Option<&str>) -> Result, Error> { const LIST_COMMAND: &str = "list-sessions"; diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index c48c966b..5d2dfd07 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -39,14 +39,14 @@ //! # } //! ``` //! -//! Listings come in pairs. The `_or_empty` form returns an empty `Vec` when -//! the underlying tmux command fails, which suits a status line; the plain -//! form keeps the reason, which suits anything that must not guess: +//! A listing keeps the reason it failed. A caller that would rather show +//! nothing than an error says so at the call site, where it reads as the +//! choice it is: //! //! ```no_run //! # async fn both(server: &libtmux::Server) -> Result<(), libtmux::Error> { -//! let quiet = server.sessions_or_empty().await; // empty on failure -//! let loud = server.sessions().await?; // Err on failure +//! let loud = server.sessions().await?; // Err on failure +//! let quiet = server.sessions().await.unwrap_or_default(); // empty on failure //! # let _ = (quiet, loud); //! # Ok(()) //! # } diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 6fa2421c..12117dc5 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -254,8 +254,8 @@ impl PromptKind { /// Each reports `Ok(None)` when tmux does not have it. /// /// **Listing everything.** [`sessions`], [`windows`], [`panes`], and -/// [`clients`], each with an `_or_empty` twin that reports no rows rather -/// than the reason for a failure. [`hierarchy`] gathers the whole tree in +/// [`clients`]. Each keeps the reason it failed, so an outage does not read +/// as an empty server. [`hierarchy`] gathers the whole tree in /// three tmux commands rather than one per object. /// /// **Changing things.** [`new_session`], [`kill`], and [`with_session`], @@ -811,17 +811,6 @@ impl Server { .build() } - /// List the sessions that have at least one client attached. - /// - /// This is the lenient form; use [`Server::attached_sessions`] when the - /// reason for an empty result matters. - pub async fn attached_sessions_or_empty(&self) -> Vec { - self.attached_sessions().await.unwrap_or_else(|error| { - listing::trace_discarded("list-sessions", &error); - Vec::new() - }) - } - /// List the sessions that have at least one client attached. /// /// # Errors diff --git a/crates/libtmux/src/server/discovery.rs b/crates/libtmux/src/server/discovery.rs index 7318e322..a2d40d5d 100644 --- a/crates/libtmux/src/server/discovery.rs +++ b/crates/libtmux/src/server/discovery.rs @@ -16,58 +16,6 @@ use crate::window::Window; use crate::{Error, PaneId, SessionId, WindowId}; impl Server { - /// List every session on the server, in tmux's own order. - /// - /// This is the lenient form: a server that is not running, or any other - /// failure of the underlying list operation, yields an empty `Vec`. Use - /// [`Server::sessions`] when the reason matters. - /// - /// # Examples - /// - /// ``` - /// # fn main() -> Result<(), Box> { - /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; - /// # runtime.block_on(async { - /// let guard = libtmux::test::TestServer::new().await?; - /// let server = guard.server(); - /// - /// // A fixture starts with no sessions. The lenient form reports that as - /// // an empty listing rather than as the failure it also collapses. - /// assert!(server.sessions_or_empty().await.is_empty()); - /// - /// guard.session("work").await?; - /// - /// let sessions = server.sessions_or_empty().await; - /// assert_eq!(sessions.len(), 1); - /// assert_eq!(sessions[0].name().as_bytes(), b"work"); - /// - /// guard.shutdown().await?; - /// # Ok::<(), Box>(()) - /// # })?; - /// # Ok(()) - /// # } - /// ``` - pub async fn sessions_or_empty(&self) -> Vec { - self.sessions().await.unwrap_or_else(|error| { - listing::trace_discarded("list-sessions", &error); - Vec::new() - }) - } - - /// List every window on the server, in tmux's own order. - /// - /// A window linked into several sessions appears once per link, so a - /// window id can repeat. See [`Window`] for what that means for equality. - /// - /// This is the lenient form; use [`Server::windows`] when the reason - /// for an empty result matters. - pub async fn windows_or_empty(&self) -> Vec { - self.windows().await.unwrap_or_else(|error| { - listing::trace_discarded("list-windows", &error); - Vec::new() - }) - } - /// List every window on the server, preserving any failure. /// /// # Errors @@ -83,20 +31,6 @@ impl Server { .collect()) } - /// List every pane on the server, in tmux's own order. - /// - /// Panes under a linked window appear once per link, matching - /// [`Server::windows_or_empty`]. - /// - /// This is the lenient form; use [`Server::panes`] when the reason for - /// an empty result matters. - pub async fn panes_or_empty(&self) -> Vec { - self.panes().await.unwrap_or_else(|error| { - listing::trace_discarded("list-panes", &error); - Vec::new() - }) - } - /// List every pane on the server, preserving any failure. /// /// # Errors @@ -112,17 +46,6 @@ impl Server { .collect()) } - /// List every client attached to the server, in tmux's own order. - /// - /// This is the lenient form; use [`Server::clients`] when the reason for - /// an empty result matters. - pub async fn clients_or_empty(&self) -> Vec { - self.clients().await.unwrap_or_else(|error| { - listing::trace_discarded("list-clients", &error); - Vec::new() - }) - } - /// List every client attached to the server, preserving any failure. /// /// # Errors @@ -182,7 +105,7 @@ impl Server { /// Find the window with this id, through the first link that reaches it. /// /// A window linked into several sessions is returned once. Use - /// [`Server::windows_or_empty`] when the link matters. + /// [`Server::windows`] when the link matters. /// /// # Errors /// @@ -247,7 +170,7 @@ impl Server { /// Fetch the whole hierarchy in three commands. /// - /// Walking down with [`Server::sessions_or_empty`], then each session's windows, + /// Walking down with [`Server::sessions`], then each session's windows, /// then each window's panes costs one command per object. tmux can answer /// the same question with `list-sessions`, `list-windows -a`, and /// `list-panes -a`, so this issues three regardless of how much is diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index 268f71fe..5a6e1d81 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -253,17 +253,6 @@ impl Session { self.core.configuration().identity() } - /// List the windows linked into this session, in tmux's own order. - /// - /// This is the lenient form; use [`Session::windows`] when the reason - /// for an empty result matters. - pub async fn windows_or_empty(&self) -> Vec { - self.windows().await.unwrap_or_else(|error| { - listing::trace_discarded("list-windows", &error); - Vec::new() - }) - } - /// List the windows linked into this session, preserving any failure. /// /// A window linked into other sessions appears here once, under this @@ -284,26 +273,6 @@ impl Session { .collect()) } - /// The windows under this session that a matcher accepts. - /// - /// Empty when the listing fails, which suits a status line. Use - /// [`Self::search_windows`] when the difference matters. - /// - /// Filtering happens here rather than in tmux. A [`crate::query::FilterExpr`] - /// is built to stay compilable to a tmux `-f` predicate, so pushing one - /// down later would change what this costs and not what it answers. - #[cfg(feature = "query")] - #[must_use] - pub async fn search_windows_or_empty>( - &self, - matcher: M, - ) -> Vec { - self.search_windows(matcher).await.unwrap_or_else(|error| { - listing::trace_discarded("list-windows", &error); - Vec::new() - }) - } - /// The windows under this session that a matcher accepts, reporting why /// if the listing fails. /// @@ -447,17 +416,6 @@ impl Session { Ok(self.windows().await?.into_iter().find(Window::is_active)) } - /// List every pane in this session, in tmux's own order. - /// - /// This is the lenient form; use [`Session::panes`] when the reason - /// for an empty result matters. - pub async fn panes_or_empty(&self) -> Vec { - self.panes().await.unwrap_or_else(|error| { - listing::trace_discarded("list-panes", &error); - Vec::new() - }) - } - /// List every pane in this session, preserving any failure. /// /// # Errors @@ -736,7 +694,7 @@ impl Session { /// Succeeds when no client was attached. tmux reports that as a failure, /// but the state this asks for -- nobody attached to this session -- is /// already true, and a caller that has to tell "detached them" from - /// "there was nobody" can compare [`crate::Server::clients_or_empty`] before and + /// "there was nobody" can compare [`crate::Server::clients`] before and /// after. /// /// # Errors diff --git a/crates/libtmux/src/window/navigation.rs b/crates/libtmux/src/window/navigation.rs index 290ad4de..4d3ed245 100644 --- a/crates/libtmux/src/window/navigation.rs +++ b/crates/libtmux/src/window/navigation.rs @@ -9,17 +9,6 @@ use crate::session::Session; use crate::{Command, Error, ObjectKind}; impl Window { - /// List this window's panes, in tmux's own order. - /// - /// This is the lenient form; use [`Window::panes`] when the reason for - /// an empty result matters. - pub async fn panes_or_empty(&self) -> Vec { - self.panes().await.unwrap_or_else(|error| { - listing::trace_discarded("list-panes", &error); - Vec::new() - }) - } - /// List this window's panes, preserving any failure. /// /// Panes are addressed by window id rather than by session and index, so @@ -39,26 +28,6 @@ impl Window { .collect()) } - /// The panes under this window that a matcher accepts. - /// - /// Empty when the listing fails, which suits a status line. Use - /// [`Self::search_panes`] when the difference matters. - /// - /// Filtering happens here rather than in tmux. A [`crate::query::FilterExpr`] - /// is built to stay compilable to a tmux `-f` predicate, so pushing one - /// down later would change what this costs and not what it answers. - #[cfg(feature = "query")] - #[must_use] - pub async fn search_panes_or_empty>( - &self, - matcher: M, - ) -> Vec { - self.search_panes(matcher).await.unwrap_or_else(|error| { - listing::trace_discarded("list-panes", &error); - Vec::new() - }) - } - /// The panes under this window that a matcher accepts, reporting why /// if the listing fails. /// @@ -245,54 +214,8 @@ impl Window { .map(Some) } - /// The sessions this window is linked into, in the order tmux lists them. - /// - /// A window can be linked into several sessions at once, and every one of - /// them holds the same window rather than a copy. This reports the - /// sessions reaching it, including the one this handle was found through. - /// - /// The sessions are read from tmux's winlink rows rather than from - /// `#{window_linked_sessions_list}`, which is a comma-separated list of - /// *names* and so cannot be taken apart: a session named `has,comma` - /// makes the list `a,has,comma`, which reads exactly like three sessions. - /// - /// Empty when the listing fails, which suits a status line. Use - /// [`Self::linked_sessions`] when the difference matters. - /// - /// # Examples - /// - /// ``` - /// # fn main() -> Result<(), Box> { - /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; - /// # runtime.block_on(async { - /// let guard = libtmux::test::TestServer::new().await?; - /// let server = guard.server(); - /// let first = server.new_session("first").await?; - /// let second = server.new_session("second").await?; - /// let window = first.active_window().await?.expect("a window"); - /// - /// assert_eq!(window.linked_sessions_or_empty().await.len(), 1); - /// - /// window.link_to(&second, None).await?; - /// let linked = window.linked_sessions_or_empty().await; - /// assert_eq!(linked.len(), 2, "the same window, reached two ways"); - /// - /// guard.shutdown().await?; - /// # Ok::<(), Box>(()) - /// # })?; - /// # Ok(()) - /// # } - /// ``` - pub async fn linked_sessions_or_empty(&self) -> Vec { - self.linked_sessions().await.unwrap_or_else(|error| { - listing::trace_discarded("list-sessions", &error); - Vec::new() - }) - } - /// The sessions this window is linked into, reporting why if it cannot. /// - /// The loud form of [`Self::linked_sessions_or_empty`]. /// /// # Errors /// diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index b73d5867..94deb4d6 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -994,8 +994,9 @@ async fn searching_a_window_finds_the_pane_that_matches() { let fields = libtmux::Pane::filter_fields(); let found = window - .search_panes_or_empty(fields.pane_id.eq(wanted.as_str())) - .await; + .search_panes(fields.pane_id.eq(wanted.as_str())) + .await + .unwrap_or_default(); assert_eq!(found.len(), 1, "the pane that matches is returned"); assert_eq!(found[0].id().to_string(), wanted); diff --git a/crates/libtmux/tests/filter_hierarchy.rs b/crates/libtmux/tests/filter_hierarchy.rs index 3125061d..29c47561 100644 --- a/crates/libtmux/tests/filter_hierarchy.rs +++ b/crates/libtmux/tests/filter_hierarchy.rs @@ -234,8 +234,9 @@ async fn searching_filters_a_listing_and_keeps_the_lenient_loud_pair() { // the listing itself fails, which is what the pair exists to distinguish. assert_eq!( session - .search_windows_or_empty(&fields.window_name.starts_with("build")) + .search_windows(&fields.window_name.starts_with("build")) .await + .unwrap_or_default() .len(), building.len(), ); diff --git a/crates/libtmux/tests/hierarchy.rs b/crates/libtmux/tests/hierarchy.rs index 72f55ff2..b323cccd 100644 --- a/crates/libtmux/tests/hierarchy.rs +++ b/crates/libtmux/tests/hierarchy.rs @@ -47,9 +47,9 @@ async fn empty_server_lists_nothing_across_the_hierarchy() { let guard = TestServer::builder().start().await.expect("tmux starts"); let server = guard.server(); - assert!(server.sessions_or_empty().await.is_empty()); - assert!(server.windows_or_empty().await.is_empty()); - assert!(server.panes_or_empty().await.is_empty()); + assert!(server.sessions().await.unwrap_or_default().is_empty()); + assert!(server.windows().await.unwrap_or_default().is_empty()); + assert!(server.panes().await.unwrap_or_default().is_empty()); // An empty listing is an ordinary result, not a decoding failure. assert!( @@ -567,9 +567,9 @@ async fn a_dead_server_yields_empty_leniently_and_an_error_loudly() { guard.shutdown().await.expect("tmux fixture shuts down"); // The lenient contract hides the cause behind an empty listing. - assert!(server.sessions_or_empty().await.is_empty()); - assert!(server.windows_or_empty().await.is_empty()); - assert!(server.panes_or_empty().await.is_empty()); + assert!(server.sessions().await.unwrap_or_default().is_empty()); + assert!(server.windows().await.unwrap_or_default().is_empty()); + assert!(server.panes().await.unwrap_or_default().is_empty()); // The loud form keeps it. This is the whole reason both forms exist: the // executor is gone, which is a caller mistake rather than an empty server. @@ -600,7 +600,7 @@ async fn an_absent_daemon_is_empty_to_one_form_and_a_reason_to_the_other() { .expect("an inert server handle is built"); assert!( - server.sessions_or_empty().await.is_empty(), + server.sessions().await.unwrap_or_default().is_empty(), "the lenient form suits a status line, which has nothing to say", ); @@ -693,7 +693,13 @@ async fn attached_sessions_selects_only_sessions_with_clients() { .expect("attached sessions") .is_empty(), ); - assert!(server.attached_sessions_or_empty().await.is_empty()); + assert!( + server + .attached_sessions() + .await + .unwrap_or_default() + .is_empty() + ); guard.shutdown().await.expect("tmux fixture shuts down"); } diff --git a/crates/libtmux/tests/lenient_listings.rs b/crates/libtmux/tests/lenient_listings.rs deleted file mode 100644 index 25d92128..00000000 --- a/crates/libtmux/tests/lenient_listings.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! What the lenient listings do with the failure they discard. - -#![cfg(all(feature = "test-support", feature = "tracing", feature = "query"))] - -use std::sync::Arc; -use std::sync::Mutex; - -use libtmux::test::TestServer; -use tracing::subscriber::Subscriber; -use tracing_subscriber::layer::{Context, Layer, SubscriberExt as _}; -use tracing_subscriber::registry::LookupSpan; - -/// Collects the `list_command` of every discard the crate records. -#[derive(Clone, Default)] -struct Discards { - seen: Arc>>, -} - -impl Discards { - fn seen(&self) -> Vec { - self.seen - .lock() - .map(|seen| seen.clone()) - .unwrap_or_default() - } -} - -impl LookupSpan<'a>> Layer for Discards { - fn on_event(&self, event: &tracing::Event<'_>, _context: Context<'_, S>) { - struct Fields<'a> { - discarded: &'a mut bool, - command: &'a mut Option, - } - - impl tracing::field::Visit for Fields<'_> { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - let rendered = format!("{value:?}"); - match field.name() { - "message" if rendered.contains("lenient listing discarded") => { - *self.discarded = true; - } - "list_command" => *self.command = Some(rendered.trim_matches('"').to_owned()), - _ => {} - } - } - - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - if field.name() == "list_command" { - *self.command = Some(value.to_owned()); - } - } - } - - let mut discarded = false; - let mut command = None; - event.record(&mut Fields { - discarded: &mut discarded, - command: &mut command, - }); - // Written as two ifs rather than a let-chain: this crate's floor is - // 1.85 and let-chains landed in 1.88. `tmux-mcp` uses them because its - // own floor is 1.88. - if discarded { - if let Ok(mut seen) = self.seen.lock() { - seen.push(command.unwrap_or_else(|| "unnamed".to_owned())); - } - } - } -} - -/// Every lenient listing must record the failure it throws away. -/// -/// The empty vector these return means "nothing there" and "the listing -/// failed" alike, which is the trade a caller chooses them for. What a caller -/// cannot then do is tell the two apart afterwards, so the discard has to -/// reach a log or it reaches nowhere. -/// -/// Five of the eleven did. The other six could not: the helper was a private -/// associated function on `Server`, so only that file's listings could call -/// it, and the split followed where the helper sat rather than any decision. -#[tokio::test] -async fn every_lenient_listing_records_what_it_discarded() { - let discards = Discards::default(); - let subscriber = tracing_subscriber::registry().with(discards.clone()); - let _guard = tracing::subscriber::set_default(subscriber); - - let guard = TestServer::builder().start().await.expect("tmux starts"); - let server = guard.server().clone(); - let server = &server; - let session = server.new_session("lenient").await.expect("session"); - let window = session - .active_window() - .await - .expect("windows") - .expect("a session has a window"); - - // Positive control: every listing answers while the server is up, so a - // later empty is a discarded failure rather than a method that never - // worked. - assert!(!server.sessions_or_empty().await.is_empty()); - assert!(!session.windows_or_empty().await.is_empty()); - assert!(!window.panes_or_empty().await.is_empty()); - assert!( - discards.seen().is_empty(), - "a healthy server discards nothing" - ); - - guard.shutdown().await.expect("tmux fixture shuts down"); - - // Now every listing fails, and each must say so. - let matcher = { - use libtmux::query::Filterable as _; - libtmux::Pane::filter_fields().pane_id.eq("%0") - }; - let windows_matcher = { - use libtmux::query::Filterable as _; - libtmux::Window::filter_fields().window_id.eq("@0") - }; - - assert!(server.sessions_or_empty().await.is_empty()); - assert!(server.windows_or_empty().await.is_empty()); - assert!(server.panes_or_empty().await.is_empty()); - assert!(server.clients_or_empty().await.is_empty()); - assert!(server.attached_sessions_or_empty().await.is_empty()); - assert!(session.windows_or_empty().await.is_empty()); - assert!(session.panes_or_empty().await.is_empty()); - assert!( - session - .search_windows_or_empty(windows_matcher) - .await - .is_empty() - ); - assert!(window.panes_or_empty().await.is_empty()); - assert!(window.search_panes_or_empty(matcher).await.is_empty()); - assert!(window.linked_sessions_or_empty().await.is_empty()); - - let seen = discards.seen(); - assert_eq!( - seen.len(), - 11, - "all eleven lenient listings record their discard, saw: {seen:?}" - ); -} diff --git a/crates/libtmux/tests/listing_failures.rs b/crates/libtmux/tests/listing_failures.rs new file mode 100644 index 00000000..5e384315 --- /dev/null +++ b/crates/libtmux/tests/listing_failures.rs @@ -0,0 +1,73 @@ +//! A listing keeps the reason it failed. +//! +//! There used to be an `_or_empty` twin of each of these, returning an empty +//! vector for "nothing there" and for "the listing failed" alike. Neither +//! consumer crate ever called one, and a reconciler reading "no sessions" from +//! an outage deletes everything, so the twins are gone and the reason is not +//! optional. A caller who would still rather show nothing writes +//! `.unwrap_or_default()`, where it reads as the choice it is. + +#![cfg(feature = "test-support")] +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use libtmux::test::TestServer; + +#[tokio::test] +async fn every_listing_reports_a_server_that_is_gone() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server().clone(); + let session = server.new_session("gone").await.expect("session"); + let window = session + .active_window() + .await + .expect("windows") + .expect("a session has a window"); + + let panes = { + use libtmux::query::Filterable as _; + libtmux::Pane::filter_fields().pane_id.eq("%0") + }; + let windows = { + use libtmux::query::Filterable as _; + libtmux::Window::filter_fields().window_id.eq("@0") + }; + + // Positive control: each listing answers while the server is up, so a + // later error is the server being gone rather than a method that never + // worked. + assert!(!server.sessions().await.expect("sessions").is_empty()); + assert!(!session.windows().await.expect("windows").is_empty()); + assert!(!window.panes().await.expect("panes").is_empty()); + + guard.shutdown().await.expect("tmux fixture shuts down"); + + // Every listing now fails, and each says so rather than reporting empty. + // The kind is `Transport` rather than `ServerGone` because the fixture + // shuts the executor down as well as the daemon, so nothing reaches tmux + // to be told the server is gone. What matters here is that no listing + // answers an outage with an empty vector. + macro_rules! assert_reports_failure { + ($label:literal, $call:expr) => { + let error = $call + .await + .expect_err(concat!($label, " reports the failure")); + assert!( + !matches!(error.kind(), libtmux::ErrorKind::Decode), + "{}: {error}", + $label, + ); + }; + } + + assert_reports_failure!("Server::sessions", server.sessions()); + assert_reports_failure!("Server::windows", server.windows()); + assert_reports_failure!("Server::panes", server.panes()); + assert_reports_failure!("Server::clients", server.clients()); + assert_reports_failure!("Server::attached_sessions", server.attached_sessions()); + assert_reports_failure!("Session::windows", session.windows()); + assert_reports_failure!("Session::panes", session.panes()); + assert_reports_failure!("Session::search_windows", session.search_windows(windows)); + assert_reports_failure!("Window::panes", window.panes()); + assert_reports_failure!("Window::search_panes", window.search_panes(panes)); + assert_reports_failure!("Window::linked_sessions", window.linked_sessions()); +} diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index 8e5b3a64..469880c8 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -1795,7 +1795,7 @@ async fn respawning_and_locking_reach_every_level_tmux_offers() { // every level rather than reporting it as a failure. server.lock_all().await.expect("the server locks"); session.lock().await.expect("the session locks"); - for client in server.clients_or_empty().await { + for client in server.clients().await.unwrap_or_default() { client.lock().await.expect("the client locks"); } diff --git a/crates/libtmux/tests/plan.rs b/crates/libtmux/tests/plan.rs index 90825e8a..19464f40 100644 --- a/crates/libtmux/tests/plan.rs +++ b/crates/libtmux/tests/plan.rs @@ -277,7 +277,7 @@ async fn an_invalid_plan_refuses_before_its_first_mutation() { .await .expect_err("the plan is invalid"); assert!( - server.sessions_or_empty().await.is_empty(), + server.sessions().await.unwrap_or_default().is_empty(), "validation happened after a mutation", ); assert_eq!(failure.kind(), libtmux::ErrorKind::InvalidInput); From 13ef9caf596b4f74808e7ed30cc49b83f76ef2d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 18:58:34 -0500 Subject: [PATCH 045/117] Server(add[buffers]): Let tmux read and write a buffer file itself `set_buffer` carries its data as a command argument, so it inherits the kernel's ceiling on one: `MAX_ARG_STRLEN`, 128 KiB on Linux. Measured on tmux 3.7d, a 200 KB `set-buffer` fails with "argument list too long" before tmux sees it, while `load-buffer` of the same bytes from a file succeeds. There was no way past that ceiling through the typed API. `load_buffer` and `save_buffer` hand tmux the path and let it do the I/O. tmux reads and writes as the user running the server, which is documented on both. Also decides the six capability rows `parity.md` still called `planned` while offering nothing. One is now implemented, above. The other five are `excluded` with the reason rather than left reading as intentions: `if-shell` is a conditional Rust already expresses; `show-messages` returns log wording tmux does not keep stable; `list-commands` enumerates a table nothing here dispatches by name from; `confirm-before` asks a person whose answer never returns to the caller; `attach` takes over the calling terminal, which is why `new_session` always passes -d. Shown capable of failing by dropping `-b` from `load_buffer`: the named buffer reads back `None`. That failure printed 200 KB of byte literals, so the assertions now compare length first and say "the bytes differ". --- crates/libtmux/docs/parity.md | 14 +++--- crates/libtmux/docs/public-api.txt | 2 + crates/libtmux/src/server.rs | 65 ++++++++++++++++++++++++++ crates/libtmux/tests/server_command.rs | 56 ++++++++++++++++++++++ 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index b5036ea7..4be59615 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -526,22 +526,22 @@ Sources: `src/libtmux/server.py`, `docs/api/libtmux.server.md`, | `wait_for` | Unit; default can block indefinitely. Lock, unlock, and set actions are independent booleans without mutual-exclusion validation. | `Server::signal_channel`, `lock_channel`, `unlock_channel`, and `wait_for_channel` cover `wait-for -S`, `-L`, `-U`, and the flagless blocking form. The wait is bounded rather than indefinite, and running out of time is `ChannelWait::TimedOut` rather than an error, so a caller separates it from a failure to reach tmux. | Options, hooks, and advanced command families | `implemented` | | `bind_key`, `unbind_key` | Unit; stderr raises. `unbind_key(None, all_keys=False)` is forwarded for tmux to reject. | `Server::bind_key` and `Server::unbind_key`, both taking the key table by name. | Options, hooks, and advanced command families | `implemented` | | `list_keys` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | `Server::key_bindings` lists default or table-scoped bindings. The tmux 3.7 format selector is not exposed. | Options, hooks, and advanced command families | `in progress` | -| `list_commands` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | Loud raw listing method; not written. `Server::cmd` reaches `list-commands` meanwhile. | Options, hooks, and advanced command families | `planned` | +| `list_commands` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | Not offered. `list-commands` enumerates the running tmux's command table as raw strings; nothing here dispatches by name at runtime, so the answer would be documentation rather than API. `Server::cmd` reaches it, which is also where a caller would use it. | Options, hooks, and advanced command families | `excluded` | | `lock_server`, `start_server` | Unit; stderr raises. Lock requires an attached client. | `Server::lock_all` and `Server::start`; covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | `server_access` | Returns access-rule lines only in list mode, otherwise `None`. Hard error below 3.3; read-only plus write raises `ValueError`. | `access_rules()`, `grant_access(user, AccessMode)`, and `revoke_access(user)`, all refusing below tmux 3.3 with `Error::UnsupportedCapability`. `AccessMode` is an enum rather than two flags, so tmux's exclusive `-r`/`-w` pair cannot both be passed: the contradiction Python raises `ValueError` for is unrepresentable. Covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | | `refresh_client`, `suspend_client`, `lock_client` | Unit and requires a usable client. Clipboard refresh requires 3.7 and is warn-ignore below. | `Client::redraw`, `Client::suspend`, and `Client::lock`, with capability errors; covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | `detach_client`, `detach_all_clients` | Unit. Detaches one named or most-recent client, or all other clients while preserving one. | `Session::detach_clients` detaches every client attached to one session; there is no form that spares the caller's own. | Object mutations and interactions | `implemented` | -| `confirm_before` | Unit and always background. Hard minimum 3.3; confirmation key and default-yes require 3.4 and are warn-ignore below. | Not offered. `confirm-before` asks a person at a terminal, which a library has no way to do on its own initiative. | Options, hooks, and advanced command families | `planned` | +| `confirm_before` | Unit and always background. Hard minimum 3.3; confirmation key and default-yes require 3.4 and are warn-ignore below. | Not offered. `confirm-before` asks a person at a terminal, which a library has no way to do on its own initiative, and whose answer never returns to the caller. | Options, hooks, and advanced command families | `excluded` | | `command_prompt` | Unit and always background. Hard minimum 3.3; literal input requires 3.6; backspace-exit and no-freeze require 3.7; unsupported flags warn and disappear. | `Server::command_prompt` takes typed prompt modes and checks capability floors. | Options, hooks, and advanced command families | `implemented` | | `display_menu` | Unit; requires a TTY-backed client. Choice and style flags require 3.4, mouse 3.5, and are warn-ignore below. `stay_open` is forwarded without its documented preflight. | `Server::display_menu` takes structured menu items and applies centralized capability validation. | Options, hooks, and advanced command families | `implemented` | -| `show_messages` | Zero or more raw strings; stderr raises. Default mode requires a current client. | `Server::cmd` can reach `show-messages`, but no typed message-log wrapper exists. | Options, hooks, and advanced command families | `planned` | +| `show_messages` | Zero or more raw strings; stderr raises. Default mode requires a current client. | Not offered. `show-messages` returns the server's own log lines, whose wording is not a contract and changes between releases, so a typed reader would promise a shape tmux does not keep. `Server::cmd` reaches it for diagnostics. | Options, hooks, and advanced command families | `excluded` | | `display_message` | Returns lines only when `get_text=True`, otherwise `None`. Stderr emits `UserWarning` instead of raising. `no_expand` requires 3.4 and is warn-ignore below. | `Server::format`, `Session::format`, `Window::format`, and `Pane::format` read expanded text; each handle's `display` method shows it with loud errors. | Options, hooks, and advanced command families | `implemented` | | `show_prompt_history`, `clear_prompt_history` | Zero or more strings or unit; hard minimum 3.3; stderr raises. | `prompt_history(PromptKind)` and `clear_prompt_history()`, both refusing below tmux 3.3 with `Error::UnsupportedCapability`. The kind is required rather than defaulted: asking without one returns every kind at once, which is a different question. Verified against 3.2a, 3.3a, and 3.7b; covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | | `set_buffer`, `show_buffer`, `delete_buffer` | Unit, one newline-joined string, or unit; stderr raises. Joining loses whether a final newline existed. | `Server::set_buffer`, `Server::buffer`, and `Server::delete_buffer` preserve bytes rather than newline-joining text. | Options, hooks, and advanced command families | `implemented` | | `list_buffers` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Server::buffer_names` lists names. Typed metadata and filter queries are not exposed. | Options, hooks, and advanced command families | `in progress` | -| `save_buffer`, `load_buffer` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Path` arguments; not written. `Server::cmd` reaches `save-buffer` and `load-buffer` meanwhile. | Options, hooks, and advanced command families | `planned` | +| `save_buffer`, `load_buffer` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Server::load_buffer` and `Server::save_buffer`. They exist for the ceiling `Server::set_buffer` cannot clear: data carried as a command argument stops at `MAX_ARG_STRLEN`, 128 KiB on Linux, and a larger buffer fails with "argument list too long" before tmux sees it. tmux opens the file itself here. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | | `source_file` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | `Server::source_file` is a loud typed request for enqueued source-file work. | Options, hooks, and advanced command families | `implemented` | -| `if_shell` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | Typed request representing enqueued state; not written. `Server::cmd` reaches `if-shell` meanwhile. | Options, hooks, and advanced command families | `planned` | +| `if_shell` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | Not offered. `if-shell` decides in tmux which tmux command to run next, so a typed wrapper would be a second, worse place to write a conditional that Rust already expresses: read the condition, then dispatch. Its background form reports only that the work was enqueued, which is the same promise `Server::spawn_shell` already makes honestly. `Server::cmd` reaches it. | Options, hooks, and advanced command families | `planned` | | `list_clients` | Zero or more raw client lines with loud errors, overlapping the typed lenient `clients` property. | Typed `Server::clients` is the whole of it; no raw line form is offered. | Options, hooks, and advanced command families | `implemented` | | `switch_client`, `attach_session` | Unit; validate session names. Despite an optional annotation, `attach_session(None)` always raises `BadSessionName`. | Require a typed `SessionTarget`. | Object mutations and interactions | `implemented` | | `__eq__`, `__repr__` | Equality compares only socket name and path, so default-server handles compare equal. | Equality and hashing over normalized `ServerIdentity`; sanitized `Debug`; covered by [`tests/server_command.rs`](../tests/server_command.rs). | Foundation | `implemented` | @@ -568,7 +568,7 @@ Sources: `src/libtmux/session.py`, `docs/api/libtmux.session.md`, | `last_window`, `next_window`, `previous_window`, `select_window` | Exactly one newly active Window. Command errors and zero or multiple active-window errors propagate. | `Session::next_window`, `Session::previous_window`, and `Session::last_window` return `Option`; `Window::select` covers `select_window`; covered by [`tests/hierarchy.rs`](../tests/hierarchy.rs). | Object mutations and interactions | `implemented` | | `active_window` | Exactly one Window or `NoActiveWindow`/`MultipleActiveWindows`. | `Session::active_window` returns the one active window loudly. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `active_pane` | Zero or one Pane after resolving the active Window; active-window cardinality errors propagate. | `Session::active_window` followed by `Window::active_pane` returns zero or one pane. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `attach` | Returns the same Session; blocks; stderr raises. A flag string is incorrectly expanded character by character. | `attach(AttachOptions) -> Result<()>` with typed client flags. | Object mutations and interactions | `planned` | +| `attach` | Returns the same Session; blocks; stderr raises. A flag string is incorrectly expanded character by character. | Not offered. Attaching takes over the calling terminal, which a library must not do on its caller's behalf -- the same reason `Server::new_session` always passes `-d`. The parts a program can use are `Client::switch_to` and `Session::detach_clients`. | Object mutations and interactions | `excluded` | | `kill` | Unit; stderr raises. Group kill requires 3.7 and is warn-ignore below. | `Session::kill`, consuming the handle; covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | `switch_client`, `rename_session` | Return the same Session. Rename validates, executes, then refreshes; refresh can fail after the rename succeeded. | `Session::rename` refreshes the handle and reports a post-rename refresh failure distinctly; `Client::switch_to` moves a client. Covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | | `new_window` | Exactly one Window. Actual default `attach=False` creates it detached despite contradictory prose. Direction, target, and index options can overlap. | `Session::new_window` takes `NewWindowOptions` with one placement enum and explicit selection. | Object mutations and interactions | `implemented` | @@ -649,7 +649,7 @@ Sources: `src/libtmux/pane.py`, `docs/api/libtmux.pane.md`, | `break_pane` | Exactly one Window; stderr raises. Raw version exactly 3.7 receives a placeholder-name workaround before an optional rename; 3.7a and later do not. | `Pane::break_out`, with the exact raw-version workaround selected by capabilities. | Object mutations and interactions | `implemented` | | `swap` | Unit; invalid option combinations raise `LibTmuxException`; stderr raises; snapshots are not refreshed. | `Pane::swap_with` takes another pane and refreshes both handles. | Object mutations and interactions | `implemented` | | `clear_history` | Unit; stderr raises. Hyperlink reset requires 3.4 and is warn-ignore below. | `Pane::clear_history` clears history loudly. The hyperlink-reset option is not exposed. | Object mutations and interactions | `in progress` | -| `clear`, `reset` | Return the same Pane. `clear` sends literal `reset` plus Enter; `reset` sends a semicolon chain. Neither checks command errors. | No dedicated clear or reset method exists; callers can compose `Pane::send_keys` explicitly. | Object mutations and interactions | `planned` | +| `clear`, `reset` | Return the same Pane. `clear` sends literal `reset` plus Enter; `reset` sends a semicolon chain. Neither checks command errors. | Not offered. Python's `clear` sends the literal text `reset` and its `reset` sends an escape sequence, so the two names are crossed at the source; composing `Pane::send_line` says which one is meant. `Pane::clear_history` covers the scrollback, which is the part with no keystroke equivalent. | Object mutations and interactions | `excluded` | | `id`, `index`, `height`, `width`, `title` | Synchronous raw snapshot strings or `None`. | `Pane::id`, `Pane::index`, `Pane::height`, `Pane::width`, and `Pane::title` are typed synchronous getters. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `at_top`, `at_bottom`, `at_left`, `at_right` | Synchronous bools produced by comparing the raw snapshot value with `"1"`. | `Pane::is_at_top`, `Pane::is_at_bottom`, `Pane::is_at_left`, and `Pane::is_at_right` are typed bool getters. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `__eq__`, `__repr__` | Equality compares only Pane ID, ignoring Server identity. | `Pane` equality and hashing use `(ServerIdentity, PaneId)`. | Discovery, traversal, refresh, and environment resolution | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index f4d169fd..42d7a135 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -525,6 +525,7 @@ function libtmux::Server::identity: fn(&self) -> &libtmux::ServerIdentity function libtmux::Server::is_alive: async fn(&self) -> bool function libtmux::Server::key_bindings: async fn(&self, table: Option<&str>) -> Result, libtmux::Error> function libtmux::Server::kill: async fn(&self) -> Result<(), libtmux::Error> +function libtmux::Server::load_buffer: async fn(&self, name: Option<&str>, path: impl AsRef) -> Result<(), libtmux::Error> function libtmux::Server::lock_all: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Server::lock_channel: async fn(&self, channel: &str) -> Result<(), libtmux::Error> function libtmux::Server::new: fn() -> Result @@ -539,6 +540,7 @@ function libtmux::Server::require_generation: async fn(&self, expected: libtmux: function libtmux::Server::resolved_tmux_executable: fn(&self) -> Option function libtmux::Server::revoke_access: async fn(&self, user: &str) -> Result<(), libtmux::Error> function libtmux::Server::run_shell: async fn(&self, command: impl Into) -> Result, libtmux::Error> +function libtmux::Server::save_buffer: async fn(&self, name: Option<&str>, path: impl AsRef) -> Result<(), libtmux::Error> function libtmux::Server::session: async fn(&self, name: impl AsRef<[u8]>) -> Result, libtmux::Error> function libtmux::Server::session_by_id: async fn(&self, id: &libtmux::SessionId) -> Result, libtmux::Error> function libtmux::Server::sessions: async fn(&self) -> Result, libtmux::Error> diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 12117dc5..e571441f 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -911,6 +911,71 @@ impl Server { listing::mutate(&self.core, "set-buffer", command.sensitive_arg(data.into())).await } + /// Fill a paste buffer from a file, letting tmux read it. + /// + /// [`Self::set_buffer`] carries the data as a command argument, which the + /// kernel caps: on Linux a single argument stops at `MAX_ARG_STRLEN`, + /// 128 KiB, and a larger one fails with "argument list too long" before + /// tmux sees it. tmux opens the file itself here, so size is the file + /// system's problem rather than the command line's. + /// + /// `path` is tmux's to resolve, and tmux reads it as the user running the + /// server, which is not necessarily this process. + /// + /// # Errors + /// + /// Returns an error when tmux cannot read the path. + pub async fn load_buffer( + &self, + name: Option<&str>, + path: impl AsRef, + ) -> Result<(), Error> { + let mut command = Command::new("load-buffer"); + if let Some(name) = name { + command = command.arg("-b").arg(OsString::from(name)); + } + + listing::mutate( + &self.core, + "load-buffer", + command + .arg("--") + .arg(path.as_ref().as_os_str().to_os_string()), + ) + .await + } + + /// Write a paste buffer to a file, letting tmux do the writing. + /// + /// The counterpart to [`Self::load_buffer`], and the same reasoning: the + /// bytes never cross a command line, so a buffer larger than an argument + /// can hold still round-trips. tmux writes as the user running the + /// server. + /// + /// # Errors + /// + /// Returns an error when the buffer is missing or tmux cannot write the + /// path. + pub async fn save_buffer( + &self, + name: Option<&str>, + path: impl AsRef, + ) -> Result<(), Error> { + let mut command = Command::new("save-buffer"); + if let Some(name) = name { + command = command.arg("-b").arg(OsString::from(name)); + } + + listing::mutate( + &self.core, + "save-buffer", + command + .arg("--") + .arg(path.as_ref().as_os_str().to_os_string()), + ) + .await + } + /// Read a paste buffer's exact bytes. /// /// Returns `None` when no buffer has that name. Buffer contents are diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index 866c7a4b..ebaf0093 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -1210,3 +1210,59 @@ async fn a_channel_lock_is_released_when_the_body_fails() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_buffer_larger_than_an_argument_round_trips_through_files() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let scratch = tempfile::tempdir().expect("a scratch directory"); + let source = scratch.path().join("in"); + let written = scratch.path().join("out"); + + // Past `MAX_ARG_STRLEN`, so `set_buffer` cannot carry it: the kernel + // refuses the argument before tmux sees it. + let payload = vec![b'x'; 200_000]; + fs::write(&source, &payload).expect("the source is written"); + + assert!( + server + .set_buffer(Some("big"), os_string_from_bytes(&payload)) + .await + .is_err(), + "a 200 KiB argument does not fit on a command line", + ); + + server + .load_buffer(Some("big"), &source) + .await + .expect("tmux reads the file"); + // Compared by shape rather than by value: a failed `assert_eq!` on the + // whole payload prints 200 KB of `120,` and buries the reason. + let loaded = server.buffer("big").await.expect("the buffer reads"); + assert_eq!(loaded.as_ref().map(Vec::len), Some(payload.len())); + assert_eq!( + loaded.as_deref(), + Some(payload.as_slice()), + "the bytes differ" + ); + + server + .save_buffer(Some("big"), &written) + .await + .expect("tmux writes the file"); + let read_back = fs::read(&written).expect("the output is readable"); + assert_eq!(read_back.len(), payload.len()); + assert!(read_back == payload, "the bytes differ"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[cfg(feature = "test-support")] +fn os_string_from_bytes(bytes: &[u8]) -> OsString { + use std::os::unix::ffi::OsStringExt as _; + OsString::from_vec(bytes.to_vec()) +} From b140e514757ab3a48ea5e419e7d282cc0f9fc335 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:05:02 -0500 Subject: [PATCH 046/117] Docs(add[timeouts]): Show the per-call deadline a caller already has The review asked for a per-command timeout API. There is one, spelled `tokio::time::timeout`: dropping the command future is what signals the process group and reaps it, so wrapping a call bounds it and cleans up without a second knob on `ServerBuilder`. That was asserted in the README's cancellation section without being shown, so it is now a compiled example and a test: a 400ms bound ends a three-second `run-shell` well inside the server's 30-second default, and the server still answers afterwards. Declining the API rather than adding it, recorded here so the question is not reopened from the same evidence. --- crates/libtmux/README.md | 29 ++++++++++++++++++++++ crates/libtmux/tests/server_command.rs | 34 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 58e1b122..bd7af33a 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -550,6 +550,35 @@ handshake and each command response use the same default timeout; a response's deadline starts before its line is written and ends with the complete block. Dropping both handles terminates and reaps the connection. +A caller that wants a shorter deadline for one command does not need an API +for it. Dropping the future is what signals the group, so wrapping the call is +a per-call deadline with the cleanup already attached: + +```rust +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let guard = libtmux::test::TestServer::new().await?; + let server = guard.server(); + + // Ends at 400ms rather than the server's 30-second default, and the + // command's process group is signalled and reaped on the way out. + let bounded = tokio::time::timeout( + Duration::from_millis(400), + server.run_shell("sleep 3"), + ) + .await; + assert!(bounded.is_err()); + + // The server is still usable: a bounded call is not a broken connection. + server.sessions().await?; + + guard.shutdown().await?; + Ok(()) +} +``` + `Server::shutdown()` is shared by all clones: it cancels active subprocess work and persistent control connections, rejects later commands and attaches, and is safe to call concurrently or repeatedly. Await it, or await your diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index ebaf0093..8f97e9c2 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -1266,3 +1266,37 @@ fn os_string_from_bytes(bytes: &[u8]) -> OsString { use std::os::unix::ffi::OsStringExt as _; OsString::from_vec(bytes.to_vec()) } + +/// A caller who wants a deadline shorter than the server's default does not +/// need an API for it: dropping the command future signals the process group +/// and reaps it, so `tokio::time::timeout` is a per-call deadline with the +/// cleanup already attached. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_caller_can_bound_one_command_with_tokio_timeout() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + + // `run-shell` blocks in tmux for as long as the command does, and the + // server's own default timeout is 30 seconds, so reaching this deadline + // is the caller's bound rather than the crate's. + let started = Instant::now(); + let outcome = + tokio::time::timeout(Duration::from_millis(400), server.run_shell("sleep 3")).await; + + assert!(outcome.is_err(), "the caller's deadline ended the wait"); + assert!( + started.elapsed() < Duration::from_secs(5), + "it ended at the caller's deadline, not the server's: {:?}", + started.elapsed(), + ); + + // The server is still usable afterwards: a bounded call is not a broken + // connection. + server.sessions().await.expect("the server still answers"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From e8b2f04bc03d206ec0b61dea3c7479ffcd235fb3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:07:45 -0500 Subject: [PATCH 047/117] Docs(design): Record that a tokio dependency is a non-goal to remove The crate requires tokio and offers no runtime abstraction, which was true but unstated, so it read as an omission rather than a decision. It is a decision: the crate spawns and supervises child processes, bounds every dispatch with a timer, and multiplexes a control connection, so it needs a runtime's process reaping and timers rather than only its executor. Abstracting the parts that differ would mean a lowest-common-denominator transport or a second one to keep in step. The `blocking::Runtime::run` panic follows from the same fact and is already covered: `try_run` reports `RuntimeNested` instead, and tests/blocking.rs pins it. --- crates/libtmux/docs/design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 3ab4ac05..d719626c 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -63,6 +63,16 @@ object APIs. - Reproducing Python's `QueryList` type, its equality and lookup-suffix defects, stale fields, or cross-server identity defects. - A process-global engine or operation registry. +- Runtime independence. `tokio` is a required dependency, not an optional one: + the crate spawns and supervises child processes, bounds every dispatch with a + timer, and multiplexes a control connection, so it needs a runtime's process + reaping and timers rather than only its executor. `async-std` and `smol` + offer no compatible child supervision, and abstracting over the parts that + differ would mean either a lowest-common-denominator transport or a second + one to keep in step. `blocking::Runtime` is how code that is not async calls + in; it is a `tokio` current-thread runtime, so entering it from inside + another runtime is a panic rather than a nested executor -- use + `blocking::Runtime::try_run` where that is possible. ## Compatibility contract From c011dd966d2f13bf8d54ebbca95150622dbab94b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:20:57 -0500 Subject: [PATCH 048/117] API(fix[signatures]): Name what a respawn kills and what a menu item is `respawn(Some("sh"), true)` says nothing about what the `true` does, and what it does is kill a running process: tmux refuses the respawn while the old command is alive unless `-k` is given. Every call site in this workspace passed `true`, so the literal carried the dangerous meaning and never once carried a decision. `Respawn::{Replacing, OnlyIfDead}` now does. `display_menu` took `(String, String, String)` triples, which compile in any order and read the same in any order. `MenuItem::new(label, key, command)` names the parts. Both are alpha breaks recorded in the changelog and the migration notes. --- crates/libtmux/docs/migration.md | 16 ++++++ crates/libtmux/docs/parity.md | 1 + crates/libtmux/docs/public-api.txt | 25 ++++++++- crates/libtmux/src/lib.rs | 6 +- crates/libtmux/src/pane.rs | 8 ++- crates/libtmux/src/server.rs | 1 + crates/libtmux/src/server/interactive.rs | 70 +++++++++++++++++++++--- crates/libtmux/src/window.rs | 23 +++++++- crates/libtmux/tests/commands.rs | 4 +- crates/libtmux/tests/mutations.rs | 13 +++-- crates/tmux-mcp/src/tools/contract.rs | 7 ++- crates/tmux-mcp/src/tools/observe.rs | 2 +- crates/tmux-mcp/tests/agent.rs | 11 ++-- 13 files changed, 155 insertions(+), 32 deletions(-) diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index 010e8e0f..c287d701 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -1,5 +1,21 @@ # Migrating from 0.1.0-alpha.11 +## `respawn` and `display_menu` take types, not literals + +```no_run +# async fn respawn(pane: &mut libtmux::Pane) -> Result<(), libtmux::Error> { +// was: pane.respawn(Some("sh"), true) +pane.respawn(Some("sh"), libtmux::Respawn::Replacing).await?; +# Ok(()) +# } +``` + +`Respawn::OnlyIfDead` is the `false` case, and it is the one worth checking +for: tmux refuses the respawn while the old command is alive. + +`Server::display_menu` takes `MenuItem::new(label, key, command)` in place of +a `(String, String, String)` triple. + ## The `_or_empty` listing twins are gone Replace `x_or_empty().await` with `x().await.unwrap_or_default()`: diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 4be59615..70d8d123 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -716,6 +716,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | `new_session` cannot set environment variables for the process it starts, though `new_window` and `split_window` can. | `NewSessionOptions::environment`, matching `NewWindowOptions` and `SplitOptions`. tmux has taken `new-session -e` since 3.2, below this crate's floor, so it needs no version gate. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | Reaching a pane's session means reading `session_id` and looking it up. | `Pane::session`, mirroring `Window::session`: it re-reads tmux, so a session renamed since discovery reports as it is now. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | A `wait-for` lock is taken and released by two calls, so a locker that returns early leaves the channel held and every later locker blocked. | `Server::with_channel_lock` pairs them, releasing on the error path as the other `with_*` scopes do. The queued-drop wedge is a tmux defect (`cmd-wait-for.c`) and stays documented on `Server::lock_channel`. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | +| A respawn's kill flag is a positional boolean, and a menu item is a string triple. | `Respawn::{Replacing, OnlyIfDead}` on `Pane::respawn` and `Window::respawn`, and `MenuItem` on `Server::display_menu`: a literal that decides whether a running process is killed, and three strings that compile in any order, both say what they mean at the call site. Covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | | An unset option and an unknown option are one failure. | `Server::typed_option` reports `None` for unset built-ins and absent user options; the `@` prefix determines which rule applies. | Options, hooks, and advanced command families | `implemented` | | Creating an object returns a handle that must be looked up again to be useful. | `Server::new_session`, `Session::new_window`, and `Pane::split` hydrate handles from each creating command's `-P -F` output in one round trip. | Object mutations and interactions | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 42d7a135..b9db22ad 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -265,6 +265,7 @@ enum libtmux::Principal enum libtmux::PromptKind enum libtmux::ReplaceMode enum libtmux::ResizeDirection +enum libtmux::Respawn enum libtmux::Rotation enum libtmux::ScopeError enum libtmux::ServerConfigurationErrorKind @@ -374,6 +375,10 @@ function libtmux::Layout::as_str: const fn(self) -> &'static str function libtmux::Layout::minimum_release: const fn(self) -> libtmux::ReleaseVersion function libtmux::ListingDecodeError::field_name: const fn(&self) -> Option<&'static str> function libtmux::ListingDecodeError::row: const fn(&self) -> Option +function libtmux::MenuItem::command: fn(&self) -> &OsStr +function libtmux::MenuItem::key: fn(&self) -> &OsStr +function libtmux::MenuItem::label: fn(&self) -> &OsStr +function libtmux::MenuItem::new: fn(label: impl Into, key: impl Into, command: impl Into) -> Self function libtmux::NewSessionOptions::command: fn(self, command: impl Into) -> Self function libtmux::NewSessionOptions::environment: fn(self, name: impl Into, value: impl Into) -> Self function libtmux::NewSessionOptions::new: fn(name: impl Into) -> Self @@ -440,7 +445,7 @@ function libtmux::Pane::refresh: async fn(&mut self) -> Result<&mut Self, libtmu function libtmux::Pane::refreshed: async fn(&self) -> Result function libtmux::Pane::resize: async fn(&mut self, width: u32, height: u32) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::resize_by: async fn(&mut self, direction: libtmux::ResizeDirection, cells: u32) -> Result<&mut Self, libtmux::Error> -function libtmux::Pane::respawn: async fn(&mut self, command: Option>, kill: bool) -> Result<&mut Self, libtmux::Error> +function libtmux::Pane::respawn: async fn(&mut self, command: Option>, respawn_mode: libtmux::Respawn) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::select: async fn(&mut self) -> Result<&mut Self, libtmux::Error> function libtmux::Pane::send_key_names: async fn(&self, keys: I) -> Result<(), libtmux::Error> where I: IntoIterator, K: Into function libtmux::Pane::send_keys: async fn(&self, keys: impl Into) -> Result<(), libtmux::Error> @@ -502,7 +507,7 @@ function libtmux::Server::command_prompt: async fn(&self, client: Option<&libtmu function libtmux::Server::config_file: fn(&self) -> Option<&Path> function libtmux::Server::default_timeout: fn(&self) -> Duration function libtmux::Server::delete_buffer: async fn(&self, name: &str) -> Result<(), libtmux::Error> -function libtmux::Server::display_menu: async fn(&self, client: Option<&libtmux::Client>, title: &str, items: impl IntoIterator) -> Result<(), libtmux::Error> +function libtmux::Server::display_menu: async fn(&self, client: Option<&libtmux::Client>, title: &str, items: impl IntoIterator) -> Result<(), libtmux::Error> function libtmux::Server::display_panes: async fn(&self, client: Option<&libtmux::Client>) -> Result<(), libtmux::Error> function libtmux::Server::display_popup: async fn(&self, client: Option<&libtmux::Client>, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::environment: async fn(&self, name: &str) -> Result, libtmux::Error> @@ -704,7 +709,7 @@ function libtmux::Window::refreshed: async fn(&self) -> Result) -> Result<&mut Self, libtmux::Error> function libtmux::Window::resize: async fn(&mut self, width: u32, height: u32) -> Result<&mut Self, libtmux::Error> function libtmux::Window::resize_by: async fn(&mut self, direction: libtmux::ResizeDirection, cells: u32) -> Result<&mut Self, libtmux::Error> -function libtmux::Window::respawn: async fn(&mut self, command: Option>, kill: bool) -> Result<&mut Self, libtmux::Error> +function libtmux::Window::respawn: async fn(&mut self, command: Option>, respawn_mode: libtmux::Respawn) -> Result<&mut Self, libtmux::Error> function libtmux::Window::rotate: async fn(&self, rotation: libtmux::Rotation) -> Result<(), libtmux::Error> function libtmux::Window::search_panes: async fn>(&self, matcher: M) -> Result, libtmux::Error> function libtmux::Window::select: async fn(&mut self) -> Result<&mut Self, libtmux::Error> @@ -999,6 +1004,7 @@ impl Clone for libtmux::JoinOptions impl Clone for libtmux::Layout impl Clone for libtmux::LayoutSpec impl Clone for libtmux::ListingDecodeError +impl Clone for libtmux::MenuItem impl Clone for libtmux::NewSessionOptions impl Clone for libtmux::NewWindowOptions impl Clone for libtmux::ObjectKind @@ -1021,6 +1027,7 @@ impl Clone for libtmux::ReleaseSuffix impl Clone for libtmux::ReleaseVersion impl Clone for libtmux::ReplaceMode impl Clone for libtmux::ResizeDirection +impl Clone for libtmux::Respawn impl Clone for libtmux::Rotation impl Clone for libtmux::Server impl Clone for libtmux::ServerConfigurationErrorKind @@ -1119,6 +1126,7 @@ impl Copy for libtmux::ReleaseSuffix impl Copy for libtmux::ReleaseVersion impl Copy for libtmux::ReplaceMode impl Copy for libtmux::ResizeDirection +impl Copy for libtmux::Respawn impl Copy for libtmux::Rotation impl Copy for libtmux::ServerConfigurationErrorKind impl Copy for libtmux::ServerGeneration @@ -1170,6 +1178,7 @@ impl Debug for libtmux::JoinOptions impl Debug for libtmux::Layout impl Debug for libtmux::LayoutSpec impl Debug for libtmux::ListingDecodeError +impl Debug for libtmux::MenuItem impl Debug for libtmux::NewSessionOptions impl Debug for libtmux::NewWindowOptions impl Debug for libtmux::ObjectKind @@ -1192,6 +1201,7 @@ impl Debug for libtmux::ReleaseSuffix impl Debug for libtmux::ReleaseVersion impl Debug for libtmux::ReplaceMode impl Debug for libtmux::ResizeDirection +impl Debug for libtmux::Respawn impl Debug for libtmux::Rotation impl Debug for libtmux::Server impl Debug for libtmux::ServerBuilder @@ -1328,6 +1338,7 @@ impl Eq for libtmux::JoinOptions impl Eq for libtmux::Layout impl Eq for libtmux::LayoutSpec impl Eq for libtmux::ListingDecodeError +impl Eq for libtmux::MenuItem impl Eq for libtmux::ObjectKind impl Eq for libtmux::OptionErrorKind impl Eq for libtmux::OptionKind @@ -1348,6 +1359,7 @@ impl Eq for libtmux::ReleaseSuffix impl Eq for libtmux::ReleaseVersion impl Eq for libtmux::ReplaceMode impl Eq for libtmux::ResizeDirection +impl Eq for libtmux::Respawn impl Eq for libtmux::Rotation impl Eq for libtmux::Server impl Eq for libtmux::ServerConfigurationErrorKind @@ -1459,6 +1471,7 @@ impl Hash for libtmux::Client impl Hash for libtmux::ErrorKind impl Hash for libtmux::IdParseError impl Hash for libtmux::Layout +impl Hash for libtmux::MenuItem impl Hash for libtmux::Pane impl Hash for libtmux::PaneDirection impl Hash for libtmux::PaneId @@ -1468,6 +1481,7 @@ impl Hash for libtmux::PaneWait impl Hash for libtmux::ReleaseSuffix impl Hash for libtmux::ReleaseVersion impl Hash for libtmux::ResizeDirection +impl Hash for libtmux::Respawn impl Hash for libtmux::Rotation impl Hash for libtmux::Server impl Hash for libtmux::ServerGeneration @@ -1534,6 +1548,7 @@ impl PartialEq for libtmux::JoinOptions impl PartialEq for libtmux::Layout impl PartialEq for libtmux::LayoutSpec impl PartialEq for libtmux::ListingDecodeError +impl PartialEq for libtmux::MenuItem impl PartialEq for libtmux::ObjectKind impl PartialEq for libtmux::OptionErrorKind impl PartialEq for libtmux::OptionKind @@ -1554,6 +1569,7 @@ impl PartialEq for libtmux::ReleaseSuffix impl PartialEq for libtmux::ReleaseVersion impl PartialEq for libtmux::ReplaceMode impl PartialEq for libtmux::ResizeDirection +impl PartialEq for libtmux::Respawn impl PartialEq for libtmux::Rotation impl PartialEq for libtmux::Server impl PartialEq for libtmux::ServerConfigurationErrorKind @@ -1787,6 +1803,7 @@ struct libtmux::EngineCapabilities struct libtmux::IdParseError struct libtmux::JoinOptions struct libtmux::ListingDecodeError +struct libtmux::MenuItem struct libtmux::NewSessionOptions struct libtmux::NewWindowOptions struct libtmux::OptionSchema @@ -2293,6 +2310,8 @@ variant libtmux::ResizeDirection::Down variant libtmux::ResizeDirection::Left variant libtmux::ResizeDirection::Right variant libtmux::ResizeDirection::Up +variant libtmux::Respawn::OnlyIfDead +variant libtmux::Respawn::Replacing variant libtmux::Rotation::Down variant libtmux::Rotation::Up variant libtmux::ScopeError::Cleanup diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 5d2dfd07..3fa4d979 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -362,8 +362,8 @@ pub use options::{ }; pub use pane::{CaptureOptions, CapturedLine, Pane, PaneWait}; pub use server::{ - AccessMode, AccessRule, ChannelWait, Chooser, NewSessionOptions, Principal, PromptKind, Server, - ServerBuilder, SessionTree, WindowTree, + AccessMode, AccessRule, ChannelWait, Chooser, MenuItem, NewSessionOptions, Principal, + PromptKind, Server, ServerBuilder, SessionTree, WindowTree, }; #[cfg(feature = "query")] pub use server::{SessionTreeFields, WindowTreeFields}; @@ -377,7 +377,7 @@ pub use target::{ }; pub use version::{ReleaseSuffix, ReleaseVersion, TmuxVersion, since}; pub use window::{ - JoinOptions, Layout, LayoutSpec, PaneDirection, PaneSize, ResizeDirection, Rotation, + JoinOptions, Layout, LayoutSpec, PaneDirection, PaneSize, ResizeDirection, Respawn, Rotation, SplitDirection, SplitOptions, Window, }; diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 84f5a895..5e1e04da 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -17,6 +17,7 @@ use crate::snapshot::PaneProjection; use crate::snapshot::{PaneFields, PaneInfo}; use crate::target::{PaneId, ServerIdentity, SessionId, WindowId}; use crate::version::TmuxVersion; +use crate::window::Respawn; use crate::window::Window; use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; @@ -963,16 +964,17 @@ impl Pane { /// /// # Errors /// - /// Returns an error when the pane is still running and `kill` is not set. + /// Returns an error under [`Respawn::OnlyIfDead`] when the command is + /// still running. pub async fn respawn( &mut self, command: Option>, - kill: bool, + respawn_mode: Respawn, ) -> Result<&mut Self, Error> { let mut respawn = Command::new("respawn-pane") .arg("-t") .arg(self.id().to_string()); - if kill { + if respawn_mode.kills() { respawn = respawn.arg("-k"); } if let Some(command) = command { diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index e571441f..294070d6 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -26,6 +26,7 @@ mod builder; mod channels; mod discovery; mod interactive; +pub use interactive::MenuItem; mod settings; pub use builder::ServerBuilder; pub use discovery::{SessionTree, WindowTree}; diff --git a/crates/libtmux/src/server/interactive.rs b/crates/libtmux/src/server/interactive.rs index 978ed277..2cd89de6 100644 --- a/crates/libtmux/src/server/interactive.rs +++ b/crates/libtmux/src/server/interactive.rs @@ -1,4 +1,4 @@ -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use super::{Chooser, Server}; use crate::client::Client; @@ -41,8 +41,7 @@ impl Server { /// Show a menu over a client. /// - /// Items are `(label, key, command)` triples in the order tmux should show - /// them. This needs a client with a terminal. + /// Items appear in the order given. This needs a client with a terminal. /// /// Like [`Self::command_prompt`], this waits for the person: tmux holds the /// invocation until an item is chosen or the menu is dismissed, and the @@ -57,7 +56,7 @@ impl Server { &self, client: Option<&Client>, title: &str, - items: impl IntoIterator, + items: impl IntoIterator, ) -> Result<(), Error> { let mut menu = Command::new("display-menu") .arg("-T") @@ -69,11 +68,8 @@ impl Server { .arg("-t") .arg(client.name().to_string_lossy().into_owned()); } - for (label, key, command) in items { - menu = menu - .arg(OsString::from(label)) - .arg(OsString::from(key)) - .arg(OsString::from(command)); + for item in items { + menu = menu.arg(item.label).arg(item.key).arg(item.command); } listing::mutate(&self.core, "display-menu", menu).await @@ -201,3 +197,59 @@ impl Server { listing::mutate(&self.core, "display-panes", request).await } } + +/// One line of a [`Server::display_menu`] menu. +/// +/// The three parts were a `(String, String, String)` triple, which reads the +/// same whichever order they are in and compiles whichever order they are in. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MenuItem { + label: OsString, + key: OsString, + command: OsString, +} + +impl MenuItem { + /// Show `label`, and run `command` when the person presses `key`. + /// + /// `key` is a tmux key name, or `-` for a line that cannot be chosen. + /// + /// # Examples + /// + /// ``` + /// use libtmux::MenuItem; + /// + /// let item = MenuItem::new("Kill the session", "k", "kill-session"); + /// assert_eq!(item.label(), "Kill the session"); + /// ``` + #[must_use] + pub fn new( + label: impl Into, + key: impl Into, + command: impl Into, + ) -> Self { + Self { + label: label.into(), + key: key.into(), + command: command.into(), + } + } + + /// The text tmux shows. + #[must_use] + pub fn label(&self) -> &OsStr { + &self.label + } + + /// The key that chooses this line. + #[must_use] + pub fn key(&self) -> &OsStr { + &self.key + } + + /// The tmux command this line runs. + #[must_use] + pub fn command(&self) -> &OsStr { + &self.command + } +} diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index bb86c78e..88f532f8 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -557,12 +557,12 @@ impl Window { pub async fn respawn( &mut self, command: Option>, - kill: bool, + respawn_mode: Respawn, ) -> Result<&mut Self, Error> { let mut respawn = Command::new("respawn-window") .arg("-t") .arg(self.id().to_string()); - if kill { + if respawn_mode.kills() { respawn = respawn.arg("-k"); } if let Some(command) = command { @@ -1411,6 +1411,25 @@ impl From<&TmuxText> for LayoutSpec { } } +/// What to do about a process still running where one is being respawned. +/// +/// tmux refuses `respawn-pane` and `respawn-window` outright while the old +/// command is alive unless `-k` says otherwise, so the choice is not a detail +/// -- it decides whether a live process is killed. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Respawn { + /// Kill whatever is running first. tmux's `-k`. + Replacing, + /// Refuse unless the pane or window is already dead. + OnlyIfDead, +} + +impl Respawn { + pub(crate) const fn kills(self) -> bool { + matches!(self, Self::Replacing) + } +} + /// The shape of a saved layout value, read before it reaches tmux. #[cfg_attr(test, derive(Debug, PartialEq))] enum SavedLayout { diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index 94deb4d6..c3a7dea7 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -621,7 +621,7 @@ async fn server_operations_reject_foreign_handles() { error_kind!(left.display_menu( Some(&foreign_client), "menu", - [("Item".into(), "i".into(), "display-message item".into())], + [libtmux::MenuItem::new("Item", "i", "display-message item")], )), error_kind!(left.command_prompt( Some(&foreign_client), @@ -1464,7 +1464,7 @@ async fn interactive_commands_need_a_client() { .display_menu( None, "menu", - [("Item".into(), "i".into(), "kill-pane".into())] + [libtmux::MenuItem::new("Item", "i", "kill-pane")] ) .await .is_err(), diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index 469880c8..232aa232 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -310,11 +310,16 @@ async fn flag_shaped_commands_and_paths_stay_literal() { ); stayed_literal( "respawn-pane", - pane.respawn(Some("-zzz-respawn"), true).await.err(), + pane.respawn(Some("-zzz-respawn"), libtmux::Respawn::Replacing) + .await + .err(), ); stayed_literal( "respawn-window", - window.respawn(Some("-zzz-respawn"), true).await.err(), + window + .respawn(Some("-zzz-respawn"), libtmux::Respawn::Replacing) + .await + .err(), ); guard.shutdown().await.expect("tmux fixture shuts down"); @@ -1782,7 +1787,7 @@ async fn respawning_and_locking_reach_every_level_tmux_offers() { assert_eq!(window.panes().await.expect("panes").len(), 2); window - .respawn(Some("sh"), true) + .respawn(Some("sh"), libtmux::Respawn::Replacing) .await .expect("the window restarts"); assert_eq!( @@ -1832,7 +1837,7 @@ async fn a_dead_panes_pid_is_absent_rather_than_a_decode_failure() { pane.set_option("remain-on-exit", "on") .await .expect("the dead pane is kept rather than closed"); - pane.respawn(Some("exit 0"), true) + pane.respawn(Some("exit 0"), libtmux::Respawn::Replacing) .await .expect("the command runs and exits"); diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index da1422e1..672a964c 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -890,7 +890,12 @@ impl TmuxTools { Parameters(RespawnArgs { pane, kill_first }): Parameters, ) -> Result, ToolError> { let mut pane = self.find_pane(&pane).await?; - pane.respawn(None::, kill_first) + let mode = if kill_first { + libtmux::Respawn::Replacing + } else { + libtmux::Respawn::OnlyIfDead + }; + pane.respawn(None::, mode) .await .map_err(|error| tmux_error(&error))?; let socket = self.socket(); diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index a3b8e053..d0cd885e 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -609,7 +609,7 @@ mod tests { "exec sleep 30" }; let pane_id = pane.id().clone(); - pane.respawn(Some(command), true) + pane.respawn(Some(command), libtmux::Respawn::Replacing) .await .expect("pane begins its final transition"); if transition == "dead" { diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index e8f12101..89941e1e 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -885,7 +885,7 @@ async fn send_keys_refuses_modal_and_dead_configured_members_before_input() { .expect("fixture retains a dead pane"); let mut peer_handle = peer_handle; peer_handle - .respawn(Some("exit 0"), true) + .respawn(Some("exit 0"), libtmux::Respawn::Replacing) .await .expect("fixture command exits"); libtmux::test::retry_until(Duration::from_secs(2), async || { @@ -1332,7 +1332,7 @@ async fn paste_text_is_target_only_and_guards_before_buffer_creation() { .await .expect("fixture retains a dead pane"); source_handle - .respawn(Some("exit 0"), true) + .respawn(Some("exit 0"), libtmux::Respawn::Replacing) .await .expect("fixture command exits"); libtmux::test::retry_until(Duration::from_secs(2), async || { @@ -1783,7 +1783,7 @@ async fn run_requires_a_known_posix_shell_before_watcher_setup() { let (guard, tools, pane) = typing_fixture("run-known-shell").await; let mut target = pane_handle(guard.server(), &pane).await; target - .respawn(Some("exec cat"), true) + .respawn(Some("exec cat"), libtmux::Respawn::Replacing) .await .expect("the pane enters an input-reading non-shell"); libtmux::test::retry_until(Duration::from_secs(2), async || { @@ -2563,7 +2563,10 @@ async fn run_frame_preserves_inherited_error_and_debug_traps() { let (guard, tools, pane) = typing_fixture(&format!("run-frame-traps-{shell_name}")).await; pane_handle(guard.server(), &pane) .await - .respawn(Some(&format!("exec {shell} {flags}")), true) + .respawn( + Some(&format!("exec {shell} {flags}")), + libtmux::Respawn::Replacing, + ) .await .expect("fixture pane changes shell"); libtmux::test::retry_until(Duration::from_secs(2), async || { From 8fc63311a418af81e411495951060aecac1ec5c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:35:20 -0500 Subject: [PATCH 049/117] Server(feat[transport]): Route the typed API over one connection `control-mode` opened a connection and left the object model behind it: `pane.send_keys()` forked tmux even with a connection attached, and the only typed route onto one was `plan`'s thirteen operations. tmux-mcp enables `control-mode` and not `plan`, and hand-builds `send-keys` lines, which is the shape of a bridge nobody can reach. `Server::over_control_mode(&sender)` returns a handle whose every typed call is written as one control-mode line and answered from its `%begin`/`%end` block. Handles reached through it inherit the route. The original server is unchanged, and stays the right one for a command that parks the client's queue -- `wait-for`, a foreground `run-shell` -- and for arguments that are not valid UTF-8, which a text protocol cannot carry. Three things this could not do naively, each settled rather than papered over: The rendered `argv` is not a control-mode line. It is lowered for `execve`, so a trailing `;` is already `\;` and the globals carry `-S `; re-reading it would double-escape and pass flags a connection has settled. A defaulted `Executor::renders_control_line` lets `Core::execute` render from the logical `Command` instead, and only for an executor that asks -- a subprocess dispatch pays nothing. `tmux -V` has no control-mode spelling: it is a client flag, not a command. So this is `async` and probes once through the parent's process transport, seeding the capability cell. That one process is the single line the proof's log expects rather than something hidden. A `ControlSender` carried no identity, so a sender for one server routed onto a handle for another would have talked to the wrong daemon in silence. It now carries `ServerIdentity` and this refuses a mismatch. Proof: `tests/control_mode_routing.rs` points `tmux_executable` at a stub that appends its argv to a log and exits 97 for anything but `-V`, so "no process spawned" is measured, not inferred -- a fallback would appear in the log and fail the call. The log stays at one line across a listing, a chain, a refusal, a `-f` filtered lookup, a capture on a killed pane, and a `send_line`. Shown capable of failing by returning the parent core: `exit_code: Some(97)`, "stub tmux was asked to run a command". Grafted from the winning contender of a three-way bakeoff; the ledger in .git/spike/rs-api-conventions.md records why the two trait-publishing shapes lost. --- crates/libtmux/README.md | 20 +- crates/libtmux/docs/parity.md | 1 + crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/command.rs | 69 +++++ crates/libtmux/src/control.rs | 46 +++- crates/libtmux/src/control/tests.rs | 12 + .../libtmux/src/internal/control_executor.rs | 82 ++++++ crates/libtmux/src/internal/core.rs | 46 +++- crates/libtmux/src/internal/executor.rs | 9 + crates/libtmux/src/internal/mod.rs | 2 + crates/libtmux/src/server.rs | 88 +++++- crates/libtmux/tests/control_mode_routing.rs | 258 ++++++++++++++++++ 12 files changed, 621 insertions(+), 13 deletions(-) create mode 100644 crates/libtmux/src/internal/control_executor.rs create mode 100644 crates/libtmux/tests/control_mode_routing.rs diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index bd7af33a..ff2394b4 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -294,7 +294,25 @@ table on your own machine. Control mode is never the default transport, and turning the feature on does not make it one: normal commands stay one process per command until you attach -a connection and use it. +a connection and use it. `Server::over_control_mode` is how you use it for +everything rather than command by command -- it returns a handle whose ordinary +typed calls travel down the connection, and handles reached through it inherit +the route: + +```text +let (commands, events) = ControlMode::attach(&server, session.id()).await?.split(); +let routed = server.over_control_mode(&commands).await?; + +// One connection, no processes: the whole typed API, not a plan's worth of it. +for pane in routed.panes().await? { + pane.send_line("echo hello").await?; +} +``` + +Keep the original server for the two things a connection is wrong for: a +command that parks the client's queue, such as `wait-for` or a foreground +`run-shell`, and arguments that are not valid UTF-8, which a text protocol +cannot carry. See `Server::over_control_mode` for the runnable version. ## When the typed API does not cover it diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 70d8d123..e6895021 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -717,6 +717,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | Reaching a pane's session means reading `session_id` and looking it up. | `Pane::session`, mirroring `Window::session`: it re-reads tmux, so a session renamed since discovery reports as it is now. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | A `wait-for` lock is taken and released by two calls, so a locker that returns early leaves the channel held and every later locker blocked. | `Server::with_channel_lock` pairs them, releasing on the error path as the other `with_*` scopes do. The queued-drop wedge is a tmux defect (`cmd-wait-for.c`) and stays documented on `Server::lock_channel`. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | | A respawn's kill flag is a positional boolean, and a menu item is a string triple. | `Respawn::{Replacing, OnlyIfDead}` on `Pane::respawn` and `Window::respawn`, and `MenuItem` on `Server::display_menu`: a literal that decides whether a running process is killed, and three strings that compile in any order, both say what they mean at the call site. Covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | +| Every command is one tmux process, and a persistent connection is reachable only by writing lines to it. | `Server::over_control_mode` routes the whole typed API over an attached `ControlSender`, so `sessions`, `send_keys`, `capture` and the option accessors answer from `%begin`/`%end` blocks instead of forking. Per-command attribution survives, because a block is per command. Covered by [`tests/control_mode_routing.rs`](../tests/control_mode_routing.rs), which proves no process is spawned by pointing `tmux_executable` at a stub that logs its argv. | Commands, transports, and control mode | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | | An unset option and an unknown option are one failure. | `Server::typed_option` reports `None` for unset built-ins and absent user options; the `@` prefix determines which rule applies. | Options, hooks, and advanced command families | `implemented` | | Creating an object returns a handle that must be looked up again to be useful. | `Server::new_session`, `Session::new_window`, and `Pane::split` hydrate handles from each creating command's `-P -F` output in one round trip. | Object mutations and interactions | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index b9db22ad..52ff78d4 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -537,6 +537,7 @@ function libtmux::Server::new: fn() -> Result function libtmux::Server::new_session: async fn(&self, options: impl Into) -> Result function libtmux::Server::option_names: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::options: async fn(&self) -> Result, libtmux::Error> +function libtmux::Server::over_control_mode: async fn(&self, sender: &libtmux::control::ControlSender) -> Result function libtmux::Server::owns_control_client: fn(&self, pid: u32) -> bool function libtmux::Server::pane_by_id: async fn(&self, id: &libtmux::PaneId) -> Result, libtmux::Error> function libtmux::Server::panes: async fn(&self) -> Result, libtmux::Error> diff --git a/crates/libtmux/src/command.rs b/crates/libtmux/src/command.rs index a321e105..e847c091 100644 --- a/crates/libtmux/src/command.rs +++ b/crates/libtmux/src/command.rs @@ -412,6 +412,27 @@ impl CommandChain { CommandSummary::from_parts(escape_diagnostic(&self.first.subcommand.value), arguments) } + /// Render the whole chain as one control-mode line. + /// + /// tmux parses a line, so the boundary between members is the bare `;` + /// its own parser reads. A member's literal `;` argument is quoted by + /// [`Command::control_mode_line`] and stays an argument. + /// + /// One line is one `%begin`/`%end` block, so a chain sent this way reports + /// one outcome for all of it -- the same trade a chained argv makes. + /// + /// Returns `None` when any token is not valid UTF-8. + #[cfg(feature = "control-mode")] + pub(crate) fn control_mode_line(&self) -> Option { + let mut line = self.first.control_mode_line()?; + for command in &self.rest { + line.push_str(" ; "); + line.push_str(&command.control_mode_line()?); + } + + Some(line) + } + /// Render the whole chain as one argv, separators included. fn into_argv(self, global_argv: &[OsString]) -> (Vec, usize) { let mut argv = Vec::with_capacity(global_argv.len() + 2 * self.command_count()); @@ -695,6 +716,21 @@ pub(crate) struct CommandRequest { command: CommandSummary, argv: Vec, logical_subcommand_index: usize, + /// The same logical command rendered for a control-mode connection. + /// + /// Carried beside the argv rather than derived from it: the argv holds + /// the server's global flags -- `-S `, `-f`, `-u` -- which an open + /// connection has already settled, and its tokens are lowered for + /// `execve`, where a trailing `;` is escaped as `\;`. Neither survives + /// re-rendering into a line tmux parses. + /// + /// `None` means either that no executor asked for a line or that a token + /// is not valid UTF-8, which a text protocol cannot carry. The one + /// executor that reads this field is only handed requests it asked to have + /// rendered, so for it the two cases are the same: absence is a command + /// control mode cannot express, and it says so rather than guessing. + #[cfg(feature = "control-mode")] + control_line: Option, } impl CommandRequest { @@ -723,6 +759,8 @@ impl CommandRequest { command: summary, argv, logical_subcommand_index, + #[cfg(feature = "control-mode")] + control_line: None, } } @@ -739,9 +777,24 @@ impl CommandRequest { command, argv, logical_subcommand_index, + #[cfg(feature = "control-mode")] + control_line: None, } } + /// Attach the control-mode rendering of the command this request carries. + #[cfg(feature = "control-mode")] + pub(crate) fn with_control_line(mut self, line: Option) -> Self { + self.control_line = line; + self + } + + /// Take the control-mode line, when one was rendered and representable. + #[cfg(feature = "control-mode")] + pub(crate) fn into_control_line(self) -> Option { + self.control_line + } + pub(crate) const fn request_id(&self) -> RequestId { self.request_id } @@ -785,6 +838,22 @@ impl ProcessStatus { } } + /// Report an outcome a transport observed without running a process. + /// + /// Control mode closes a block with `%end` or `%error` and never with an + /// exit status, and a test double never had one either. The code is + /// synthesized so callers that read one see the shape tmux would have + /// exited with, and it is stated here rather than invented at each call + /// site. + #[cfg(any(feature = "control-mode", feature = "test-support"))] + pub(crate) const fn from_block_outcome(succeeded: bool) -> Self { + Self { + success: succeeded, + code: Some(if succeeded { 0 } else { 1 }), + signal: None, + } + } + pub(crate) const fn success(self) -> bool { self.success } diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index aba7ea77..9849bcf7 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -488,15 +488,12 @@ impl ControlMode { commands, timeout, pane_off_is_safe, + identity: server.identity().clone(), }; - // Ask for JSON layouts before anything else can read one. tmux 3.8+ - // hands a control client the classic `window_layout` string it hands - // a plain client only after this flag is set, so `Event::LayoutChanged` - // and a snapshot taken through this connection would otherwise - // disagree with each other on format. A release below 3.8 has no such - // flag: `server_client_set_flags` skips a name it does not recognise, - // so this is a no-op there rather than a refusal. + // Without this, tmux 3.8+ hands a control client the classic + // `window_layout` string instead of JSON, disagreeing with a plain + // client's snapshot; a release below 3.8 ignores the unknown flag. sender .send(Command::new("refresh-client").arg("-f").arg("new-layouts")) .await? @@ -633,6 +630,12 @@ pub struct ControlSender { /// Read once at attach rather than per call: the server cannot change /// release under a connection. pane_off_is_safe: bool, + /// Which server this connection reaches. + /// + /// A sender says nothing about where it points, so routing one onto a + /// handle for a different server would silently talk to this one. + /// [`crate::Server::over_control_mode`] compares it and refuses. + identity: crate::ServerIdentity, } impl ControlSender { @@ -682,17 +685,44 @@ impl ControlSender { self.send_ordered(command, None).await } + /// Return the server this connection reaches. + pub(crate) const fn identity(&self) -> &crate::ServerIdentity { + &self.identity + } + /// Send a command whose completed block marks one point in event order. async fn send_ordered( &self, command: Command, boundary: Option, ) -> Result { - let deadline = Instant::now().checked_add(self.timeout); let sensitive_input = command.summary().sensitive_argument_count() > 0; let line = command .control_mode_line() .ok_or_else(Error::control_mode_unrepresentable)?; + self.dispatch_line(line, sensitive_input, boundary).await + } + + /// Send one already-rendered control-mode line. + /// + /// The typed API routes through here: a request built for dispatch carries + /// its own rendering, so re-deriving one from the argv is neither needed + /// nor correct. + pub(crate) async fn send_line( + &self, + line: String, + sensitive_input: bool, + ) -> Result { + self.dispatch_line(line, sensitive_input, None).await + } + + async fn dispatch_line( + &self, + line: String, + sensitive_input: bool, + boundary: Option, + ) -> Result { + let deadline = Instant::now().checked_add(self.timeout); let (result, mut answer) = oneshot::channel(); let (commit, mut commitment) = oneshot::channel(); let finish = |answer: Result, oneshot::error::RecvError>| { diff --git a/crates/libtmux/src/control/tests.rs b/crates/libtmux/src/control/tests.rs index a17e2690..7830e029 100644 --- a/crates/libtmux/src/control/tests.rs +++ b/crates/libtmux/src/control/tests.rs @@ -75,6 +75,9 @@ fn sender(commands: mpsc::Sender, timeout: Duration) -> ControlSender { commands, timeout, pane_off_is_safe: true, + identity: crate::ServerIdentity::from_socket_path(std::path::PathBuf::from( + "/tmp/libtmux-rs-test/control-sender", + )), } } @@ -124,6 +127,9 @@ async fn watch_only_marks_a_transport_failure_after_its_first_mute() { commands, timeout: Duration::from_secs(1), pane_off_is_safe: true, + identity: crate::ServerIdentity::from_socket_path(std::path::PathBuf::from( + "/tmp/libtmux-rs-test/control-sender", + )), }; let watch = tokio::spawn(async move { sender.watch_only(&[]).await }); @@ -175,6 +181,9 @@ async fn watch_only_refuses_a_failed_listing_before_muting_any_pane() { commands, timeout: Duration::from_secs(1), pane_off_is_safe: true, + identity: crate::ServerIdentity::from_socket_path(std::path::PathBuf::from( + "/tmp/libtmux-rs-test/control-sender", + )), }; let watch = tokio::spawn(async move { sender.watch_only(&[]).await }); @@ -461,6 +470,9 @@ async fn mute_pane_reports_a_control_error_block() { commands, timeout: Duration::from_secs(1), pane_off_is_safe: true, + identity: crate::ServerIdentity::from_socket_path(std::path::PathBuf::from( + "/tmp/libtmux-rs-test/control-sender", + )), }; let pane: PaneId = "%1".parse().expect("a pane id"); let mute = tokio::spawn(async move { sender.mute_pane(&pane).await }); diff --git a/crates/libtmux/src/internal/control_executor.rs b/crates/libtmux/src/internal/control_executor.rs new file mode 100644 index 00000000..3d46f933 --- /dev/null +++ b/crates/libtmux/src/internal/control_executor.rs @@ -0,0 +1,82 @@ +//! Dispatch onto a control-mode connection instead of a process. +//! +//! Control mode wraps every command in its own `%begin`/`%end` block, so a +//! connection keeps the one property that makes a process worth spawning: +//! each command reports its own outcome. That is what lets the typed API move +//! onto it whole rather than through a separate set of methods. + +use std::sync::Arc; + +use crate::command::{CommandRequest, CommandResult, ProcessStatus}; +use crate::control::ControlSender; +use crate::internal::executor::{DispatchFuture, Executor, ShutdownFuture}; + +/// Runs commands over a connection someone else opened. +pub(crate) struct ControlModeExecutor { + sender: Arc, +} + +impl ControlModeExecutor { + pub(crate) fn new(sender: ControlSender) -> Self { + Self { + sender: Arc::new(sender), + } + } +} + +impl Executor for ControlModeExecutor { + fn execute(&self, request: CommandRequest) -> DispatchFuture { + let sender = Arc::clone(&self.sender); + let request_id = request.request_id(); + let summary = request.summary().clone(); + let sensitive_input = summary.sensitive_argument_count() > 0; + let line = request.into_control_line(); + + DispatchFuture::new(async move { + let Some(line) = line else { + return Err(crate::Error::control_mode_unrepresentable()); + }; + let block = sender.send_line(line, sensitive_input).await?; + + // tmux prints a command's output inside the block, one line at a + // time, with the trailing newline that separated them removed. + // Putting it back is what makes the bytes identical to the + // stdout a process would have written, which every parser above + // this already reads. + let mut bytes = Vec::new(); + for line in block.output() { + bytes.extend_from_slice(line.as_bytes()); + bytes.push(b'\n'); + } + + let succeeded = block.succeeded(); + // A refused command prints its reason where a process would have + // put it: an `%error` block is stderr, not stdout, and error + // classification reads stderr. + let (stdout, stderr) = if succeeded { + (bytes, Vec::new()) + } else { + (Vec::new(), bytes) + }; + + Ok(CommandResult::new( + request_id, + summary, + ProcessStatus::from_block_outcome(succeeded), + stdout, + stderr, + )) + }) + } + + fn shutdown(&self) -> ShutdownFuture { + // The connection belongs to whoever attached it. Closing it from a + // routed handle would end a caller's event stream as a side effect of + // dropping a server object. + ShutdownFuture::new(async { Ok(()) }) + } + + fn renders_control_line(&self) -> bool { + true + } +} diff --git a/crates/libtmux/src/internal/core.rs b/crates/libtmux/src/internal/core.rs index 12fbea06..9e19890d 100644 --- a/crates/libtmux/src/internal/core.rs +++ b/crates/libtmux/src/internal/core.rs @@ -78,6 +78,7 @@ impl BuildContext { } } +#[derive(Clone)] pub(crate) struct CoreConfiguration { identity: ServerIdentity, socket_name: Option, @@ -363,10 +364,37 @@ impl Core { } } + /// Build a core that dispatches over an already-attached connection. + /// + /// The capabilities are carried across rather than re-probed: the version + /// probe is `tmux -V`, a client flag and not a command, so it has no + /// control-mode spelling at all. + #[cfg(feature = "control-mode")] + pub(crate) fn over_control_mode( + &self, + sender: crate::control::ControlSender, + capabilities: EngineCapabilities, + ) -> Self { + let executor = crate::internal::control_executor::ControlModeExecutor::new(sender); + + Self { + configuration: self.configuration.clone(), + executor: Arc::new(executor), + capabilities: OnceCell::new_with(Some(capabilities)), + next_request_id: AtomicU64::new(1), + persistent_clients: PersistentClients::new(self.configuration.control_client_limits), + // Shared with the parent: a control client this process spawned is + // the same process whichever handle dispatches through it. + control_client_pids: Arc::clone(&self.control_client_pids), + } + } + #[cfg(test)] pub(crate) fn from_executor_for_test(executor: Arc) -> Self { let configuration = CoreConfiguration { - identity: ServerIdentity::from_socket_path(PathBuf::from("/tmp/libtmux-test")), + identity: ServerIdentity::from_socket_path(PathBuf::from( + "/tmp/libtmux-rs-test/no-such-socket", + )), socket_name: None, config_file: None, colors: None, @@ -388,20 +416,36 @@ impl Core { } pub(crate) async fn execute(&self, command: Command) -> Result { + // Rendered before the command is consumed, and only for the transport + // that reads a line. A subprocess dispatch pays nothing for it. + #[cfg(feature = "control-mode")] + let control_line = self + .executor + .renders_control_line() + .then(|| command.control_mode_line()); let request = CommandRequest::with_global_argv( self.next_request_id(), &self.configuration.global_argv, command, ); + #[cfg(feature = "control-mode")] + let request = request.with_control_line(control_line.flatten()); self.executor.execute(request).await } pub(crate) async fn execute_chain(&self, chain: CommandChain) -> Result { + #[cfg(feature = "control-mode")] + let control_line = self + .executor + .renders_control_line() + .then(|| chain.control_mode_line()); let request = CommandRequest::chain_with_global_argv( self.next_request_id(), &self.configuration.global_argv, chain, ); + #[cfg(feature = "control-mode")] + let request = request.with_control_line(control_line.flatten()); self.executor.execute(request).await } diff --git a/crates/libtmux/src/internal/executor.rs b/crates/libtmux/src/internal/executor.rs index e638d071..ac401c9b 100644 --- a/crates/libtmux/src/internal/executor.rs +++ b/crates/libtmux/src/internal/executor.rs @@ -48,4 +48,13 @@ pub(crate) trait Executor: Send + Sync + 'static { fn execute(&self, request: CommandRequest) -> DispatchFuture; fn shutdown(&self) -> ShutdownFuture; + + /// Whether this executor dispatches a text line rather than an argv. + /// + /// Asked before a request is built, so the line is rendered only for the + /// one transport that reads it. A subprocess executor pays nothing. + #[cfg(feature = "control-mode")] + fn renders_control_line(&self) -> bool { + false + } } diff --git a/crates/libtmux/src/internal/mod.rs b/crates/libtmux/src/internal/mod.rs index 2bef6745..596408d2 100644 --- a/crates/libtmux/src/internal/mod.rs +++ b/crates/libtmux/src/internal/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "control-mode")] +pub(crate) mod control_executor; pub(crate) mod core; pub(crate) mod environment; pub(crate) mod executor; diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 294070d6..6f905d4d 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -9,8 +9,6 @@ use std::time::Duration; use crate::SessionId; use crate::formats::TmuxText; use crate::internal::core::Core; -#[cfg(test)] -use crate::internal::executor::Executor; use crate::internal::listing; #[cfg(feature = "control-mode")] use crate::internal::process::PersistentChild; @@ -1607,11 +1605,95 @@ impl Server { } #[cfg(test)] - pub(crate) fn from_executor_for_test(executor: Arc) -> Self { + pub(crate) fn from_executor_for_test( + executor: Arc, + ) -> Self { Self { core: Arc::new(Core::from_executor_for_test(executor)), } } + + /// Return a handle whose commands run over an open control connection. + /// + /// Every typed call on the returned server -- `sessions`, `send_keys`, + /// `capture`, the option and hook accessors -- is written to `sender` as + /// one control-mode line and answered from its `%begin`/`%end` block, + /// rather than spawning `tmux`. Handles reached through it inherit the + /// route, because they carry the same connection. + /// + /// The original server is unchanged and still dispatches processes. Use it + /// for the two things a connection is wrong for: a command that answers at + /// once and then parks the client's queue, such as `wait-for` or a + /// foreground `run-shell`, and a command whose arguments are not valid + /// UTF-8, which a text protocol cannot carry. + /// + /// Commands wait for the sender's own [`reply_timeout`], not this server's + /// [`default_timeout`]. They are not the same budget: one bounds a round + /// trip on an open connection, the other bounds forking tmux. + /// + /// # Draining events + /// + /// The connection stops reading tmux once a caller is far enough behind on + /// [`ControlEvents`], and refuses commands past that. Either keep reading + /// events, drop the watching half, or narrow what tmux reports with + /// [`ControlSender::watch_only`]. + /// + /// # Errors + /// + /// Returns an error when the sender reaches a different server, or when + /// the tmux version cannot be detected. Detection runs here, once, through + /// this server's process transport, because `tmux -V` is a client flag and + /// has no control-mode spelling; the returned handle never probes. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// use libtmux::control::ControlMode; + /// use libtmux::test::TestServer; + /// + /// let guard = TestServer::new().await?; + /// let session = guard.server().new_session("routed").await?; + /// + /// let (sender, events) = ControlMode::attach(guard.server(), session.id()) + /// .await? + /// .split(); + /// let routed = guard.server().over_control_mode(&sender).await?; + /// + /// // One line on the connection, not a process. + /// assert_eq!(routed.sessions().await?.len(), 1); + /// + /// // And a handle it found keeps the route. + /// let pane = routed.panes().await?.remove(0); + /// pane.send_line("true").await?; + /// + /// events.shutdown().await?; + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`reply_timeout`]: crate::control::ControlSender::reply_timeout + /// [`default_timeout`]: Server::default_timeout + /// [`ControlEvents`]: crate::control::ControlEvents + /// [`ControlSender::watch_only`]: crate::control::ControlSender::watch_only + #[cfg(feature = "control-mode")] + pub async fn over_control_mode( + &self, + sender: &crate::control::ControlSender, + ) -> Result { + self.core + .require_same_server(sender.identity(), "over_control_mode")?; + let capabilities = self.core.capabilities().await?.clone(); + + Ok(Self { + core: Arc::new(self.core.over_control_mode(sender.clone(), capabilities)), + }) + } } impl PartialEq for Server { diff --git a/crates/libtmux/tests/control_mode_routing.rs b/crates/libtmux/tests/control_mode_routing.rs new file mode 100644 index 00000000..705fc63e --- /dev/null +++ b/crates/libtmux/tests/control_mode_routing.rs @@ -0,0 +1,258 @@ +//! Typed calls routed over an open control-mode connection. +//! +//! The claim under test is that `Server::over_control_mode` moves the whole +//! typed API onto a connection someone else attached, and that it costs no +//! process to do it. +//! +//! Proving the second half needs a witness. The routed handle is built from a +//! server whose `tmux` executable is a shell stub that records every argv it +//! is given, so a dispatch that fell back to a process cannot go unnoticed: +//! it would appear in the stub's log, and the stub answers nothing but `-V`, +//! so the call would fail as well. + +#![cfg(all(feature = "control-mode", feature = "test-support"))] +// The routing proof is one long function on purpose: the stub's log has to +// stay at one line across every dispatch, and splitting it would mean a second +// control client whose count says nothing about the first. +#![allow( + clippy::expect_used, + clippy::panic, + clippy::too_many_lines, + clippy::unwrap_used +)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; + +use libtmux::control::ControlMode; +use libtmux::test::TestServer; +use libtmux::{Command, CommandChain, Server}; + +/// Write a `tmux` that logs its argv, answers `-V`, and refuses the rest. +fn stub_tmux(directory: &Path, version: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let log = directory.join("spawned.log"); + let stub = directory.join("tmux"); + let script = format!( + "#!/bin/sh\n\ + printf '%s\\n' \"$*\" >> '{log}'\n\ + if [ \"$1\" = '-V' ]; then printf 'tmux {version}\\n'; exit 0; fi\n\ + printf 'stub tmux was asked to run a command: %s\\n' \"$*\" >&2\n\ + exit 97\n", + log = log.display(), + ); + + fs::write(&stub, script).expect("the stub is written"); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).expect("the stub is executable"); + fs::write(&log, "").expect("the log starts empty"); + + (stub, log) +} + +/// Return one line per process the stub was asked to run. +fn spawned(log: &Path) -> Vec { + fs::read_to_string(log) + .expect("the log is readable") + .lines() + .map(str::to_owned) + .collect() +} + +#[tokio::test] +async fn typed_calls_route_over_the_connection_and_spawn_nothing() { + let guard = TestServer::new().await.expect("a private tmux starts"); + let real = guard.server().clone(); + let session = real + .new_session("routed") + .await + .expect("the fixture holds a session"); + let version = real + .capabilities() + .await + .expect("the fixture reports its release") + .tmux_version() + .raw() + .to_owned(); + + // The connection belongs to the caller. Everything below borrows it. + let control = ControlMode::attach(&real, session.id()) + .await + .expect("a control client attaches"); + let (sender, events) = control.split(); + + let directory = tempfile::tempdir().expect("a scratch directory"); + let (stub, log) = stub_tmux(directory.path(), &version); + let probe = Server::builder() + .socket_path(guard.socket_path()) + .tmux_executable(&stub) + .build() + .expect("a server pointed at the fixture's socket"); + + let routed = probe + .over_control_mode(&sender) + .await + .expect("the connection reaches the same server"); + + // The version probe is the one process this whole test lets the routed + // handle's transport start, and it happens before the switch. + assert_eq!( + spawned(&log), + vec!["-V".to_owned()], + "only the release probe ran as a process", + ); + + // A listing: the typed call renders a format plan and decodes rows. + let names: Vec = routed + .sessions() + .await + .expect("the routed handle lists sessions") + .iter() + .map(|found| found.name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, ["routed"], "the rows came from the real daemon"); + + // A mutation, and a chain, which takes the other dispatch path. + let window = session + .new_window("second") + .await + .expect("the fixture can be changed through the connection's server"); + routed + .chain( + CommandChain::new( + Command::new("rename-window") + .arg("-t") + .arg(window.id().to_string()) + .arg("renamed"), + ) + .then(Command::new("list-windows").arg("-F").arg("#{window_name}")), + ) + .await + .expect("a chain routes as one line"); + + // A refusal is a result, not a transport error, exactly as it is for a + // process: tmux answers the block with `%error`. + let refused = routed + .cmd(Command::new("list-panes").arg("-t").arg("%4294967294")) + .await + .expect("a refused command still answers"); + assert!(!refused.success(), "tmux refused the target"); + assert_eq!(refused.exit_code(), Some(1)); + assert!( + !refused.stderr().is_empty(), + "the refusal text lands where a process would have put it", + ); + + // A `-f` predicate survives being rendered as a control-mode line. It is + // the token most likely not to: `#{==:#{pane_id},%1}` opens with `#` and + // carries braces and a comma, so tmux has to be given it quoted. + assert!( + routed + .pane_by_id(&"%4294967294".parse().expect("a well-formed pane id")) + .await + .expect("a filtered listing succeeds with no rows") + .is_none(), + "the predicate reached tmux intact and matched nothing", + ); + + // And the typed layer reads an `%error` block the way it reads stderr. + // This is the assertion that discriminates: `Pane::capture` classifies a + // refusal from the text tmux gave, so putting that text on the wrong + // stream would make the error generic instead of "the pane is gone". + let doomed = session + .new_window("doomed") + .await + .expect("a window to take away again"); + let doomed_pane = doomed + .active_pane() + .await + .expect("the new window reports its pane") + .expect("a window has a pane"); + let routed_pane = routed + .pane_by_id(doomed_pane.id()) + .await + .expect("the routed handle finds it") + .expect("it is still there"); + routed + .cmd( + Command::new("kill-window") + .arg("-t") + .arg(doomed.id().to_string()), + ) + .await + .expect("the window is killed over the connection"); + + let gone = routed_pane + .capture() + .await + .expect_err("capturing a pane that is gone fails"); + assert!( + gone.is_object_gone(), + "an `%error` block classifies from its text, not from a status: {gone:?}", + ); + + // A handle the routed server found dispatches over the connection too, + // rather than falling back to the process transport it was built from. + let pane = routed + .panes() + .await + .expect("the routed handle lists panes") + .remove(0); + pane.send_line("true") + .await + .expect("an inherited handle sends over the connection"); + + // Nothing above added a line: every one of those calls was written to the + // connection instead of forked. + assert_eq!( + spawned(&log).len(), + 1, + "routed dispatches spawned no process: {:?}", + spawned(&log), + ); + + // And the real daemon agrees the work happened. + let windows = real.windows().await.expect("the fixture lists its windows"); + let window_names: Vec = windows + .iter() + .map(|found| found.name().to_string_lossy().into_owned()) + .collect(); + assert!( + window_names.contains(&"renamed".to_owned()), + "the rename reached tmux: {window_names:?}", + ); + + events.shutdown().await.expect("the connection closes"); + guard.shutdown().await.expect("the fixture stops"); +} + +#[tokio::test] +async fn a_sender_for_another_server_is_refused() { + let first = TestServer::new().await.expect("a private tmux starts"); + let second = TestServer::new() + .await + .expect("a second private tmux starts"); + let session = first + .server() + .new_session("origin") + .await + .expect("the first fixture holds a session"); + + let control = ControlMode::attach(first.server(), session.id()) + .await + .expect("a control client attaches"); + let (sender, events) = control.split(); + + let error = second + .server() + .over_control_mode(&sender) + .await + .expect_err("a sender reaching another server is refused"); + assert!( + matches!(error, libtmux::Error::ServerMismatch { .. }), + "the mismatch is reported rather than silently followed: {error:?}", + ); + + events.shutdown().await.expect("the connection closes"); + first.shutdown().await.expect("the first fixture stops"); + second.shutdown().await.expect("the second fixture stops"); +} From 726fa76c13323029c69399db1f94c2b2105120b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:42:10 -0500 Subject: [PATCH 050/117] Client(add): Tell our own control connection from a person's terminal tmux counts a control connection as an attached client, and this crate opens them: `stream_output`, `ControlMode::attach` and the waits built on them all appear in a client listing beside a human's terminal. So "is anybody watching this session" always meant anybody else, and the only way to ask was `Server::owns_control_client` plus pid arithmetic over a listing the caller parsed. `Client::is_own()` asks it on the handle. `owns_control_client` stays for the case that motivated it -- reading `list-clients` directly to get sessions and pids in one command, where a per-client lookup would cost one command each -- and now points here as the usual form. Shown capable of failing by returning `false`: our own attached connection stops being counted, `left: 0, right: 1`. --- crates/libtmux/docs/parity.md | 1 + crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/client.rs | 32 ++++++++++++++++++++++++ crates/libtmux/src/server.rs | 6 +++++ crates/libtmux/tests/control.rs | 40 ++++++++++++++++++++++++++++-- 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index e6895021..587912a9 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -718,6 +718,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | A `wait-for` lock is taken and released by two calls, so a locker that returns early leaves the channel held and every later locker blocked. | `Server::with_channel_lock` pairs them, releasing on the error path as the other `with_*` scopes do. The queued-drop wedge is a tmux defect (`cmd-wait-for.c`) and stays documented on `Server::lock_channel`. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | | A respawn's kill flag is a positional boolean, and a menu item is a string triple. | `Respawn::{Replacing, OnlyIfDead}` on `Pane::respawn` and `Window::respawn`, and `MenuItem` on `Server::display_menu`: a literal that decides whether a running process is killed, and three strings that compile in any order, both say what they mean at the call site. Covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | | Every command is one tmux process, and a persistent connection is reachable only by writing lines to it. | `Server::over_control_mode` routes the whole typed API over an attached `ControlSender`, so `sessions`, `send_keys`, `capture` and the option accessors answer from `%begin`/`%end` blocks instead of forking. Per-command attribution survives, because a block is per command. Covered by [`tests/control_mode_routing.rs`](../tests/control_mode_routing.rs), which proves no process is spawned by pointing `tmux_executable` at a stub that logs its argv. | Commands, transports, and control mode | `implemented` | +| A client listing cannot tell a library's own control connection from a person's terminal, so "is anybody attached" counts the asker. | `Client::is_own` answers it on a handle; `Server::owns_control_client` remains for a caller holding a pid and no handle. Covered by [`tests/control.rs`](../tests/control.rs). | Commands, transports, and control mode | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | | An unset option and an unknown option are one failure. | `Server::typed_option` reports `None` for unset built-ins and absent user options; the `@` prefix determines which rule applies. | Options, hooks, and advanced command families | `implemented` | | Creating an object returns a handle that must be looked up again to be useful. | `Server::new_session`, `Session::new_window`, and `Pane::split` hydrate handles from each creating command's `-P -F` output in one round trip. | Object mutations and interactions | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 52ff78d4..3341fd16 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -313,6 +313,7 @@ function libtmux::Client::created: fn(&self) -> i64 function libtmux::Client::detach: async fn(self) -> Result<(), libtmux::Error> function libtmux::Client::height: fn(&self) -> Option function libtmux::Client::is_control_mode: fn(&self) -> bool +function libtmux::Client::is_own: fn(&self) -> bool function libtmux::Client::is_readonly: fn(&self) -> bool function libtmux::Client::lock: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Client::name: const fn(&self) -> &libtmux::TmuxText diff --git a/crates/libtmux/src/client.rs b/crates/libtmux/src/client.rs index a6cd051a..f38480d4 100644 --- a/crates/libtmux/src/client.rs +++ b/crates/libtmux/src/client.rs @@ -110,6 +110,38 @@ impl Client { *self.info.client_control_mode() } + /// Report whether this process opened this client itself. + /// + /// tmux counts a control connection as an attached client, and this crate + /// opens them: [`crate::Pane::stream_output`], + /// [`crate::control::ControlMode::attach`] and the waits built on them all + /// show up in a client listing beside a human's terminal. A caller asking + /// "is anybody watching this session" means anybody *else*, so this is the + /// question to ask before counting. + /// + /// `false` for every client once this process ends, since the answer is + /// about connections this `Server` is still holding open. + /// + /// # Examples + /// + /// ```no_run + /// # async fn watchers(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// let others = server + /// .clients() + /// .await? + /// .into_iter() + /// .filter(|client| !client.is_own()) + /// .count(); + /// # let _ = others; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "control-mode")] + #[must_use] + pub fn is_own(&self) -> bool { + self.core.owns_control_client(self.pid()) + } + /// Return the identity of the server this client is attached to. pub(crate) fn server_identity(&self) -> &ServerIdentity { self.core.configuration().identity() diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 6f905d4d..2dd48597 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -401,6 +401,12 @@ impl Server { /// Report whether this process itself opened the control client with /// this pid, and it is still running. /// + /// [`crate::Client::is_own`] is the usual form, and reads better: ask a handle + /// from [`Self::clients`] rather than doing pid arithmetic. This one is + /// for a caller that already has a pid and no handle -- reading + /// `list-clients` itself to get sessions and pids in one command, which a + /// per-client lookup would turn into one command each. + /// /// [`crate::Pane::stream_output`], [`crate::Client::pid`] and friends can /// all open or report a control-mode connection; tmux counts any of them /// as an attached client the same as a human's terminal. A caller telling diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index 16e644d9..2e67027d 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -6,7 +6,7 @@ use std::time::Duration; use libtmux::control::{ControlEvents, ControlMode, ControlSender, Event, Subscription}; use libtmux::test::TestServer; -use libtmux::{Command, NewWindowOptions}; +use libtmux::{Client, Command, NewWindowOptions}; use static_assertions::assert_impl_all; use tokio_stream::StreamExt as _; @@ -145,7 +145,7 @@ async fn owns_control_client_reports_its_own_spawned_connections() { .expect("control mode attaches"); let clients = server.clients().await.expect("clients list"); - let pids: Vec = clients.iter().map(libtmux::Client::pid).collect(); + let pids: Vec = clients.iter().map(Client::pid).collect(); assert_eq!(pids.len(), 1, "exactly one client is attached: {pids:?}"); let pid = pids[0]; assert!( @@ -1989,3 +1989,39 @@ async fn a_pane_is_watched_wherever_it_now_lives() { output.shutdown().await.expect("the stream shuts down"); guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// tmux counts a control connection as an attached client, and this crate +/// opens them, so "is anybody watching" has to mean anybody else. +#[tokio::test] +async fn a_clients_listing_tells_our_own_connection_apart() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("watched").await.expect("session"); + + // No connection yet: nothing on the server is ours. + assert!( + server + .clients() + .await + .expect("clients list") + .iter() + .all(|client| !client.is_own()), + ); + + let mode = ControlMode::attach(server, session.id()) + .await + .expect("the connection attaches"); + + let clients = server.clients().await.expect("clients list"); + let ours = clients.iter().filter(|client| client.is_own()).count(); + assert_eq!(ours, 1, "our own control client, and only it: {clients:?}"); + assert!( + clients + .iter() + .filter(|client| client.is_own()) + .all(Client::is_control_mode), + ); + + mode.shutdown().await.expect("the connection closes"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} From 7c1a63f6584cafae4a000617b650932e42b5ef65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:46:28 -0500 Subject: [PATCH 051/117] Build(fix[features]): Gate two items at the feature that uses them `just features` lints the feature powerset, and two things only the all-features build had exercised failed in it. `ProcessStatus::from_block_outcome` arrived gated on `control-mode` or `test-support` because the contender it came from also used it in a test double this branch did not take. With only `test-support` on it is dead code, so it is gated on `control-mode` alone. `tests/listing_failures.rs` reaches `libtmux::query` for the two search listings but declared only `test-support`, so that combination failed to compile. It declares both, as the file it replaced did. Neither would have been caught by the all-features lane, which is the whole reason the powerset lane exists. --- crates/libtmux/src/command.rs | 2 +- crates/libtmux/tests/listing_failures.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/libtmux/src/command.rs b/crates/libtmux/src/command.rs index e847c091..80444329 100644 --- a/crates/libtmux/src/command.rs +++ b/crates/libtmux/src/command.rs @@ -845,7 +845,7 @@ impl ProcessStatus { /// synthesized so callers that read one see the shape tmux would have /// exited with, and it is stated here rather than invented at each call /// site. - #[cfg(any(feature = "control-mode", feature = "test-support"))] + #[cfg(feature = "control-mode")] pub(crate) const fn from_block_outcome(succeeded: bool) -> Self { Self { success: succeeded, diff --git a/crates/libtmux/tests/listing_failures.rs b/crates/libtmux/tests/listing_failures.rs index 5e384315..a52589bb 100644 --- a/crates/libtmux/tests/listing_failures.rs +++ b/crates/libtmux/tests/listing_failures.rs @@ -7,7 +7,7 @@ //! optional. A caller who would still rather show nothing writes //! `.unwrap_or_default()`, where it reads as the choice it is. -#![cfg(feature = "test-support")] +#![cfg(all(feature = "test-support", feature = "query"))] #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] use libtmux::test::TestServer; From 74dd74c57dbd322d47ffab72fc5e2a9898e02503 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:51:59 -0500 Subject: [PATCH 052/117] Docs: Say why the executor is bypassed and the itertools overlap kept Two things that were true, deliberate and unwritten, so each read as an oversight. `Core::spawn_control` hands its request to `PersistentChild::spawn` rather than to the `Executor`, which every other dispatch site uses. The reason is that a control connection is a long-lived child with its own protocol on its pipes, not a request with one answer, so it has nothing to return through a `DispatchFuture`. The consequence worth stating: a connection always forks tmux, including the one a `ControlModeExecutor` then dispatches over. Recorded on the trait, where a reader looking for the single dispatch point will find it. `QueryIteratorExt::exactly_one` and `one_or_none` overlap with `itertools::Itertools`, and now do so on every iterator since the trait became blanket. Kept rather than renamed: the names are the obvious ones and the shapes differ where it counts -- `ExactlyOneError` is a plain `Eq` enum, while itertools' error owns the iterator to replay it. With both traits imported the call is a compile error naming both candidates, not a silent choice, and the note shows the one-line fix. --- crates/libtmux/src/internal/executor.rs | 11 +++++++++++ crates/libtmux/src/lib.rs | 12 +++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/libtmux/src/internal/executor.rs b/crates/libtmux/src/internal/executor.rs index ac401c9b..4dd974f3 100644 --- a/crates/libtmux/src/internal/executor.rs +++ b/crates/libtmux/src/internal/executor.rs @@ -44,6 +44,17 @@ impl Future for ShutdownFuture { } } +/// How a built command reaches tmux. +/// +/// Not every path to tmux comes through here, and the exception matters when +/// reasoning about what installing an executor changes: +/// [`crate::internal::core::Core::spawn_control`] builds a `CommandRequest` +/// and then hands it to [`crate::internal::process::PersistentChild::spawn`] +/// directly, using the launch context rather than this trait. A control-mode +/// connection is a long-lived child with its own protocol on its pipes, not a +/// request with one answer, so it has nothing to return through +/// `DispatchFuture`. The consequence: opening a connection always forks tmux, +/// including the connection that a `ControlModeExecutor` then dispatches over. pub(crate) trait Executor: Send + Sync + 'static { fn execute(&self, request: CommandRequest) -> DispatchFuture; diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 3fa4d979..291ca2ad 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -7,9 +7,15 @@ //! [`query::QueryIteratorExt::matching`] for a portable expression or a named //! [`query::Matcher`]. Exact cardinality inspects at most two items. //! -//! If another iterator extension trait, such as `itertools::Itertools`, adds -//! the same method name, use universal function call syntax to select this -//! crate's method: +//! `matching` and `matching_owned` are this trait's own; `exactly_one` and +//! `one_or_none` deliberately overlap with `itertools::Itertools`, which has +//! `exactly_one` and `at_most_one`. The overlap is kept rather than renamed +//! around: the names are the obvious ones, and the shapes differ where it +//! matters. [`query::ExactlyOneError`] is a plain `NoItems`/`MultipleItems` +//! enum that is `Eq` and cheap to match, where itertools' error owns the +//! iterator so it can replay it. With both traits imported a call is +//! ambiguous, which is a compile error naming both candidates rather than a +//! silent choice; universal function call syntax picks one: //! //! ``` //! use libtmux::query::QueryIteratorExt; From c526fa2f4dc8f5befc9daf28c1381adcd4b7f6f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 19:55:50 -0500 Subject: [PATCH 053/117] mcp-swap(fix[tests]): Drop two wall-clock bounds that only measured load `preflight.rs` asserted `started.elapsed() < 1s` in two tests, and both failed whenever another cargo job shared the machine and passed on an idle one. Each bound was redundant with an assertion already there: - Reaping a setsid descendant: `recv_timeout` already panics with "preflight did not return promptly" past three seconds, so a tighter second bound on the same property added nothing but a way to fail. - Refusing an oversized stream: the error saying "exceeds" is what proves it refused on size rather than running out its two-second budget, since a timeout says so in its own words. CONTRIBUTING names this shape -- a duration chosen to cover a latency with no upper bound is a guess, and load is what collects on it -- and the file already carries two earlier fixes of the same kind. Shown by the condition that failed it: three passes while a 69-combination clippy run held the machine, where the old bounds failed twice under the same kind of load. --- tools/mcp-swap/tests/preflight.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/mcp-swap/tests/preflight.rs b/tools/mcp-swap/tests/preflight.rs index 6c42eec9..d613e752 100644 --- a/tools/mcp-swap/tests/preflight.rs +++ b/tools/mcp-swap/tests/preflight.rs @@ -79,7 +79,6 @@ fn initialize_result_reaps_a_setsid_descendant_holding_pipes() { env: BTreeMap::new(), }; let (sender, receiver) = std::sync::mpsc::channel(); - let started = Instant::now(); let worker = thread::spawn(move || { let result = preflight(&spec, Duration::from_secs(2)); let _ = sender.send(result); @@ -101,7 +100,10 @@ fn initialize_result_reaps_a_setsid_descendant_holding_pipes() { }; result.expect("initialize response before exit"); - assert!(started.elapsed() < Duration::from_secs(1)); + // Promptness is already bounded above: `recv_timeout` panics with + // "preflight did not return promptly" if this takes longer than three + // seconds. A second, tighter wall-clock bound here only added a way for a + // loaded machine to fail a test about reaping. assert_process_stopped(&child_pid); } @@ -192,7 +194,6 @@ fn long_lived_oversized_streams_are_refused_and_reaped() { child_pid.display() ), ); - let started = Instant::now(); let error = preflight( &ServerSpec { @@ -204,8 +205,11 @@ fn long_lived_oversized_streams_are_refused_and_reaped() { ) .expect_err("oversized output"); + // "exceeds" is what proves it refused on size rather than running out + // the two-second budget: a timeout says so in its own words. That makes + // a wall-clock assertion here redundant, and redundant only on an idle + // machine. assert!(error.to_string().contains("exceeds"), "{error}"); - assert!(started.elapsed() < Duration::from_secs(1)); assert_process_stopped(&child_pid); let size = fs::metadata(&heartbeat).expect("heartbeat").len(); thread::sleep(Duration::from_millis(50)); From 7bcb7e6d239ec02971e4acfb0bee735b8e839ebb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 20:03:54 -0500 Subject: [PATCH 054/117] Docs(add[cancel]): Say when an async fn owes a cancel-safety section `WRITING.md` set conventions for `# Examples`, `# Errors` and `# Panics` and none for cancellation, in a crate whose every public operation is async. Twenty-odd doc lines described cancellation, each in its own words and none under a heading a reader could look for. The convention: a `# Cancel safety` section on an `async fn` whose future, dropped partway, leaves something the caller must know about, saying which of three it is -- nothing happened; the effect may have happened, so a retry can repeat it; or something is left held. A method that adds nothing to the crate-wide model in the README needs no section. Applied to the two that most need it. `send_line` may have happened, but never half: text and Enter are one `send-keys`, so a retry can type the line twice but a drop cannot leave it unsubmitted. `lock_channel` is not cancel safe: a future dropped while queued leaves tmux holding a queue entry nobody will take, which wedges the channel for good. --- .github/WRITING.md | 9 +++++++++ crates/libtmux/src/pane.rs | 10 +++++++--- crates/libtmux/src/server/channels.rs | 25 +++++++++++++++---------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/WRITING.md b/.github/WRITING.md index d379b472..860968f6 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -122,6 +122,15 @@ channel inside a type is not documentation. restating that it can fail. - `# Panics` where a caller could trip one. Where nothing can, "Never panics" is worth writing: it is a promise, and inference is not. +- `# Cancel safety` on an `async fn` whose future, dropped partway, leaves + something a caller has to know about. Say which of three it is: nothing + happened; the effect may or may not have happened, so a retry can repeat it; + or something is left held -- a lock, a queue position, a half-sent input. + The crate-wide model is in the README's "Cancellation and shutdown": dropping + a dispatch signals its process group and reaps it. A method that adds nothing + to that model needs no section; one that does needs one, because a caller + racing it against a timeout or a `select!` cannot read the answer off the + signature. - No `# Safety`. `unsafe_code` is `forbid` at the workspace level, so there is no unsafe code here to document. If that ever changes, the section states the proof obligation the caller must uphold, and the reason it holds. diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 5e1e04da..0663ffc2 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -650,13 +650,17 @@ impl Pane { /// Send literal text followed by Enter as one dispatch. /// - /// The text and Enter are submitted together, so cancelling this future - /// cannot leave a completed text send without its Enter. The text is - /// sensitive and stays out of diagnostics. + /// The text is sensitive and stays out of diagnostics. /// /// # Errors /// /// Returns an error when tmux refuses the line. + /// + /// # Cancel safety + /// + /// The effect may or may not have happened, but never half of it: the text + /// and its Enter travel as one `send-keys`, so a dropped future cannot leave + /// a line typed and not submitted. A retry can type the line twice. pub async fn send_line(&self, text: impl Into) -> Result<(), Error> { listing::mutate( &self.core, diff --git a/crates/libtmux/src/server/channels.rs b/crates/libtmux/src/server/channels.rs index 69746c9f..6369c1ca 100644 --- a/crates/libtmux/src/server/channels.rs +++ b/crates/libtmux/src/server/channels.rs @@ -99,20 +99,25 @@ impl Server { /// Lock a `wait-for` channel, blocking later lock attempts on it. /// - /// Dropping this future while it is still queued behind another locker - /// leaves the channel permanently locked if that locker's process ends - /// without calling [`Self::unlock_channel`], and every future call here - /// for the same channel blocks forever: `cmd_wait_for_unlock` hands a - /// released lock to the next queued locker with no mechanism to skip one - /// whose client already disconnected. This is a tmux defect - /// (`cmd-wait-for.c`), not something this crate can protect against, and - /// the non-locking [`Self::wait_for_channel`]'s claim that dropping it is - /// safe does not carry over to this call: that form is an idempotent - /// latch check, not a queue. Measured directly on 3.2a, 3.7c, and master. + /// [`Self::with_channel_lock`] pairs this with the unlock, which is + /// usually what a caller wants. /// /// # Errors /// /// Returns an error when tmux refuses the channel name. + /// + /// # Cancel safety + /// + /// Not cancel safe: dropping this future can leave something held, and + /// no one can release it. While the call is queued behind another locker, + /// tmux holds a queue entry for it. If that locker's process ends without + /// calling [`Self::unlock_channel`], `cmd_wait_for_unlock` hands the + /// released lock to the next queued entry with no way to skip one whose + /// client has gone, and every later call here for the same channel blocks + /// forever. That is a tmux defect (`cmd-wait-for.c`), measured on 3.2a, + /// 3.7c and master, and no scope in this crate reaches it. The + /// non-locking [`Self::wait_for_channel`] is safe to drop: it is a latch + /// check, not a queue. pub async fn lock_channel(&self, channel: &str) -> Result<(), Error> { listing::mutate( &self.core, From 1c0afd04af70294542ccb9a7c6733e57a971c802 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 20:19:35 -0500 Subject: [PATCH 055/117] ControlMode(fix[chain]): Read a block per command why: `Server::over_control_mode` (33bbf1e) sent a `CommandChain` as one line and took the first `%begin`/`%end` block as the whole reply. tmux answers each command of a chain with its own block, and runs nothing after the first one that fails (`cmdq_fire_command` guards each item; `cmdq_remove_group` drops the rest on error; seen on 3.7d). A routed chain therefore returned only its first command's output. The rest was dropped, or, when another request was already waiting, handed to that request as its reply. `Pane::wait_for_text` and `Pane::wait_for_quiet` look with a two-command chain, so on a routed pane they read an empty capture and reported `TimedOut` for text that was on screen. Option writes and `plan` steps that chain were cut short the same way. The routing test ran a chain and asserted only that it returned `Ok`, so it could not see the loss. what: - Carry a request's command count to its reply slot; the slot takes that many blocks, or stops at the first `%error`, and answers once - Keep what earlier commands printed apart from a refusal, so the executor puts it in stdout and the refusal in stderr, as a process - Report an ordering boundary when the slot finishes, not per block - Assert the routed chain's last output and that a failing chain leaves the next reply aligned; unit-test both paths --- crates/libtmux/src/command.rs | 16 +++ crates/libtmux/src/control.rs | 43 ++++++- crates/libtmux/src/control/actor.rs | 116 +++++++++++++++--- crates/libtmux/src/control/tests.rs | 77 ++++++++++++ .../libtmux/src/internal/control_executor.rs | 32 ++--- crates/libtmux/tests/control_mode_routing.rs | 26 +++- 6 files changed, 275 insertions(+), 35 deletions(-) diff --git a/crates/libtmux/src/command.rs b/crates/libtmux/src/command.rs index 80444329..8520cf30 100644 --- a/crates/libtmux/src/command.rs +++ b/crates/libtmux/src/command.rs @@ -731,6 +731,10 @@ pub(crate) struct CommandRequest { /// control mode cannot express, and it says so rather than guessing. #[cfg(feature = "control-mode")] control_line: Option, + /// Commands in the request, which is how many blocks a connection answers + /// it with when none fails. + #[cfg(feature = "control-mode")] + command_count: usize, } impl CommandRequest { @@ -761,6 +765,8 @@ impl CommandRequest { logical_subcommand_index, #[cfg(feature = "control-mode")] control_line: None, + #[cfg(feature = "control-mode")] + command_count: 1, } } @@ -770,6 +776,8 @@ impl CommandRequest { chain: CommandChain, ) -> Self { let command = chain.summary(); + #[cfg(feature = "control-mode")] + let command_count = chain.command_count(); let (argv, logical_subcommand_index) = chain.into_argv(global_argv); Self { @@ -779,6 +787,8 @@ impl CommandRequest { logical_subcommand_index, #[cfg(feature = "control-mode")] control_line: None, + #[cfg(feature = "control-mode")] + command_count, } } @@ -795,6 +805,12 @@ impl CommandRequest { self.control_line } + /// How many commands the request carries. + #[cfg(feature = "control-mode")] + pub(crate) const fn command_count(&self) -> usize { + self.command_count + } + pub(crate) const fn request_id(&self) -> RequestId { self.request_id } diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index 9849bcf7..fe132535 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -356,6 +356,9 @@ pub struct BlockResult { succeeded: bool, output: Vec, sensitive_input: bool, + /// Leading lines of `output` printed by earlier commands of the same + /// chain, each of which succeeded. + chained: usize, } impl BlockResult { @@ -381,6 +384,32 @@ impl BlockResult { &self.output } + /// Split the output into what succeeded and what the failing command + /// printed, the way a process separates stdout from stderr. + pub(crate) fn split_by_outcome(&self) -> (&[TmuxText], &[TmuxText]) { + if self.succeeded { + (&self.output, &[]) + } else { + self.output.split_at(self.chained.min(self.output.len())) + } + } + + /// Append the block tmux sent for the next command of the same chain. + /// + /// Only called while every block so far has succeeded: tmux runs nothing + /// after the first command in a chain that fails. + pub(super) fn followed_by(mut self, next: Self) -> Self { + let chained = self.output.len(); + self.output.extend(next.output); + Self { + number: next.number, + succeeded: next.succeeded, + output: self.output, + sensitive_input: next.sensitive_input, + chained, + } + } + /// Classify an error block as a refusal for a named operation. /// /// Use a fixed operation name without targets or argument values. Output @@ -700,20 +729,24 @@ impl ControlSender { let line = command .control_mode_line() .ok_or_else(Error::control_mode_unrepresentable)?; - self.dispatch_line(line, sensitive_input, boundary).await + self.dispatch_line(line, sensitive_input, boundary, 1).await } - /// Send one already-rendered control-mode line. + /// Send one already-rendered control-mode line holding `commands` + /// commands. /// /// The typed API routes through here: a request built for dispatch carries /// its own rendering, so re-deriving one from the argv is neither needed - /// nor correct. + /// nor correct. tmux answers each command of a chain with its own block, + /// and they come back as one result. pub(crate) async fn send_line( &self, line: String, sensitive_input: bool, + commands: usize, ) -> Result { - self.dispatch_line(line, sensitive_input, None).await + self.dispatch_line(line, sensitive_input, None, commands) + .await } async fn dispatch_line( @@ -721,6 +754,7 @@ impl ControlSender { line: String, sensitive_input: bool, boundary: Option, + commands: usize, ) -> Result { let deadline = Instant::now().checked_add(self.timeout); let (result, mut answer) = oneshot::channel(); @@ -749,6 +783,7 @@ impl ControlSender { result, commit, boundary, + blocks: commands.max(1), }); tokio::select! { diff --git a/crates/libtmux/src/control/actor.rs b/crates/libtmux/src/control/actor.rs index 2917b2a2..e83b4ecb 100644 --- a/crates/libtmux/src/control/actor.rs +++ b/crates/libtmux/src/control/actor.rs @@ -99,7 +99,7 @@ pub(super) async fn open( }) } -/// One command waiting for its result block. +/// One line waiting for its result blocks. #[derive(Debug)] pub(super) struct Request { pub(super) line: String, @@ -107,6 +107,8 @@ pub(super) struct Request { pub(super) result: oneshot::Sender>, pub(super) commit: oneshot::Sender<()>, pub(super) boundary: Option, + /// Commands on the line: tmux answers each with its own block. + pub(super) blocks: usize, } /// A request whose caller can no longer prevent the first write. @@ -116,6 +118,7 @@ pub(super) struct CommittedRequest { pub(super) deadline: Option, pub(super) result: oneshot::Sender>, pub(super) boundary: Option, + pub(super) blocks: usize, } impl Request { @@ -126,6 +129,7 @@ impl Request { result, commit, boundary, + blocks, } = self; commit.send(()).ok()?; Some(CommittedRequest { @@ -133,6 +137,7 @@ impl Request { deadline, result, boundary, + blocks, }) } } @@ -153,11 +158,16 @@ pub(super) enum ReplySlot { result: oneshot::Sender>, deadline: Option, boundary: Option, + /// Blocks still to come, one per command on the line. + owed: usize, + /// The chain's blocks so far, all of them successes. + received: Option, }, - /// Consume this block without giving it to a later caller. + /// Consume these blocks without giving them to a later caller. Tombstone { deadline: Option, boundary: Option, + owed: usize, }, } @@ -173,6 +183,39 @@ impl ReplySlot { Self::Live { boundary, .. } | Self::Tombstone { boundary, .. } => *boundary, } } + + /// Whether `block` is the last this slot is owed. tmux runs nothing + /// after the first command in a chain that fails, so a failure ends it. + const fn is_last(&self, block: &BlockResult) -> bool { + let (Self::Live { owed, .. } | Self::Tombstone { owed, .. }) = self; + *owed <= 1 || !block.succeeded() + } + + /// Take one block, returning the whole reply once the last one arrives. + fn take(&mut self, block: BlockResult) -> Option { + let last = self.is_last(&block); + match self { + Self::Live { owed, received, .. } => { + let block = match received.take() { + Some(earlier) => earlier.followed_by(block), + None => block, + }; + if last { + return Some(block); + } + *owed -= 1; + *received = Some(block); + None + } + Self::Tombstone { owed, .. } => { + if last { + return Some(block); + } + *owed -= 1; + None + } + } + } } /// Ordered reply slots, including blocks whose callers were refused. @@ -190,7 +233,16 @@ impl ReplySlots { result: oneshot::Sender>, deadline: Option, ) { - self.push_ordered(result, deadline, None); + self.push_ordered(result, deadline, None, 1); + } + + #[cfg(test)] + pub(super) fn push_chain( + &mut self, + result: oneshot::Sender>, + blocks: usize, + ) { + self.push_ordered(result, None, None, blocks); } fn push_ordered( @@ -198,23 +250,36 @@ impl ReplySlots { result: oneshot::Sender>, deadline: Option, boundary: Option, + blocks: usize, ) { self.earliest = earliest_deadline(self.earliest, deadline); + let owed = blocks.max(1); if result.is_closed() { - self.slots - .push_back(ReplySlot::Tombstone { deadline, boundary }); + self.slots.push_back(ReplySlot::Tombstone { + deadline, + boundary, + owed, + }); } else { self.slots.push_back(ReplySlot::Live { result, deadline, boundary, + owed, + received: None, }); self.live += 1; } } - fn front_boundary(&self) -> Option { - self.slots.front().and_then(ReplySlot::boundary) + /// The front slot's boundary, when `block` is the last it is owed. + fn boundary_closed_by(&self, block: &BlockResult) -> Option { + let front = self.slots.front()?; + if front.is_last(block) { + front.boundary() + } else { + None + } } pub(super) const fn has_live(&self) -> bool { @@ -239,19 +304,37 @@ impl ReplySlots { pub(super) fn refuse_live(&mut self) { for slot in &mut self.slots { - let deadline = slot.deadline(); - let boundary = slot.boundary(); - let ReplySlot::Live { result, .. } = - std::mem::replace(slot, ReplySlot::Tombstone { deadline, boundary }) + let ReplySlot::Live { + deadline, + boundary, + owed, + .. + } = *slot else { continue; }; + let ReplySlot::Live { result, .. } = std::mem::replace( + slot, + ReplySlot::Tombstone { + deadline, + boundary, + owed, + }, + ) else { + continue; + }; let _ = result.send(Err(Error::control_mode_unread())); } self.live = 0; } pub(super) fn complete(&mut self, block: BlockResult) { + let Some(front) = self.slots.front_mut() else { + return; + }; + let Some(block) = front.take(block) else { + return; + }; let Some(slot) = self.slots.pop_front() else { return; }; @@ -484,8 +567,12 @@ impl Connection { } break; } - self.awaiting - .push_ordered(request.result, request.deadline, request.boundary); + self.awaiting.push_ordered( + request.result, + request.deadline, + request.boundary, + request.blocks, + ); } // The caller took one, so the next one can go. Step::Deliver(true) => { @@ -632,7 +719,7 @@ impl Connection { // order. Put the private marker after `%end` and // before reading another line so the receiver sees // the same boundary without exposing protocol state. - if let Some(boundary) = self.awaiting.front_boundary() { + if let Some(boundary) = self.awaiting.boundary_closed_by(&block) { self.report(Delivery::Boundary(boundary)); } self.awaiting.complete(block); @@ -723,6 +810,7 @@ impl Connection { succeeded, output, sensitive_input: false, + chained: 0, })); } Some(Line::Text(text)) => { diff --git a/crates/libtmux/src/control/tests.rs b/crates/libtmux/src/control/tests.rs index 7830e029..10c1241f 100644 --- a/crates/libtmux/src/control/tests.rs +++ b/crates/libtmux/src/control/tests.rs @@ -21,6 +21,7 @@ fn reply(number: u64) -> BlockResult { succeeded: true, output: Vec::new(), sensitive_input: false, + chained: 0, } } @@ -65,6 +66,7 @@ fn request() -> (Request, oneshot::Receiver>) { commit, result, boundary: None, + blocks: 1, }, answer, ) @@ -98,6 +100,7 @@ fn block_refusal_classification_withholds_sensitive_output() { succeeded: false, output: vec![TmuxText::from(secret)], sensitive_input: true, + chained: 0, }; let error = block .refusal_for("display-message") @@ -142,6 +145,7 @@ async fn watch_only_marks_a_transport_failure_after_its_first_mute() { succeeded: true, output: vec![TmuxText::from_bytes(*b"%1"), TmuxText::from_bytes(*b"%2")], sensitive_input: false, + chained: 0, })) .expect("watch is waiting for the listing"); @@ -195,6 +199,7 @@ async fn watch_only_refuses_a_failed_listing_before_muting_any_pane() { succeeded: false, output: vec![TmuxText::from_bytes(*b"listing refused")], sensitive_input: false, + chained: 0, })) .expect("watch is waiting for the listing"); @@ -485,6 +490,7 @@ async fn mute_pane_reports_a_control_error_block() { succeeded: false, output: vec![TmuxText::from_bytes(*b"mute refused")], sensitive_input: false, + chained: 0, })) .expect("mute is waiting for its block"); @@ -536,6 +542,77 @@ fn a_refused_reply_keeps_the_next_reply_aligned() { ); } +/// tmux answers each command of a chain with its own block and runs nothing +/// after the first that fails. Either way the chain gets every block it is +/// owed, and the next caller gets none of them. +#[test] +fn a_chain_takes_one_block_per_command_and_stops_at_a_failure() { + fn block(number: u64, succeeded: bool, text: &str) -> BlockResult { + BlockResult { + output: vec![TmuxText::from(text)], + succeeded, + ..reply(number) + } + } + let pending = |answer: &mut oneshot::Receiver>| { + matches!(answer.try_recv(), Err(oneshot::error::TryRecvError::Empty)) + }; + + let mut replies = ReplySlots::default(); + let (chain, mut chain_answer) = oneshot::channel(); + let (next, mut next_answer) = oneshot::channel(); + let (failing, mut failing_answer) = oneshot::channel(); + replies.push_chain(chain, 2); + replies.push(next, None); + replies.push_chain(failing, 3); + + replies.complete(block(1, true, "first")); + assert!( + pending(&mut chain_answer), + "one block of two is not the answer" + ); + replies.complete(block(2, true, "second")); + let chained = chain_answer + .try_recv() + .expect("the chain is answered") + .expect("both commands succeeded"); + assert_eq!( + chained.output(), + [TmuxText::from("first"), TmuxText::from("second")] + ); + assert!(pending(&mut next_answer), "the chain's blocks stay its own"); + + replies.complete(reply(3)); + assert_eq!( + next_answer + .try_recv() + .expect("the next caller is answered") + .expect("it succeeded") + .number(), + 3, + ); + + replies.complete(block(4, true, "printed")); + replies.complete(block(5, false, "refused")); + let failed = failing_answer + .try_recv() + .expect("a failure ends the chain early") + .expect("a refusal is a result"); + assert!(!failed.succeeded()); + assert_eq!( + failed.split_by_outcome(), + ( + &[TmuxText::from("printed")][..], + &[TmuxText::from("refused")][..] + ), + "what ran before the failure is kept apart from the refusal", + ); + assert!( + replies.slots.is_empty(), + "no slot waits for a skipped command" + ); +} + #[tokio::test] async fn queue_wait_counts_toward_the_command_deadline() { let (commands, mut requests) = mpsc::channel(1); diff --git a/crates/libtmux/src/internal/control_executor.rs b/crates/libtmux/src/internal/control_executor.rs index 3d46f933..79cbc294 100644 --- a/crates/libtmux/src/internal/control_executor.rs +++ b/crates/libtmux/src/internal/control_executor.rs @@ -30,41 +30,41 @@ impl Executor for ControlModeExecutor { let request_id = request.request_id(); let summary = request.summary().clone(); let sensitive_input = summary.sensitive_argument_count() > 0; + let commands = request.command_count(); let line = request.into_control_line(); DispatchFuture::new(async move { let Some(line) = line else { return Err(crate::Error::control_mode_unrepresentable()); }; - let block = sender.send_line(line, sensitive_input).await?; + let block = sender.send_line(line, sensitive_input, commands).await?; // tmux prints a command's output inside the block, one line at a // time, with the trailing newline that separated them removed. // Putting it back is what makes the bytes identical to the // stdout a process would have written, which every parser above // this already reads. - let mut bytes = Vec::new(); - for line in block.output() { - bytes.extend_from_slice(line.as_bytes()); - bytes.push(b'\n'); - } + let bytes = |lines: &[crate::TmuxText]| { + let mut bytes = Vec::new(); + for line in lines { + bytes.extend_from_slice(line.as_bytes()); + bytes.push(b'\n'); + } + bytes + }; - let succeeded = block.succeeded(); // A refused command prints its reason where a process would have // put it: an `%error` block is stderr, not stdout, and error - // classification reads stderr. - let (stdout, stderr) = if succeeded { - (bytes, Vec::new()) - } else { - (Vec::new(), bytes) - }; + // classification reads stderr. What a chain printed before it is + // stdout, as it would be from a process. + let (stdout, stderr) = block.split_by_outcome(); Ok(CommandResult::new( request_id, summary, - ProcessStatus::from_block_outcome(succeeded), - stdout, - stderr, + ProcessStatus::from_block_outcome(block.succeeded()), + bytes(stdout), + bytes(stderr), )) }) } diff --git a/crates/libtmux/tests/control_mode_routing.rs b/crates/libtmux/tests/control_mode_routing.rs index 705fc63e..0014cacc 100644 --- a/crates/libtmux/tests/control_mode_routing.rs +++ b/crates/libtmux/tests/control_mode_routing.rs @@ -116,7 +116,7 @@ async fn typed_calls_route_over_the_connection_and_spawn_nothing() { .new_window("second") .await .expect("the fixture can be changed through the connection's server"); - routed + let chained = routed .chain( CommandChain::new( Command::new("rename-window") @@ -128,6 +128,30 @@ async fn typed_calls_route_over_the_connection_and_spawn_nothing() { ) .await .expect("a chain routes as one line"); + // tmux answers each command of a chain with its own block, so the last + // command's output is only here if every block was read. + assert!( + String::from_utf8_lossy(chained.stdout()) + .lines() + .any(|name| name == "renamed"), + "the chain's last command answered: {:?}", + String::from_utf8_lossy(chained.stdout()), + ); + + // A chain stops at its first failure, and tmux sends no block for the + // commands it skipped, so the next caller's reply is still its own. + let stopped = routed + .chain( + CommandChain::new(Command::new("list-panes").arg("-t").arg("%4294967294")) + .then(Command::new("display-message").arg("-p").arg("skipped")), + ) + .await + .expect("a refused chain still answers"); + assert!(!stopped.success(), "the first command was refused"); + assert!( + stopped.stdout().is_empty(), + "the skipped command printed nothing" + ); // A refusal is a result, not a transport error, exactly as it is for a // process: tmux answers the block with `%error`. From 03c35d1b2fa343540e937bde5c72ed979c971222 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 20:32:04 -0500 Subject: [PATCH 056/117] Pane(feat[wait_until]): Wait on a predicate over lines why: `Pane::wait_for_text` matches a literal needle, and the only predicate wait, `test::retry_until`, sits behind `test-support`. A caller wanting a line equal to something, a count, or a pattern wrote the capture loop themselves. `wait_until` runs the loop `wait_for_text` and `wait_for_quiet` already share -- scrollback with wrapped lines joined, `#{pane_dead}` read in the same look, the first look before any sleep -- and hands the predicate the lines `capture_with` would return. `wait_for_text` stays on the byte loop, so a needle holding a newline still spans lines. It polls on every route. The `waits` bench settled what a `%output` doorbell buys: under three milliseconds on a marker, six times on a 20,000-line flood, nothing at 200,000 lines with a four-fold wider spread. On a handle from `Server::over_control_mode` the connection's events belong to the caller, so a doorbell would attach a second client per wait. A spike at load ~34 on 10 cores measured that attach at 19.7ms median against 2.6ms for a whole routed marker wait, and a routed look is a line rather than a process. A `query::Matcher` overload was not added: `Matcher` takes `&self`, so the state `wait_for_quiet` keeps cannot be one, its `T` must be sized, and a closure already is a `Matcher`, so adapting one is `|lines| lines.iter().any(|line| matcher.matches(line))`. what: - Add `Pane::wait_until(within, settled)`; rename the private loop to `look_until` and share the line split with `capture_with` - Rustdoc with `# Errors`, `# Cancel safety` and a runnable example - Tests: arrives, times out at and not before its deadline, ends on a dead pane, and runs over a control connection - Parity row, public API record, changelog, and the design note on why a routed wait still polls --- crates/libtmux/docs/design.md | 10 +++ crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/pane/observe.rs | 106 +++++++++++++++++++++---- crates/libtmux/tests/commands.rs | 122 +++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 16 deletions(-) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index d719626c..8a035aff 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -1957,6 +1957,16 @@ below rather than the clock. The clock now says it would be worth having -- under three milliseconds on a marker, six times on a moderate flood, nothing once the flood is large enough -- and a default build still cannot reach it. +`Pane::wait_until` runs the same loop with a predicate over the captured lines, +for what a literal needle cannot say. It polls on a handle from +`Server::over_control_mode` as well, where `control-mode` is on and the feature +argument does not apply. The reason there is ownership: a connection's +`%output` goes to whoever holds its events, and the handle holds only the +sender, so waking on output would attach a second client for every wait -- the +cost the `streamed` lane leaves out of its number. Over a connection a look is +a line rather than a process, so the round trip a doorbell would save shrinks +as well. + What follows is why, and it is kept because the constraints it records are the ones the implementation had to meet. diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 587912a9..1fa13f2a 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -631,7 +631,7 @@ Sources: `src/libtmux/pane.py`, `docs/api/libtmux.pane.md`, | `cmd` | One raw result targeting the Pane ID by default. | `Pane::cmd`, placing `-t` after the subcommand rather than appending it: tmux stops reading flags at the first positional, so an appended target is taken as text and the command succeeds having acted on something else. Covered by [`tests/commands.rs`](../tests/commands.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | | `resize` | Returns a refreshed Pane. Validates direction/amount and digit-or-percentage values; stderr raises. | `Pane::resize` sets dimensions and `Pane::resize_by` changes cells. Percentage adjustment is not exposed. | Object mutations and interactions | `in progress` | | `capture_pane` | Returns stdout lines unless writing to a buffer, then `None`. Stderr is ignored. `trim_trailing` requires 3.4, `mode_screen` 3.6, and hyperlinks/line numbers/line flags 3.7; unsupported flags warn and disappear. | `Pane::capture_with` taking `CaptureOptions`, with loud errors and capability checks; covered by [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | -| Pane-level waiting | Python has none. `retry_until` is a test helper in `libtmux/test/retry.py`, so production code that waits on a pane writes the loop itself. | `Pane::wait_for_text` and `Pane::wait_for_quiet`, returning `PaneWait`, behind no feature. Each look reads the scrollback with wrapped lines joined, so output that scrolled away is still found and a needle spanning a wrap still matches; a pane whose process ends answers `Dead` rather than running to the deadline, and a deadline is a value rather than an error. Covered by [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | +| Pane-level waiting | Python has none. `retry_until` is a test helper in `libtmux/test/retry.py`, so production code that waits on a pane writes the loop itself. | `Pane::wait_for_text`, `Pane::wait_for_quiet`, and `Pane::wait_until` for any predicate over the captured lines, returning `PaneWait`, behind no feature. Each look reads the scrollback with wrapped lines joined, so output that scrolled away is still found and a needle spanning a wrap still matches; a pane whose process ends answers `Dead` rather than running to the deadline, and a deadline is a value rather than an error. Covered by [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | | `send_keys` | Unit; stderr is ignored. Missing command requires reset, repeat, or copy-mode behavior or raises `ValueError`. Client/key-name options require 3.4 and are warn-ignore below. | `Pane::send_line` sends literal sensitive text plus Enter in one dispatch, `Pane::send_keys` sends literal sensitive text, and `Pane::send_key_names` sends named keys. Reset, repeat, copy-mode, and client-routing options are not exposed. | Object mutations and interactions | `in progress` | | `enter` | Returns the same Pane and sends Enter through the non-loud key path. | `Pane::send_key_names` sends `Enter` through the loud key-name path. | Object mutations and interactions | `implemented` | | `display_message` | Returns lines only when `get_text=True`, otherwise `None`; stderr warns. `no_expand` requires 3.4 and `update_pane` 3.6; unsupported flags warn and disappear. | `Pane::format` reads expanded text and `Pane::display` shows it; both fail loudly; covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 3341fd16..4e65bed6 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -470,6 +470,7 @@ function libtmux::Pane::unset_hook: async fn(&self, name: &str) -> Result<(), li function libtmux::Pane::unset_option: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Pane::wait_for_quiet: async fn(&self, quiet_for: Duration, within: Duration) -> Result function libtmux::Pane::wait_for_text: async fn(&self, needle: impl AsRef<[u8]>, within: Duration) -> Result +function libtmux::Pane::wait_until: async fn(&self, within: Duration, settled: impl FnMut(&[libtmux::TmuxText]) -> bool) -> Result function libtmux::Pane::width: fn(&self) -> u32 function libtmux::Pane::window: async fn(&self) -> Result, libtmux::Error> function libtmux::Pane::window_id: const fn(&self) -> &libtmux::WindowId diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index b92803e1..3e4d2e87 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -164,18 +164,7 @@ impl Pane { )); } - // tmux terminates every line, including the last, so a trailing empty - // element after the final newline is framing rather than content. - let stdout = result.stdout(); - let stdout = stdout.strip_suffix(b"\n").unwrap_or(stdout); - if stdout.is_empty() { - return Ok(Vec::new()); - } - - Ok(stdout - .split(|byte| *byte == b'\n') - .map(|line| TmuxText::from(line.to_vec())) - .collect()) + Ok(split_lines(result.stdout())) } /// Capture with the per-line flags tmux records, marking shell prompts. @@ -236,6 +225,8 @@ impl Pane { /// Wait until this pane's output contains `needle`. /// + /// [`Pane::wait_until`] takes a predicate over the lines instead. + /// /// Polls rather than streams, so it needs no feature: a caller who /// dispatches a command needs to know when it finished, and /// [`Pane::send_keys`] without that is half an operation. A control-mode @@ -298,7 +289,7 @@ impl Pane { within: Duration, ) -> Result { let needle = needle.as_ref(); - self.wait_until(within, |text, _| contains(text, needle)) + self.look_until(within, |text, _| contains(text, needle)) .await } @@ -345,7 +336,7 @@ impl Pane { ) -> Result { let mut last_change = tokio::time::Instant::now(); let mut previous: Option> = None; - self.wait_until(within, move |text, now| { + self.look_until(within, move |text, now| { if previous.as_deref() == Some(text) { return now.duration_since(last_change) >= quiet_for; } @@ -356,8 +347,77 @@ impl Pane { .await } + /// Wait until `settled` holds for this pane's captured lines. + /// + /// For what a literal [`Pane::wait_for_text`] cannot say: a line equal to + /// something rather than containing it, a count, a pattern, the shape of + /// the last line. `settled` sees what [`Pane::capture_with`] returns for + /// `CaptureOptions::history().join_wrapped()`: scrollback and screen, one + /// entry per line, a line tmux wrapped joined back into one, and the + /// screen's unused rows as empty lines at the end. + /// + /// Looks are the ones [`Pane::wait_for_text`] describes: 120ms apart, + /// the first before any sleep, so lines already there answer at once. A + /// pane whose process ends answers [`PaneWait::Dead`] rather than running + /// to the deadline. It polls even on a handle routed through + /// `Server::over_control_mode`, because that connection's `%output` + /// belongs to whoever reads its events, so waking on it would attach a + /// second client per wait to save under three milliseconds on a marker + /// (`benches/waits.rs`). + /// + /// A `query::Matcher` over one line fits as + /// `|lines| lines.iter().any(|line| matcher.matches(line))`. + /// + /// # Errors + /// + /// Returns an error when tmux cannot be reached or refuses a look, which + /// includes a pane that has been closed. Running out of time is + /// [`PaneWait::TimedOut`], not an error. + /// + /// # Cancel safety + /// + /// Nothing happened. A look only reads, so a future dropped mid-look + /// leaves tmux as it was, and output produced in the meantime stays in the + /// scrollback, up to `history-limit`, for the next wait to find. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// use libtmux::PaneWait; + /// use std::time::Duration; + /// + /// # let guard = libtmux::test::TestServer::builder().start().await?; + /// # let session = guard.server().new_session("counting").await?; + /// # let pane = session.panes().await?.remove(0); + /// pane.send_line("for n in 1 2 3; do echo tick; done").await?; + /// + /// // The echoed command contains `tick`; only the output is a line equal to it. + /// let ticked = pane + /// .wait_until(Duration::from_secs(10), |lines| { + /// lines.iter().filter(|line| **line == "tick").count() == 3 + /// }) + /// .await?; + /// assert_eq!(ticked, PaneWait::Arrived); + /// # guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + pub async fn wait_until( + &self, + within: Duration, + mut settled: impl FnMut(&[TmuxText]) -> bool, + ) -> Result { + self.look_until(within, |text, _| settled(&split_lines(text))) + .await + } + /// The shared loop: look, decide, sleep, repeat until the deadline. - async fn wait_until( + async fn look_until( &self, within: Duration, mut settled: impl FnMut(&[u8], tokio::time::Instant) -> bool, @@ -419,6 +479,22 @@ impl Pane { } } +/// Split `capture-pane` output into lines. +/// +/// tmux terminates every line, including the last, so a trailing empty element +/// after the final newline is framing rather than content. +fn split_lines(stdout: &[u8]) -> Vec { + let stdout = stdout.strip_suffix(b"\n").unwrap_or(stdout); + if stdout.is_empty() { + return Vec::new(); + } + + stdout + .split(|byte| *byte == b'\n') + .map(|line| TmuxText::from(line.to_vec())) + .collect() +} + /// Split one look's output into the pane's dead flag and its capture. /// /// `display-message -p` writes one line, so the flag is everything before the diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index c3a7dea7..b38d2332 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -2153,6 +2153,128 @@ async fn waiting_ends_when_the_pane_dies_rather_than_at_the_deadline() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A predicate wait sees whole lines, answers when they satisfy it, and holds +/// an unmet one to the deadline rather than past or short of it. +#[tokio::test] +async fn a_predicate_wait_arrives_or_runs_to_its_deadline() { + use libtmux::PaneWait; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("predicate").await.expect("session"); + let pane = session.panes().await.expect("panes").remove(0); + + // Wider than the pane, so equality holds only if the wrap is joined; and + // the echoed command contains the text without being equal to it. + let wide = "P".repeat(300); + pane.send_line(format!("printf '%s\\n' {wide} {wide}")) + .await + .expect("keys are sent"); + let outcome = pane + .wait_until(Duration::from_secs(10), |lines| { + lines.iter().filter(|line| **line == *wide).count() == 2 + }) + .await + .expect("waiting is not an error"); + assert_eq!(outcome, PaneWait::Arrived, "both printed lines were seen"); + + let within = Duration::from_millis(400); + let started = std::time::Instant::now(); + let outcome = tokio::time::timeout( + within * 10, + pane.wait_until(within, |lines| lines.iter().any(|line| *line == "absent")), + ) + .await + .expect("an unmet wait ends near its deadline, not long after it") + .expect("a deadline is not an error"); + let waited = started.elapsed(); + + assert_eq!(outcome, PaneWait::TimedOut); + assert!( + waited >= within, + "an unmet wait answers at its deadline, not before: {waited:?}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A predicate wait on a dead pane ends early, as `wait_for_text` does. +#[tokio::test] +async fn a_predicate_wait_ends_when_the_pane_dies() { + use libtmux::PaneWait; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("dying").await.expect("session"); + server + .cmd( + Command::new("set-option") + .arg("-g") + .arg("remain-on-exit") + .arg("on"), + ) + .await + .expect("panes remain after exit"); + let window = session + .new_window(NewWindowOptions::new("shortlived").command("true")) + .await + .expect("window"); + let pane = window.panes().await.expect("panes").remove(0); + + let started = std::time::Instant::now(); + let outcome = pane + .wait_until(Duration::from_secs(20), |_| false) + .await + .expect("waiting is not an error"); + let waited = started.elapsed(); + + assert_eq!(outcome, PaneWait::Dead); + assert!( + waited < Duration::from_secs(15), + "a dead pane ends the wait early rather than at the deadline: {waited:?}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A pane reached over a control connection waits over that connection. +/// +/// A look is a two-command chain, so this is the path where it has to travel +/// as one control-mode line and come back as one block. +#[cfg(feature = "control-mode")] +#[tokio::test] +async fn a_predicate_wait_runs_over_a_control_connection() { + use libtmux::PaneWait; + use libtmux::control::ControlMode; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard.server().new_session("routed").await.expect("session"); + let (sender, events) = ControlMode::attach(guard.server(), session.id()) + .await + .expect("a control connection") + .split(); + let routed = guard + .server() + .over_control_mode(&sender) + .await + .expect("a routed handle"); + let pane = routed.panes().await.expect("panes").remove(0); + + pane.send_line("printf 'ROUTED-%s\\n' 1") + .await + .expect("keys are sent"); + let outcome = pane + .wait_until(Duration::from_secs(10), |lines| { + lines.iter().any(|line| *line == "ROUTED-1") + }) + .await + .expect("waiting is not an error"); + assert_eq!(outcome, PaneWait::Arrived); + + events.shutdown().await.expect("the connection closes"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A quiet shorter than the polling interval costs one interval anyway. /// /// `wait_for_quiet` compares one look with the last, so the shortest silence From d50ce05557fbab5319377923db607592873c7c06 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 20:42:39 -0500 Subject: [PATCH 057/117] Docs(fix[examples]): Show Respawn and MenuItem on their own pages `just example-coverage-check`, a CI gate that needs nightly rustdoc, requires every crate-root type's own documentation to carry a runnable block: someone who lands on a type's page from a search should find it used there. `Respawn` had no example anywhere and `MenuItem` had one only on `new`, so the gate failed from the commit that introduced them. 80 of 80 types now pass. --- crates/libtmux/src/server/interactive.rs | 20 ++++++++++++++++++++ crates/libtmux/src/window.rs | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/libtmux/src/server/interactive.rs b/crates/libtmux/src/server/interactive.rs index 2cd89de6..4490cd1a 100644 --- a/crates/libtmux/src/server/interactive.rs +++ b/crates/libtmux/src/server/interactive.rs @@ -202,6 +202,26 @@ impl Server { /// /// The three parts were a `(String, String, String)` triple, which reads the /// same whichever order they are in and compiles whichever order they are in. +/// +/// # Examples +/// +/// ```no_run +/// # async fn menu(server: &libtmux::Server) -> Result<(), libtmux::Error> { +/// use libtmux::MenuItem; +/// +/// server +/// .display_menu( +/// None, +/// "session", +/// [ +/// MenuItem::new("Detach", "d", "detach-client"), +/// MenuItem::new("Kill", "k", "kill-session"), +/// ], +/// ) +/// .await?; +/// # Ok(()) +/// # } +/// ``` #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct MenuItem { label: OsString, diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 88f532f8..bd43d303 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -1416,6 +1416,18 @@ impl From<&TmuxText> for LayoutSpec { /// tmux refuses `respawn-pane` and `respawn-window` outright while the old /// command is alive unless `-k` says otherwise, so the choice is not a detail /// -- it decides whether a live process is killed. +/// +/// # Examples +/// +/// ```no_run +/// # async fn restart(pane: &mut libtmux::Pane) -> Result<(), libtmux::Error> { +/// use libtmux::Respawn; +/// +/// // Rerun the pane's own command, killing it first if it is still running. +/// pane.respawn(None::<&str>, Respawn::Replacing).await?; +/// # Ok(()) +/// # } +/// ``` #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Respawn { /// Kill whatever is running first. tmux's `-k`. From 39ff0271b2b468cf8367ac5bc90464d7eb7419d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 20:46:22 -0500 Subject: [PATCH 058/117] Docs(test): Say that real tmux is the supported test path, and how A downstream crate testing against this one needs tmux, and the README said how to depend on `test-support` but not how to get tmux into CI or why there is no way around it. A record/replay double was built and measured instead (branch `m1t-record-replay`, kept, not merged). It works -- replay reaches no tmux, and an unrecorded command fails loudly -- and it is not worth its cost here: 29 public items; every fixture invalidated by any change to the fields a listing asks for; the machine's hostname and checkout path recorded unless a test knows to suppress them; and arguments this crate redacts everywhere else written verbatim into files meant to be committed. Neither consumer crate needs it -- both already test through `TestServer`, at 49 call sites. So the README says it plainly: no mock, no replay, install tmux, and `LIBTMUX_TEST_TMUX` picks a release. --- crates/libtmux/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index ff2394b4..7216de36 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -620,6 +620,17 @@ best-effort cleanup even after the runtime has ended. On Linux, cleanup also sweeps processes by an exact environment marker through pidfds, so PID reuse cannot redirect a signal. +There is no mock and no replay mode: a test here talks to tmux, because what +tmux answers is the thing under test. CI needs tmux installed, which on a +GitHub Actions Ubuntu runner is one step: + +```yaml +- run: sudo apt-get install -y --no-install-recommends tmux +``` + +`LIBTMUX_TEST_TMUX` points the guard at a specific build when one release +matters. + The guarantees and their limits are set out in `docs/design.md`, which ships with the crate. From 04fa91f45c70effdb5400cd6afd778e332bc7512 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 20:54:17 -0500 Subject: [PATCH 059/117] Compat(fix[test]): Stop deadlines racing setup why: Supervisor tests gave their cases 0.5-3 s deadlines that armed as soon as the outer supervisor was validated. Under load a case needs 0.6-1.4 s (load 41, 10 cores) just to start its interpreters and spawn a descendant, so SIGTERM landed before the case wrote its control file and the test died reading it with FileNotFoundError. test_timed_out_case_has_outer_containment failed 3 of 6 runs at load 41; full runs at load 39-60 then failed four more tests the same way. just check stops at the first failure, so one flake hid every later gate. A deadline is either the subject or a hang bound, and each gets its own remedy. Where the deadline is the subject (timed-out, nested-timeout, topmost, hard-kill, forced-timeout), it must find the case in the state under test. arm_after holds it in after_owner_validation until a milestone: the descendant's ready marker, the nested supervisor's root, the outer supervisor stopped, the root the fault created, the paused case. Each caller asserts the milestone was reached, because a failed hook also ends the run with status 1 and would otherwise pass as a contained timeout. Elapsed bounds now start from the armed deadline. Where it is a hang bound (failed, nested-failure, dangling, frontier, untrusted, post-exit, same-name), four sat below waits their own probes perform: 2 s against spawn_runner_probe's 3 s, 2 s and 3 s against a nested supervisor's 5 s ready wait, 2 s against run_to_completion's 12 s. The other three wait on nothing, and same-name still failed at load 50. All seven inherit CASE_TIMEOUT. LIBTMUX_TEST_TIMEOUT_SCALE was the other option and is not used: unset it is a no-op, so it cannot fix a default just check, and scaling a deadline under test only moves the race. what: - Add arm_after and milestone_path; use them in the five tests whose subject is the deadline - Let the seven hang-bound cases inherit CASE_TIMEOUT --- scripts/test-tmux-format-compat-supervisor.py | 76 +++++++++++++++---- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/scripts/test-tmux-format-compat-supervisor.py b/scripts/test-tmux-format-compat-supervisor.py index fc29041b..579b49e4 100755 --- a/scripts/test-tmux-format-compat-supervisor.py +++ b/scripts/test-tmux-format-compat-supervisor.py @@ -723,6 +723,30 @@ def wait_for_path( raise AssertionError(f"timed out waiting for {path.name}; pid={process.pid}") +def arm_after( + milestone: t.Callable[[subprocess.Popen[str]], None], + armed: list[float], +) -> t.Callable[[subprocess.Popen[str]], None]: + """Hold a case deadline under test until the case reaches its subject. + + A failed milestone also ends the run with status 1, so a caller asserts + ``armed`` before reading that status as a contained timeout. + """ + + def hook(process: subprocess.Popen[str]) -> None: + milestone(process) + armed.append(time.monotonic()) + + return hook + + +def milestone_path( + path: pathlib.Path, +) -> t.Callable[[subprocess.Popen[str]], None]: + """Wait for a control file a case writes once it reaches its subject.""" + return lambda process: wait_for_path(process, path, timeout=WAIT_TIMEOUT) + + def finish(process: subprocess.Popen[str], started: float) -> Outcome: """Collect one bounded supervisor result.""" try: @@ -1757,7 +1781,6 @@ def test_frontier_timeout_defers_root_cleanup_to_enclosing_owner() -> None: try: status = run_selected_cases( ["test_kill_closure_discovers_adopted_frontier"], - case_timeout=3.0, case_cleanup_timeout=0.5, environment={ FRONTIER_TIMEOUT_CONTROL_ENV: os.fspath(control), @@ -2136,7 +2159,7 @@ def test_dangling_symlink_root_residue_is_rejected() -> None: control = pathlib.Path(raw) outcome = run_isolated_case( RUNNER_DANGLING_ROOT_PROBE, - timeout=2.0, + timeout=CASE_TIMEOUT, cleanup_timeout=0.5, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, ) @@ -2236,7 +2259,6 @@ def test_failed_case_has_outer_containment() -> None: control = pathlib.Path(raw) status = run_selected_cases( [RUNNER_FAILURE_PROBE], - case_timeout=2.0, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, ) root = pathlib.Path( @@ -2252,13 +2274,18 @@ def test_timed_out_case_has_outer_containment() -> None: with tempfile.TemporaryDirectory(prefix="libtmux-runner-timeout-") as raw: control = pathlib.Path(raw) case_timeout = 0.5 - started = time.monotonic() + armed: list[float] = [] status = run_selected_cases( [RUNNER_TIMEOUT_PROBE], case_timeout=case_timeout, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, + after_owner_validation=arm_after( + milestone_path(control / "ready"), + armed, + ), ) - elapsed = time.monotonic() - started + assert armed, "the probe never became ready, so no deadline was tested" + elapsed = time.monotonic() - armed[0] root = pathlib.Path( (control / "outer-root").read_text(encoding="utf-8").strip() ) @@ -2275,7 +2302,6 @@ def test_failed_case_cleans_nested_supervisor_root() -> None: try: status = run_selected_cases( [RUNNER_INNER_FAILURE_PROBE], - case_timeout=2.0, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, ) inner_root = pathlib.Path( @@ -2296,18 +2322,19 @@ def test_timed_out_case_cleans_nested_supervisor_root() -> None: """Timeout containment removes nested supervisor roots and identities.""" with tempfile.TemporaryDirectory(prefix="libtmux-runner-inner-timeout-") as raw: control = pathlib.Path(raw) - started = time.monotonic() + armed: list[float] = [] try: status = run_selected_cases( [RUNNER_INNER_TIMEOUT_PROBE], case_timeout=0.5, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, - after_owner_validation=lambda process: wait_for_path( - process, - control / "inner-root", + after_owner_validation=arm_after( + milestone_path(control / "inner-root"), + armed, ), ) - elapsed = time.monotonic() - started + assert armed, "the nested supervisor never started" + elapsed = time.monotonic() - armed[0] inner_root = pathlib.Path( (control / "inner-root").read_text(encoding="utf-8").strip() ) @@ -2327,15 +2354,26 @@ def test_topmost_failure_has_independent_containment() -> None: """A stopped outer supervisor cannot escape the top-level pidfd owner.""" with tempfile.TemporaryDirectory(prefix="libtmux-runner-topmost-") as raw: control = pathlib.Path(raw) - started = time.monotonic() + armed: list[float] = [] + + def outer_stopped(process: subprocess.Popen[str]) -> None: + wait_for_path(process, control / "inner-root", timeout=WAIT_TIMEOUT) + wait_for_exact_process_stopped( + process, + require_process_start_time(process), + timeout=WAIT_TIMEOUT, + ) + try: status = run_selected_cases( [RUNNER_TOPMOST_FAILURE_PROBE], case_timeout=3.0, case_cleanup_timeout=0.25, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, + after_owner_validation=arm_after(outer_stopped, armed), ) - elapsed = time.monotonic() - started + assert armed, "the outer supervisor never stopped" + elapsed = time.monotonic() - armed[0] inner_root = pathlib.Path( (control / "inner-root").read_text(encoding="utf-8").strip() ) @@ -2364,7 +2402,7 @@ def test_untrusted_case_cannot_report_an_unrelated_root() -> None: try: outcome = run_isolated_case( RUNNER_FORGED_ROOT_PROBE, - timeout=2.0, + timeout=CASE_TIMEOUT, cleanup_timeout=0.5, environment={ "TFC_RUNNER_CONTROL": os.fspath(control), @@ -2394,7 +2432,7 @@ def retain_direct_child(_process: subprocess.Popen[str]) -> None: try: outcome = run_isolated_case( RUNNER_CLEAN_EXIT_PROBE, - timeout=2.0, + timeout=CASE_TIMEOUT, cleanup_timeout=0.5, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, after_owner_validation=retain_direct_child, @@ -2468,6 +2506,9 @@ def test_hard_kill_after_child_root_creation_cleans_container() -> None: environment={ AFTER_ROOT_CREATE_CONTROL_ENV: os.fspath(control), }, + after_owner_validation=milestone_path( + control / "after-root-create-root" + ), outer_fault="after-root-create", ) except AssertionError as error: @@ -2484,6 +2525,7 @@ def assert_forced_case_timeout_cleans_root(test_name: str, pause_label: str) -> """Force-kill one allocated case and require its enclosing owner to clean.""" with tempfile.TemporaryDirectory(prefix="libtmux-runner-case-root-") as raw: control = pathlib.Path(raw) + armed: list[float] = [] status = run_selected_cases( [test_name], case_timeout=2.0, @@ -2492,7 +2534,9 @@ def assert_forced_case_timeout_cleans_root(test_name: str, pause_label: str) -> PAUSE_CASE_ROOT_ENV: pause_label, PAUSE_CASE_ROOT_CONTROL_ENV: os.fspath(control), }, + after_owner_validation=arm_after(milestone_path(control / "ready"), armed), ) + assert armed, "the case never paused after allocating its root" root = pathlib.Path( (control / "allocated-root").read_text(encoding="utf-8").strip() ) @@ -2524,7 +2568,7 @@ def test_same_name_root_replacement_is_not_deleted() -> None: try: run_isolated_case( RUNNER_ROOT_REPLACEMENT_PROBE, - timeout=2.0, + timeout=CASE_TIMEOUT, cleanup_timeout=0.5, environment={"TFC_RUNNER_CONTROL": os.fspath(control)}, ) From 91b2843053fe790406887851b42dd762ead5a2bb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 21:08:49 -0500 Subject: [PATCH 060/117] Tracing(feat[span]): Open one span per dispatch why: The tracing feature emitted a few debug events and no span. A dispatch's request_id could not tie it to its outcome or its caller, the subprocess supervisor reported completion from a task outside any span, and a dispatch over Server::over_control_mode emitted nothing. what: - DispatchSpan opens a DEBUG tmux_command span with request_id, subcommand, transport, outcome and error_kind; both executors run their dispatch inside it - Record outcome as success, exit, error (with the ErrorKind) or cancelled when the dispatch future is dropped first - Run the subprocess supervisor in the dispatch span, so its finished and failed events nest under it - No argument reaches the span; the subcommand is always public, and the events keep their redacted command field - Without the feature DispatchSpan is empty and run() is the DispatchFuture it replaced - Raise the declared tracing minimum to 0.1.36: Span::record takes the error_kind DebugValue by value only from that release - Test both transports against real tmux: one span per dispatch, every outcome, every event inside its span, no sentinel in any field - spawn_control still bypasses the executor by design and opens no span --- crates/libtmux/Cargo.toml | 2 +- crates/libtmux/README.md | 2 +- crates/libtmux/src/command.rs | 6 + .../libtmux/src/internal/control_executor.rs | 5 +- crates/libtmux/src/internal/executor.rs | 90 +++++++ crates/libtmux/src/internal/subprocess.rs | 8 +- crates/libtmux/tests/instrumentation.rs | 243 ++++++++++++++++++ 7 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 crates/libtmux/tests/instrumentation.rs diff --git a/crates/libtmux/Cargo.toml b/crates/libtmux/Cargo.toml index 371ec03d..d31928e8 100644 --- a/crates/libtmux/Cargo.toml +++ b/crates/libtmux/Cargo.toml @@ -130,7 +130,7 @@ serde_json = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } thiserror.workspace = true tokio = { workspace = true, features = ["io-util", "macros", "process", "rt", "sync", "time"] } -tracing = { version = "0.1.29", optional = true } +tracing = { version = "0.1.36", optional = true } [dev-dependencies] criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 7216de36..880afc4e 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -88,7 +88,7 @@ below, so a caller who wants them all does not have to list them. | `derive` | `#[derive(Filterable)]`, for filtering your own structs with the same expressions | | `serde` | Versioned serialization for `FilterExpr`, for sending expressions over a wire | | `schema` | JSON Schema for serialized plans and portable filter expressions | -| `tracing` | Sanitized command instrumentation | +| `tracing` | Sanitized command instrumentation: a `tmux_command` span per dispatch | | `test-support` | The real-tmux test guard, for your own tests | | `full` | Every capability above, but not `test-support` | diff --git a/crates/libtmux/src/command.rs b/crates/libtmux/src/command.rs index 8520cf30..d9550b24 100644 --- a/crates/libtmux/src/command.rs +++ b/crates/libtmux/src/command.rs @@ -627,6 +627,12 @@ impl CommandSummary { pub const fn sensitive_argument_count(&self) -> usize { self.sensitive_argument_count } + + /// The escaped subcommand, which is never a sensitive argument. + #[cfg(feature = "tracing")] + pub(crate) fn subcommand(&self) -> &str { + &self.subcommand + } } impl fmt::Display for CommandSummary { diff --git a/crates/libtmux/src/internal/control_executor.rs b/crates/libtmux/src/internal/control_executor.rs index 79cbc294..fce5d4f6 100644 --- a/crates/libtmux/src/internal/control_executor.rs +++ b/crates/libtmux/src/internal/control_executor.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::command::{CommandRequest, CommandResult, ProcessStatus}; use crate::control::ControlSender; -use crate::internal::executor::{DispatchFuture, Executor, ShutdownFuture}; +use crate::internal::executor::{DispatchFuture, DispatchSpan, Executor, ShutdownFuture}; /// Runs commands over a connection someone else opened. pub(crate) struct ControlModeExecutor { @@ -27,13 +27,14 @@ impl ControlModeExecutor { impl Executor for ControlModeExecutor { fn execute(&self, request: CommandRequest) -> DispatchFuture { let sender = Arc::clone(&self.sender); + let span = DispatchSpan::new(&request, "control"); let request_id = request.request_id(); let summary = request.summary().clone(); let sensitive_input = summary.sensitive_argument_count() > 0; let commands = request.command_count(); let line = request.into_control_line(); - DispatchFuture::new(async move { + span.run(async move { let Some(line) = line else { return Err(crate::Error::control_mode_unrepresentable()); }; diff --git a/crates/libtmux/src/internal/executor.rs b/crates/libtmux/src/internal/executor.rs index 4dd974f3..03299c35 100644 --- a/crates/libtmux/src/internal/executor.rs +++ b/crates/libtmux/src/internal/executor.rs @@ -27,6 +27,96 @@ impl Future for DispatchFuture { } } +/// The span one dispatch runs in, from admission to its outcome. +/// +/// No field may carry an argument: a sensitive one would reach every +/// subscriber. Without the `tracing` feature this is empty and [`Self::run`] +/// adds nothing to the dispatch. +pub(crate) struct DispatchSpan { + #[cfg(feature = "tracing")] + span: tracing::Span, +} + +impl DispatchSpan { + /// Open the span before `request` is consumed. + pub(crate) fn new(request: &CommandRequest, transport: &'static str) -> Self { + #[cfg(not(feature = "tracing"))] + let _ = (request, transport); + Self { + #[cfg(feature = "tracing")] + span: tracing::debug_span!( + "tmux_command", + request_id = request.request_id().get(), + subcommand = request.summary().subcommand(), + transport, + outcome = tracing::field::Empty, + error_kind = tracing::field::Empty, + ), + } + } + + /// Run `future` inside the span and record how it ended. + #[cfg_attr( + not(feature = "tracing"), + allow(clippy::unused_self, reason = "the span is compiled out") + )] + pub(crate) fn run( + self, + future: impl Future> + Send + 'static, + ) -> DispatchFuture { + #[cfg(feature = "tracing")] + { + use tracing::Instrument as _; + + let mut outcome = OutcomeRecorder { + span: self.span.clone(), + recorded: false, + }; + DispatchFuture::new( + async move { + let result = future.await; + outcome.record(&result); + result + } + .instrument(self.span), + ) + } + #[cfg(not(feature = "tracing"))] + DispatchFuture::new(future) + } +} + +/// Records a dispatch's outcome once, or `cancelled` if it is dropped first. +#[cfg(feature = "tracing")] +struct OutcomeRecorder { + span: tracing::Span, + recorded: bool, +} + +#[cfg(feature = "tracing")] +impl OutcomeRecorder { + fn record(&mut self, result: &Result) { + match result { + Ok(result) if result.success() => self.span.record("outcome", "success"), + Ok(_) => self.span.record("outcome", "exit"), + Err(error) => self + .span + .record("outcome", "error") + .record("error_kind", tracing::field::debug(error.kind())), + }; + self.recorded = true; + } +} + +#[cfg(feature = "tracing")] +impl Drop for OutcomeRecorder { + fn drop(&mut self) { + if !self.recorded { + self.span.record("outcome", "cancelled"); + } + } +} + #[must_use = "shutdown futures do nothing unless awaited"] pub(crate) struct ShutdownFuture(BoxedShutdown); diff --git a/crates/libtmux/src/internal/subprocess.rs b/crates/libtmux/src/internal/subprocess.rs index 4cbdaf62..de659f38 100644 --- a/crates/libtmux/src/internal/subprocess.rs +++ b/crates/libtmux/src/internal/subprocess.rs @@ -20,11 +20,11 @@ use tokio::time::Instant; use crate::limits::{DispatchLimits, OutputLimits}; #[cfg(feature = "tracing")] -use tracing::instrument::WithSubscriber as _; +use tracing::instrument::{Instrument as _, WithSubscriber as _}; use crate::Error; use crate::command::{CommandRequest, CommandResult, CommandSummary, ProcessStatus, RequestId}; -use crate::internal::executor::{DispatchFuture, Executor, ShutdownFuture}; +use crate::internal::executor::{DispatchFuture, DispatchSpan, Executor, ShutdownFuture}; use crate::internal::process::{ LaunchContext, ProcessAdmission, ProcessGroupGuard, validate_request, }; @@ -338,7 +338,7 @@ impl SubprocessExecutor { self.configuration.hooks.clone(), ); #[cfg(feature = "tracing")] - tokio::spawn(supervisor.with_current_subscriber()); + tokio::spawn(supervisor.in_current_span().with_current_subscriber()); #[cfg(not(feature = "tracing"))] tokio::spawn(supervisor); @@ -348,7 +348,7 @@ impl SubprocessExecutor { impl Executor for SubprocessExecutor { fn execute(&self, request: CommandRequest) -> DispatchFuture { - DispatchFuture::new(self.clone().run(request)) + DispatchSpan::new(&request, "subprocess").run(self.clone().run(request)) } fn shutdown(&self) -> ShutdownFuture { diff --git a/crates/libtmux/tests/instrumentation.rs b/crates/libtmux/tests/instrumentation.rs new file mode 100644 index 00000000..d82b404e --- /dev/null +++ b/crates/libtmux/tests/instrumentation.rs @@ -0,0 +1,243 @@ +//! The span each dispatch runs in, on both transports. + +#![cfg(all( + feature = "control-mode", + feature = "test-support", + feature = "tracing" +))] +// Helpers outside a test function are not covered by clippy.toml's +// in-test exemptions, and these files have them. +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt; +use std::os::unix::ffi::OsStringExt as _; +use std::sync::{Arc, Mutex}; + +use libtmux::control::ControlMode; +use libtmux::test::TestServer; +use libtmux::{Command, ErrorKind, Server}; +use tracing::span::{Attributes, Id, Record}; +use tracing::subscriber::Subscriber; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt as _}; +use tracing_subscriber::registry::LookupSpan; + +const SECRET: &str = "sentinel-span-secret"; + +#[derive(Debug, Default)] +struct Fields(BTreeMap<&'static str, String>); + +impl Fields { + fn get(&self, name: &str) -> Option<&str> { + self.0.get(name).map(String::as_str) + } +} + +impl tracing::field::Visit for Fields { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.0.insert(field.name(), value.to_owned()); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + self.0.insert(field.name(), format!("{value:?}")); + } +} + +/// One `tmux_command` span and the events recorded inside it. +#[derive(Debug, Default)] +struct Dispatch { + fields: Fields, + events: Vec, +} + +#[derive(Default)] +struct Recorded { + dispatches: Vec, + /// Events with no `tmux_command` span around them. + orphans: Vec, +} + +/// Collects dispatch spans by creation order, not by span ID, which the +/// registry reuses once a span closes. +#[derive(Clone, Default)] +struct Collector(Arc>); + +struct Slot(usize); + +impl LookupSpan<'a>> Layer for Collector { + fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) { + if attributes.metadata().name() != "tmux_command" { + return; + } + let mut fields = Fields::default(); + attributes.record(&mut fields); + let mut recorded = self.0.lock().unwrap(); + recorded.dispatches.push(Dispatch { + fields, + events: Vec::new(), + }); + let slot = Slot(recorded.dispatches.len() - 1); + context.span(id).unwrap().extensions_mut().insert(slot); + } + + fn on_record(&self, id: &Id, values: &Record<'_>, context: Context<'_, S>) { + let span = context.span(id).unwrap(); + if let Some(Slot(slot)) = span.extensions().get::() { + values.record(&mut self.0.lock().unwrap().dispatches[*slot].fields); + } + } + + fn on_event(&self, event: &tracing::Event<'_>, context: Context<'_, S>) { + let mut fields = Fields::default(); + event.record(&mut fields); + let slot = context + .event_span(event) + .and_then(|span| span.extensions().get::().map(|slot| slot.0)); + let mut recorded = self.0.lock().unwrap(); + match slot { + Some(slot) => recorded.dispatches[slot].events.push(fields), + None => recorded.orphans.push(fields), + } + } +} + +fn set_token(value: impl Into) -> Command { + Command::new("set-environment") + .arg("-g") + .arg("LIBTMUX_SPAN_TOKEN") + .sensitive_arg(value) +} + +/// Dispatch four commands, one per outcome, and return the error's kind. +async fn dispatch_each_outcome(server: &Server) -> ErrorKind { + let result = server.cmd(set_token(SECRET)).await.expect("dispatched"); + assert!(result.success(), "{result:?}"); + + let refused = Command::new("set-environment") + .arg("-t") + .arg("no-such-session-for-spans") + .arg("LIBTMUX_SPAN_TOKEN") + .sensitive_arg(SECRET); + let result = server.cmd(refused).await.expect("dispatched"); + assert!(!result.success(), "{result:?}"); + + // A NUL cannot reach a process and invalid UTF-8 cannot reach a control + // line, so each transport refuses this before tmux sees it. + let mut unsendable = SECRET.as_bytes().to_vec(); + unsendable.extend_from_slice(b"\xff\0"); + let error = server + .cmd(set_token(OsString::from_vec(unsendable))) + .await + .expect_err("refused before dispatch"); + + // Poll once, so the dispatch is in flight, then drop it. + let in_flight = server.cmd(set_token(SECRET)); + tokio::pin!(in_flight); + tokio::select! { + biased; + _ = &mut in_flight => panic!("a dispatch finished on its first poll"), + () = std::future::ready(()) => {} + } + + error.kind() +} + +#[tokio::test] +async fn each_dispatch_runs_in_one_span_on_either_transport() { + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server().clone(); + let session = server.new_session("spans").await.expect("a session"); + // The version probe is a dispatch of its own; take it before collecting. + server + .capabilities() + .await + .expect("tmux reports its version"); + let (sender, events) = ControlMode::attach(&server, session.id()) + .await + .expect("a control client attaches") + .split(); + let routed = server + .over_control_mode(&sender) + .await + .expect("a routed handle"); + + let collector = Collector::default(); + let subscriber = tracing_subscriber::registry().with(collector.clone()); + let default = tracing::subscriber::set_default(subscriber); + let process_kind = dispatch_each_outcome(&server).await; + let control_kind = dispatch_each_outcome(&routed).await; + drop(default); + events.shutdown().await.expect("control shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); + + let recorded = collector.0.lock().unwrap(); + let observed: Vec<_> = recorded + .dispatches + .iter() + .map(|dispatch| { + let fields = &dispatch.fields; + ( + fields.get("transport").unwrap(), + fields.get("subcommand").unwrap(), + fields.get("outcome"), + fields.get("error_kind"), + ) + }) + .collect(); + let process_kind = format!("{process_kind:?}"); + let control_kind = format!("{control_kind:?}"); + let mut expected = Vec::new(); + for (transport, kind) in [("subprocess", &process_kind), ("control", &control_kind)] { + expected.extend([ + (transport, "set-environment", Some("success"), None), + (transport, "set-environment", Some("exit"), None), + ( + transport, + "set-environment", + Some("error"), + Some(kind.as_str()), + ), + (transport, "set-environment", Some("cancelled"), None), + ]); + } + assert_eq!(observed, expected, "one span per dispatch"); + + for dispatch in &recorded.dispatches { + let request_id = dispatch.fields.get("request_id").unwrap(); + request_id.parse::().expect("a numeric request ID"); + for event in &dispatch.events { + assert_eq!(event.get("request_id"), Some(request_id), "{dispatch:?}"); + } + } + + // The supervisor that reports a finished process runs on its own task. + for dispatch in &recorded.dispatches[..2] { + let messages: Vec<_> = dispatch + .events + .iter() + .filter_map(|event| event.get("message")) + .collect(); + assert_eq!( + messages, + ["tmux command requested", "tmux command finished"], + "{dispatch:?}", + ); + } + assert!( + recorded.orphans.is_empty(), + "every dispatch event is inside its span: {:?}", + recorded.orphans, + ); + + for dispatch in &recorded.dispatches { + let values = dispatch + .events + .iter() + .chain([&dispatch.fields]) + .flat_map(|fields| fields.0.values()); + for value in values { + assert!(!value.contains(SECRET), "{dispatch:?}"); + } + } +} From 786da38cf9f80bd7f5cdd93335c07659db488d58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 21:19:17 -0500 Subject: [PATCH 061/117] Formats(feat[catalog]): List scroll_position why: A pane's copy-mode scroll offset reached a caller only through a second `display-message`, because the catalog held `scroll_position` as copy-mode-only with no list profile. tmux's `format_defaults_pane` runs the active mode's format callback for every pane row, at 3.2a, 3.4 and 3.7b alike, so `list-panes` already resolves it: `0` or more in copy mode, empty outside it. what: - Move `scroll_position` into the pane supplements: owner copy-mode, context pane, all profiles, empty policy absent. Required would fail hydration on every pane outside a mode. - `PaneFields::scroll_position` filters on it - Update the checked catalog row, the partition and plan counts, and the recorded list profiles - The other nine copy-mode fields stay catalogue-only --- crates/libtmux/docs/list-profiles.txt | 3 ++- crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/formats.rs | 2 +- crates/libtmux/src/formats/tests.rs | 28 +++++++++++------------ crates/libtmux/src/snapshot/tests.rs | 33 +++++++++++++++++---------- 6 files changed, 40 insertions(+), 29 deletions(-) diff --git a/crates/libtmux/docs/list-profiles.txt b/crates/libtmux/docs/list-profiles.txt index b336319e..07d8f614 100644 --- a/crates/libtmux/docs/list-profiles.txt +++ b/crates/libtmux/docs/list-profiles.txt @@ -57,7 +57,7 @@ windows (31 fields) window_width window_zoomed_flag -panes (72 fields) +panes (73 fields) alternate_saved_x alternate_saved_y bracket_paste_flag @@ -123,6 +123,7 @@ panes (72 fields) pane_y pane_z pane_zoomed_flag + scroll_position scroll_region_lower scroll_region_upper session_id diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 1fa13f2a..3270176f 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -371,7 +371,7 @@ returns the global `window_client_mode.default_format`. | `pane_z` | pane | pane | all | 3.7 | u32 | required | pane-info | | `pane_zoomed_flag` | pane | pane | all | 3.7 | bool | required | pane-info | | `pid` | server | none | all | 3.2a | u32 | required | catalog-only | -| `scroll_position` | copy-mode | copy-mode | none | 3.2a | i32 | required | catalog-only | +| `scroll_position` | copy-mode | pane | all | 3.2a | i32 | absent | pane-info | | `scroll_region_lower` | pane | pane | all | 3.2a | u32 | required | pane-info | | `scroll_region_upper` | pane | pane | all | 3.2a | u32 | required | pane-info | | `search_match` | copy-mode | copy-mode | none | 3.2a | text | absent | catalog-only | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 4e65bed6..24cc3e1c 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -2058,6 +2058,7 @@ struct_field libtmux::PaneFields::pane_x: libtmux::query::IntegerField struct_field libtmux::PaneFields::pane_z: libtmux::query::IntegerField struct_field libtmux::PaneFields::pane_zoomed_flag: libtmux::query::BoolField +struct_field libtmux::PaneFields::scroll_position: libtmux::query::IntegerField struct_field libtmux::PaneFields::scroll_region_lower: libtmux::query::IntegerField struct_field libtmux::PaneFields::scroll_region_upper: libtmux::query::IntegerField struct_field libtmux::PaneFields::synchronized_output_flag: libtmux::query::BoolField diff --git a/crates/libtmux/src/formats.rs b/crates/libtmux/src/formats.rs index c55a7a16..1865da76 100644 --- a/crates/libtmux/src/formats.rs +++ b/crates/libtmux/src/formats.rs @@ -380,6 +380,7 @@ macro_rules! format_catalog { (PANE_Y, pane_y, "pane_y", Pane, Pane, All, V3_7, I32, Required), (PANE_Z, pane_z, "pane_z", Pane, Pane, All, V3_7, U32, Required), (PANE_ZOOMED_FLAG, pane_zoomed_flag, "pane_zoomed_flag", Pane, Pane, All, V3_7, Bool, Required), + (SCROLL_POSITION, scroll_position, "scroll_position", CopyMode, Pane, All, V3_2A, I32, Absent), (SCROLL_REGION_LOWER, scroll_region_lower, "scroll_region_lower", Pane, Pane, All, V3_2A, U32, Required), (SCROLL_REGION_UPPER, scroll_region_upper, "scroll_region_upper", Pane, Pane, All, V3_2A, U32, Required), (SYNCHRONIZED_OUTPUT_FLAG, synchronized_output_flag, "synchronized_output_flag", Pane, Pane, All, V3_7, Bool, Required), @@ -437,7 +438,6 @@ macro_rules! format_catalog { (PANE_FORMAT, pane_format, "pane_format", ListRow, FormatType, All, V3_2A, Bool, Required), (PANE_MARKED_SET, pane_marked_set, "pane_marked_set", Server, Pane, All, V3_2A, Bool, Required), (PID, pid, "pid", Server, None, All, V3_2A, U32, Required), - (SCROLL_POSITION, scroll_position, "scroll_position", CopyMode, CopyMode, None, V3_2A, I32, Required), (SEARCH_MATCH, search_match, "search_match", CopyMode, CopyMode, None, V3_2A, Text, Absent), (SELECTION_END_X, selection_end_x, "selection_end_x", CopyMode, CopyMode, None, V3_2A, I32, Absent), (SELECTION_END_Y, selection_end_y, "selection_end_y", CopyMode, CopyMode, None, V3_2A, I32, Absent), diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index aaca24e8..727d3440 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -433,7 +433,7 @@ fn format_codec_normal_profiles_store_exact_identity_and_version_evidence() { SemanticOwner::Pane, RequiredContext::Pane, DecoderKind::PaneId, - 57, + 58, ), ( ListProfile::Clients, @@ -1316,18 +1316,18 @@ fn format_catalog_checked_parity_partitions_are_exact() { ); assert_eq!( count_tokens(rows.iter().map(|row| row.profiles)), - std::collections::BTreeMap::from([("all", 135), ("clients", 27), ("none", 17)]) + std::collections::BTreeMap::from([("all", 136), ("clients", 27), ("none", 16)]) ); assert_eq!( count_tokens(rows.iter().map(|row| row.empty)), - std::collections::BTreeMap::from([("absent", 31), ("available", 28), ("required", 120),]) + std::collections::BTreeMap::from([("absent", 32), ("available", 28), ("required", 119),]) ); assert_eq!( count_tokens(rows.iter().map(|row| row.placement)), std::collections::BTreeMap::from([ - ("catalog-only", 68), + ("catalog-only", 67), ("client-info", 22), - ("pane-info", 69), + ("pane-info", 70), ("session-info", 9), ("window-info", 11), ]) @@ -1356,11 +1356,11 @@ fn format_catalog_checked_parity_partitions_are_exact() { ("client", 27), ("command", 3), ("config", 1), - ("copy-mode", 10), + ("copy-mode", 9), ("format-type", 3), ("list-row", 1), ("none", 9), - ("pane", 70), + ("pane", 71), ("session", 22), ("window", 11), ("window-link", 19), @@ -1482,13 +1482,13 @@ fn format_catalog_info_orders_and_supplements_are_exact() { #[test] fn format_catalog_profile_plans_are_baseline_first_and_exact_once() { let versions = [ - (b"tmux 3.2a\n".as_slice(), [9, 11, 54, 20]), - (b"tmux 3.3\n".as_slice(), [9, 11, 57, 22]), - (b"tmux 3.6\n".as_slice(), [9, 11, 58, 22]), - (b"tmux 3.7\n".as_slice(), [9, 11, 69, 22]), - (b"tmux 3.8\n".as_slice(), [9, 11, 69, 22]), - (b"tmux master\n".as_slice(), [9, 11, 54, 20]), - (b"tmux next-3.8\n".as_slice(), [9, 11, 54, 20]), + (b"tmux 3.2a\n".as_slice(), [9, 11, 55, 20]), + (b"tmux 3.3\n".as_slice(), [9, 11, 58, 22]), + (b"tmux 3.6\n".as_slice(), [9, 11, 59, 22]), + (b"tmux 3.7\n".as_slice(), [9, 11, 70, 22]), + (b"tmux 3.8\n".as_slice(), [9, 11, 70, 22]), + (b"tmux master\n".as_slice(), [9, 11, 55, 20]), + (b"tmux next-3.8\n".as_slice(), [9, 11, 55, 20]), ]; let profiles = [ (ListProfile::Sessions, SESSION_INFO_DESCRIPTORS), diff --git a/crates/libtmux/src/snapshot/tests.rs b/crates/libtmux/src/snapshot/tests.rs index df6703a7..ffd0ae99 100644 --- a/crates/libtmux/src/snapshot/tests.rs +++ b/crates/libtmux/src/snapshot/tests.rs @@ -594,7 +594,7 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { flat, [pane_bottom, pane_left, pane_right, pane_top] ); - assert_stored_fields!(pane, i32, evidence, [pane_x, pane_y]); + assert_stored_fields!(pane, i32, evidence, [pane_x, pane_y, scroll_position]); assert_stored_fields!(pane, u8, evidence, [pane_dead_status, pane_pb_progress]); assert_stored_fields!( pane, @@ -847,6 +847,7 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { pane_y, pane_z, pane_zoomed_flag, + scroll_position, scroll_region_lower, scroll_region_upper, synchronized_output_flag, @@ -1004,7 +1005,15 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { i32, b"-7", -7_i32, - [pane_bottom, pane_left, pane_right, pane_top, pane_x, pane_y] + [ + pane_bottom, + pane_left, + pane_right, + pane_top, + pane_x, + pane_y, + scroll_position + ] ); assert_integer_handles!( pane_fields, @@ -2377,12 +2386,12 @@ fn snapshot_projection_value_shapes_accessors_and_traits_are_exact() { #[test] fn snapshot_projection_plans_have_exact_order_state_and_templates() { let cases = [ - (b"tmux 3.2a\n".as_slice(), 57, 15, 0), - (b"tmux 3.3\n".as_slice(), 60, 12, 0), - (b"tmux 3.6\n".as_slice(), 61, 11, 0), - (b"tmux 3.7\n".as_slice(), 72, 0, 0), - (b"tmux master\n".as_slice(), 57, 0, 15), - (b"tmux next-3.8\n".as_slice(), 57, 0, 15), + (b"tmux 3.2a\n".as_slice(), 58, 15, 0), + (b"tmux 3.3\n".as_slice(), 61, 12, 0), + (b"tmux 3.6\n".as_slice(), 62, 11, 0), + (b"tmux 3.7\n".as_slice(), 73, 0, 0), + (b"tmux master\n".as_slice(), 58, 0, 15), + (b"tmux next-3.8\n".as_slice(), 58, 0, 15), ]; let expected_window: Vec<_> = WINDOW_INFO_DESCRIPTORS .iter() @@ -2426,7 +2435,7 @@ fn snapshot_projection_plans_have_exact_order_state_and_templates() { .collect(); assert_eq!(pane_plan.profile(), ListProfile::Panes); assert_eq!(pane_plan.purpose(), PlanPurpose::Projection); - assert_eq!(pane_plan.planned().len(), 72); + assert_eq!(pane_plan.planned().len(), 73); assert_eq!(pane_plan.descriptors_for_test().len(), pane_selected); assert_descriptor_sequence(pane_plan.descriptors_for_test(), &expected_selected); assert_eq!( @@ -3038,12 +3047,12 @@ fn snapshot_projection_pane_trailing_descriptor_requires_finish() { &row, )); - assert_eq!(trailing.planned().len(), 73); - assert_eq!(trailing.descriptors_for_test().len(), 73); + assert_eq!(trailing.planned().len(), 74); + assert_eq!(trailing.descriptors_for_test().len(), 74); assert_eq!(error.kind(), FormatCodecErrorKind::PlanRowMismatch); assert_eq!(error.phase(), FormatCodecPhase::Decode); assert_eq!(error.row(), Some(0)); - assert_eq!(error.field(), Some(72)); + assert_eq!(error.field(), Some(73)); assert_eq!(error.field_name(), Some("client_mode_format")); assert_eq!(error.expected(), None); assert_eq!(error.offset(), None); From 5d79033f3a4ebb05e1199e3134af71e94234b04f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 21:43:37 -0500 Subject: [PATCH 062/117] Snapshot(feat[get]): Read any field by its handle why: Every snapshot field had a public filter handle and a typed getter, but the getter was crate-private, so a field without a hand-written accessor could be filtered on and not read. tmux-mcp's snapshot_pane spent a display-message on cursor_x, cursor_y, pane_mode and scroll_position, all of which the pane listing already carried. The public accessors that did exist returned Option, which cannot say whether tmux predates a field or reported nothing: a pane straight from Pane::split reports no pane_current_path until its process starts. That is tmux answering empty, not a thinner creation plan; split and listing use the same plan. Reading through the handle beat a generated accessor per field. The handles are already public with their types, so reading adds one method per object, not about 110; a generated `pane_active()` beside `is_active()` would name one field twice for 45 fields; and a generated method's return type would encode the catalog's empty policy and version floor, so each floor correction would change a signature. The cost is that an always-present field also reads as `Availability`. what: - Add `Pane::get`, `Window::get`, `Session::get` and `Client::get`, bounded by the sealed `query::ReadField`, one impl per handle type a target uses - Make `Availability` public and `#[non_exhaustive]` with `as_ref`, `available` and `is_available`; the info structs stay private - Give `TextField` a value type parameter defaulting to `TmuxText`, so the three ID handles read as `&PaneId`, `&WindowId`, `&SessionId` - Generate the read dispatch from the same field list as the handles; `unread` fails to compile when a handle type has no impl, and its test names a handle whose read finds no stored field - Prove the four tmux-mcp fields read from a listed pane with no command and agree with `display-message`, in and out of copy mode - Record the public API, the parity row, the changelog and the migration note for the ID handle types --- crates/libtmux/docs/design.md | 11 +- crates/libtmux/docs/migration.md | 16 ++ crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 63 ++++- crates/libtmux/src/client.rs | 44 ++- crates/libtmux/src/lib.rs | 2 +- crates/libtmux/src/pane.rs | 62 ++++- crates/libtmux/src/query.rs | 2 + crates/libtmux/src/query/fields.rs | 29 +- crates/libtmux/src/session.rs | 50 +++- crates/libtmux/src/snapshot.rs | 363 ++++++++++++++++++++++--- crates/libtmux/src/snapshot/tests.rs | 118 +++++++- crates/libtmux/src/window.rs | 52 +++- crates/libtmux/tests/command_budget.rs | 89 ++++++ 14 files changed, 837 insertions(+), 66 deletions(-) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 8a035aff..f0cc7594 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -352,11 +352,12 @@ permanently attached to stale parents. ### Private snapshots and future refresh -The current `SessionInfo`, `WindowInfo`, `PaneInfo`, `ClientInfo`, -`Availability`, projections, format plans, and built-in fields are -crate-private. No public hierarchy handle or listing can return them yet. The -discovery slice will promote only the values needed by a public consumer and -define handle refresh around complete owned snapshots. +The `SessionInfo`, `WindowInfo`, `PaneInfo`, and `ClientInfo` structs, +projections, format plans, and built-in fields are crate-private. Each field +is readable through the handle that filters it: `Pane::get(fields.cursor_x)` +returns `Availability`, so one name serves filtering and reading and no +per-field method commits the crate to a field's storage shape. Hand-written +getters remain for the common fields. Inside that private kernel, known IDs, indices, flags, sizes, timestamps, and enums use typed fields. `TmuxText` retains stored bytes exactly and exposes diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index c287d701..b944f0c5 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -16,6 +16,22 @@ for: tmux refuses the respawn while the old command is alive. `Server::display_menu` takes `MenuItem::new(label, key, command)` in place of a `(String, String, String)` triple. +## ID filter handles carry their ID type + +`PaneFields::pane_id`, `WindowFields::window_id` and +`SessionFields::session_id` are `TextField`, +`TextField` and `TextField`. Filtering +through them is unchanged. Only code that spells the handle's type changes: + +``` +use libtmux::query::{Filterable as _, TextField}; +use libtmux::{Pane, PaneId}; + +// was: let handle: TextField = Pane::filter_fields().pane_id; +let handle: TextField = Pane::filter_fields().pane_id; +let _ = handle.eq("%1"); +``` + ## The `_or_empty` listing twins are gone Replace `x_or_empty().await` with `x().await.unwrap_or_default()`: diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 3270176f..e916011e 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -193,7 +193,7 @@ preserves the buffer on that branch. | No direct Python equivalent: raw text value | Python format fields are decoded strings, so invalid bytes are replacement-escaped before object construction. | Public `TmuxText` provides raw, strict, and named lossy views with bytewise traits and payload-free `Debug`; covered by [`tests/tmux_text.rs`](../tests/tmux_text.rs). | Formats, snapshots, winlinks, and queries | `verified` | | `SCOPES_BY_LIST_CMD` | Mutable map of format scopes supported by each `list-*` command. Client listings add client scope; buffer, context, and event scopes are excluded. | Private `FormatDescriptor::profiles`, `owner`, and `required_context` provide checked static metadata; covered by catalog tests in [`src/formats/tests.rs`](../src/formats/tests.rs). `Server::sessions` and `Server::clients` consume that metadata. | Formats, snapshots, winlinks, and queries | `verified` | | `FIELD_VERSION` | Mutable minimum-version overrides. Unlisted fields default to 3.2a. | Private `FormatDescriptor::minimum_release` gates each field; covered by release-selection and catalog tests in [`src/formats/tests.rs`](../src/formats/tests.rs). `Server::sessions` consumes those release floors. | Formats, snapshots, winlinks, and queries | `verified` | -| `Obj(server, **fields)` | Public dataclass constructor with one `Server` and 178 optional string fields. Missing, empty, unsupported, and out-of-scope values all collapse to `None`; generated equality and repr cover the whole snapshot. | Crate-private hydration into `SessionInfo`, `WindowInfo`, `PaneInfo`, and `ClientInfo`; typed fields retain explicit availability while parser slots remain transient. Public snapshots wait for listing APIs. | Formats, snapshots, winlinks, and queries | `planned` | +| `Obj(server, **fields)` | Public dataclass constructor with one `Server` and 178 optional string fields. Missing, empty, unsupported, and out-of-scope values all collapse to `None`; generated equality and repr cover the whole snapshot. | `Pane::get`, `Window::get`, `Session::get`, and `Client::get` read every modeled field by the `query::ReadField` handle that filters it, without a tmux call. `Availability` keeps an unsupported release, an unproven development build, and an absent value apart where Python collapses them to `None`. The `SessionInfo`, `WindowInfo`, `PaneInfo`, and `ClientInfo` structs stay crate-private. Covered by [`src/snapshot/tests.rs`](../src/snapshot/tests.rs) and [`tests/command_budget.rs`](../tests/command_budget.rs). | Formats, snapshots, winlinks, and queries | `implemented` | | Parent-row active-child fields | Session rows can contain current-window and active-pane values; Window rows can contain active-pane values; Client rows contain attached Session, current Window, and active Pane snapshots. | Document the same projection while keeping identity and live relationships separate. | Formats, snapshots, winlinks, and queries | `planned` | | `get_output_format` private plan construction | Cached `(field_names, format_string)` pair filtered by scope and detected version. Unknown list commands fall back to universal/session/window/pane scopes. | Private `FormatPlan::for_profile`, `for_descriptors`, and `template` bind descriptor order and version selection. Closed `ListProfile::{Sessions, Windows, Panes, Clients}` inputs make unknown command strings unrepresentable; covered by [`src/formats/tests.rs`](../src/formats/tests.rs). `Server::sessions` consumes the resulting plan. | Formats, snapshots, winlinks, and queries | `verified` | | `get_output_format` public selector | The Python helper is callable directly and accepts list-command strings. | A public format-token or profile selector remains deferred until discovery has a consumer. | Discovery, traversal, refresh, and environment resolution | `planned` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 24cc3e1c..6f78968f 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -15,6 +15,7 @@ assoc_const libtmux::query::FilterEnum::FILTER_VARIANTS: &'static [&'static str] assoc_const libtmux::query::Filterable::FILTER_TARGET: &'static str assoc_type libtmux::plan::Operation::Creates: libtmux::plan::FromStep assoc_type libtmux::query::Filterable::Fields +assoc_type libtmux::query::ReadField::Value<'a> where Target: 'a constant libtmux::option_names::ACTIVITY_ACTION: &str constant libtmux::option_names::AFTER_BIND_KEY: &str constant libtmux::option_names::AFTER_CAPTURE_PANE: &str @@ -243,6 +244,7 @@ constant libtmux::since::PANE_BORDER_STYLE_PER_PANE: libtmux::ReleaseVersion constant libtmux::since::PROMPT_HISTORY: libtmux::ReleaseVersion constant libtmux::since::SERVER_ACCESS: libtmux::ReleaseVersion enum libtmux::AccessMode +enum libtmux::Availability enum libtmux::ChannelWait enum libtmux::Chooser enum libtmux::ControlModeErrorKind @@ -297,6 +299,9 @@ enum libtmux::test::TestServerErrorKind function libtmux::AccessRule::mode: const fn(&self) -> libtmux::AccessMode function libtmux::AccessRule::name: fn(&self) -> &str function libtmux::AccessRule::principal: const fn(&self) -> libtmux::Principal +function libtmux::Availability::as_ref: const fn(&self) -> libtmux::Availability<&T> +function libtmux::Availability::available: fn(self) -> Option +function libtmux::Availability::is_available: const fn(&self) -> bool function libtmux::CaptureOptions::end: const fn(self, line: i32) -> Self function libtmux::CaptureOptions::escape_sequences: const fn(self) -> Self function libtmux::CaptureOptions::history: const fn() -> Self @@ -311,6 +316,7 @@ function libtmux::Client::attached_session: async fn(&self) -> Result Result, libtmux::Error> function libtmux::Client::created: fn(&self) -> i64 function libtmux::Client::detach: async fn(self) -> Result<(), libtmux::Error> +function libtmux::Client::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> function libtmux::Client::height: fn(&self) -> Option function libtmux::Client::is_control_mode: fn(&self) -> bool function libtmux::Client::is_own: fn(&self) -> bool @@ -419,6 +425,7 @@ function libtmux::Pane::exit_mode: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Pane::format: async fn(&self, template: &str) -> Result function libtmux::Pane::from_env: async fn(server: &libtmux::Server) -> Result, libtmux::Error> function libtmux::Pane::from_env_value: async fn(server: &libtmux::Server, value: Option>) -> Result, libtmux::Error> +function libtmux::Pane::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> function libtmux::Pane::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Pane::height: fn(&self) -> u32 function libtmux::Pane::hook: async fn(&self, name: &str) -> Result, libtmux::Error> @@ -605,6 +612,7 @@ function libtmux::Session::environment_all: async fn(&self) -> Result Result function libtmux::Session::from_env: async fn(server: &libtmux::Server) -> Result, libtmux::Error> function libtmux::Session::from_env_value: async fn(server: &libtmux::Server, value: Option>) -> Result, libtmux::Error> +function libtmux::Session::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> function libtmux::Session::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Session::hide_environment: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Session::hook: async fn(&self, name: &str) -> Result, libtmux::Error> @@ -682,6 +690,7 @@ function libtmux::Window::focus_direction: async fn(&self, direction: libtmux::P function libtmux::Window::format: async fn(&self, template: &str) -> Result function libtmux::Window::from_env: async fn(server: &libtmux::Server) -> Result, libtmux::Error> function libtmux::Window::from_env_value: async fn(server: &libtmux::Server, value: Option>) -> Result, libtmux::Error> +function libtmux::Window::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> function libtmux::Window::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Window::has_activity: const fn(&self) -> bool function libtmux::Window::has_bell: const fn(&self) -> bool @@ -1700,6 +1709,30 @@ impl libtmux::query::Filterable for libtmux::Session impl libtmux::query::Filterable for libtmux::SessionTree impl libtmux::query::Filterable for libtmux::Window impl libtmux::query::Filterable for libtmux::WindowTree +impl libtmux::query::ReadField for libtmux::query::BoolField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::TextField +impl libtmux::query::ReadField for libtmux::query::BoolField +impl libtmux::query::ReadField for libtmux::query::EnumField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::TextField +impl libtmux::query::ReadField for libtmux::query::TextField +impl libtmux::query::ReadField for libtmux::query::BoolField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::TextField +impl libtmux::query::ReadField for libtmux::query::TextField +impl libtmux::query::ReadField for libtmux::query::BoolField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::IntegerField +impl libtmux::query::ReadField for libtmux::query::TextField +impl libtmux::query::ReadField for libtmux::query::TextField impl<'de, T: libtmux::query::Filterable> Deserialize<'de> for libtmux::query::FilterExpr impl<'de, T> Deserialize<'de> for libtmux::plan::Slot impl<'de> Deserialize<'de> for libtmux::plan::CapturePane @@ -1750,12 +1783,22 @@ impl Copy for libtmux::query::IntegerField impl Debug for libtmux::query::IntegerField impl Eq for libtmux::query::IntegerField impl PartialEq for libtmux::query::IntegerField +impl Clone for libtmux::query::TextField +impl Copy for libtmux::query::TextField +impl Debug for libtmux::query::TextField +impl Eq for libtmux::query::TextField +impl PartialEq for libtmux::query::TextField +impl Clone for libtmux::Availability impl Clone for libtmux::SparseValues +impl Copy for libtmux::Availability +impl Debug for libtmux::Availability impl Debug for libtmux::SparseValues impl Default for libtmux::SparseValues +impl Eq for libtmux::Availability impl Eq for libtmux::SparseValues impl> From for libtmux::NewSessionOptions impl> From for libtmux::NewWindowOptions +impl PartialEq for libtmux::Availability impl PartialEq for libtmux::SparseValues impl Error for libtmux::ScopeError impl Debug for libtmux::ScopeError @@ -1766,24 +1809,19 @@ impl libtmux::query::Matcher for libtmux::quer impl Clone for libtmux::plan::Slot impl Clone for libtmux::query::BoolField impl Clone for libtmux::query::FilterExpr -impl Clone for libtmux::query::TextField impl Copy for libtmux::plan::Slot impl Copy for libtmux::query::BoolField -impl Copy for libtmux::query::TextField impl Debug for libtmux::plan::Slot impl Debug for libtmux::query::BoolField impl Debug for libtmux::query::FilterExpr -impl Debug for libtmux::query::TextField impl Eq for libtmux::plan::Slot impl Eq for libtmux::query::BoolField impl Eq for libtmux::query::FilterExpr -impl Eq for libtmux::query::TextField impl From> for libtmux::SparseValues impl JsonSchema for libtmux::plan::Slot impl PartialEq for libtmux::plan::Slot impl PartialEq for libtmux::query::BoolField impl PartialEq for libtmux::query::FilterExpr -impl PartialEq for libtmux::query::TextField impl Serialize for libtmux::plan::Slot impl libtmux::plan::FromStep for libtmux::plan::Slot impl Debug for libtmux::ClientFields @@ -1874,11 +1912,12 @@ struct libtmux::query::IntegerField struct libtmux::query::ManyRelation struct libtmux::query::MultipleItemsError struct libtmux::query::OneRelation -struct libtmux::query::TextField +struct libtmux::query::TextField struct libtmux::test::RetryTimeout struct libtmux::test::TestServer struct libtmux::test::TestServerBuilder struct libtmux::test::TestServerError +struct_field libtmux::Availability::Available::0: T struct_field libtmux::CapturedLine::starts_output: bool struct_field libtmux::CapturedLine::starts_prompt: bool struct_field libtmux::CapturedLine::text: libtmux::TmuxText @@ -2029,7 +2068,7 @@ struct_field libtmux::PaneFields::pane_fg: libtmux::query::TextField struct_field libtmux::PaneFields::pane_flags: libtmux::query::TextField struct_field libtmux::PaneFields::pane_floating_flag: libtmux::query::BoolField struct_field libtmux::PaneFields::pane_height: libtmux::query::IntegerField -struct_field libtmux::PaneFields::pane_id: libtmux::query::TextField +struct_field libtmux::PaneFields::pane_id: libtmux::query::TextField struct_field libtmux::PaneFields::pane_in_mode: libtmux::query::IntegerField struct_field libtmux::PaneFields::pane_index: libtmux::query::IntegerField struct_field libtmux::PaneFields::pane_input_off: libtmux::query::BoolField @@ -2075,7 +2114,7 @@ struct_field libtmux::ScopeError::OperationAndCleanup::operation: E struct_field libtmux::SessionFields::session_activity: libtmux::query::IntegerField struct_field libtmux::SessionFields::session_attached: libtmux::query::IntegerField struct_field libtmux::SessionFields::session_created: libtmux::query::IntegerField -struct_field libtmux::SessionFields::session_id: libtmux::query::TextField +struct_field libtmux::SessionFields::session_id: libtmux::query::TextField struct_field libtmux::SessionFields::session_last_attached: libtmux::query::IntegerField struct_field libtmux::SessionFields::session_many_attached: libtmux::query::BoolField struct_field libtmux::SessionFields::session_name: libtmux::query::TextField @@ -2091,7 +2130,7 @@ struct_field libtmux::WindowFields::window_activity: libtmux::query::IntegerFiel struct_field libtmux::WindowFields::window_cell_height: libtmux::query::IntegerField struct_field libtmux::WindowFields::window_cell_width: libtmux::query::IntegerField struct_field libtmux::WindowFields::window_height: libtmux::query::IntegerField -struct_field libtmux::WindowFields::window_id: libtmux::query::TextField +struct_field libtmux::WindowFields::window_id: libtmux::query::TextField struct_field libtmux::WindowFields::window_layout: libtmux::query::TextField struct_field libtmux::WindowFields::window_name: libtmux::query::TextField struct_field libtmux::WindowFields::window_panes: libtmux::query::IntegerField @@ -2189,9 +2228,15 @@ trait libtmux::query::FilterSchema: libtmux::query::Filterable trait libtmux::query::Filterable: Sized trait libtmux::query::Matcher trait libtmux::query::QueryIteratorExt: Iterator + Sized +trait libtmux::query::ReadField: Copy + sealed::Sealed +trait libtmux::snapshot::sealed::Sealed type_alias libtmux::IndexedHooks = libtmux::SparseValues variant libtmux::AccessMode::ReadOnly variant libtmux::AccessMode::Write +variant libtmux::Availability::Absent +variant libtmux::Availability::Available +variant libtmux::Availability::Unproven +variant libtmux::Availability::Unsupported variant libtmux::ChannelWait::Signalled variant libtmux::ChannelWait::TimedOut variant libtmux::Chooser::Buffer diff --git a/crates/libtmux/src/client.rs b/crates/libtmux/src/client.rs index f38480d4..34b4b2a1 100644 --- a/crates/libtmux/src/client.rs +++ b/crates/libtmux/src/client.rs @@ -10,10 +10,10 @@ use crate::formats::TmuxText; use crate::internal::core::Core; use crate::internal::listing; #[cfg(feature = "query")] -use crate::query::{FilterSchema, Filterable}; -#[cfg(feature = "query")] -use crate::snapshot::ClientFields; +use crate::query::{FilterSchema, Filterable, ReadField}; use crate::snapshot::ClientInfo; +#[cfg(feature = "query")] +use crate::snapshot::{Availability, ClientFields, FieldRef}; use crate::target::ServerIdentity; use crate::{Command, Error, ObjectKind}; @@ -501,6 +501,44 @@ impl fmt::Debug for Client { } } +#[cfg(feature = "query")] +impl Client { + /// Read one field of this client's snapshot, named by the handle that + /// filters it. + /// + /// Every field in [`ClientFields`] reads this way, including those with no + /// getter of their own. Nothing is sent to tmux, so the value is as old as + /// the snapshot. The result says why a field holds no value: see + /// [`Availability`]. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// use libtmux::Client; + /// use libtmux::query::Filterable as _; + /// + /// let fields = Client::filter_fields(); + /// for client in server.clients().await? { + /// // The key table a client is in: `prefix` right after the prefix key. + /// if let Some(table) = client.get(fields.client_key_table).available() { + /// println!("{client}: {}", table.to_string_lossy()); + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn get>(&self, field: F) -> Availability> { + field.__read(self).unwrap_or(Availability::Absent) + } + + /// Return the stored field tmux names `name`. + pub(crate) fn stored(&self, name: &str) -> Option>> { + self.info.stored(name) + } +} + /// Filtering a client uses the same handles as the snapshot beneath it. /// /// Matching and validation delegate to that snapshot, so an expression can diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 291ca2ad..d485031b 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -374,7 +374,7 @@ pub use server::{ #[cfg(feature = "query")] pub use server::{SessionTreeFields, WindowTreeFields}; pub use session::{EnvironmentEntry, NewWindowOptions, Session, WindowPlacement}; -pub use snapshot::PaneProgressState; +pub use snapshot::{Availability, PaneProgressState}; #[cfg(feature = "query")] pub use snapshot::{ClientFields, PaneFields, SessionFields, WindowFields}; pub use target::{ diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 0663ffc2..8309bddc 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -10,11 +10,11 @@ use crate::formats::TmuxText; use crate::internal::core::Core; use crate::internal::listing; #[cfg(feature = "query")] -use crate::query::{FilterSchema, Filterable}; +use crate::query::{FilterSchema, Filterable, ReadField}; use crate::session::Session; use crate::snapshot::PaneProjection; #[cfg(feature = "query")] -use crate::snapshot::{PaneFields, PaneInfo}; +use crate::snapshot::{Availability, FieldRef, PaneFields, PaneInfo}; use crate::target::{PaneId, ServerIdentity, SessionId, WindowId}; use crate::version::TmuxVersion; use crate::window::Respawn; @@ -177,6 +177,11 @@ impl Pane { } /// Return the pane's working directory. + /// + /// `None` when tmux reported none, which a pane straight from + /// [`Self::split`] can do until its process has started; [`Self::refresh`] + /// asks again. Reading `pane_current_path` through [`Self::get`] says + /// which kind of missing it is. #[must_use] pub fn current_path(&self) -> Option<&TmuxText> { self.projection.pane().pane_current_path().available() @@ -1252,6 +1257,59 @@ impl fmt::Debug for Pane { } } +#[cfg(feature = "query")] +impl Pane { + /// Read one field of this pane's snapshot, named by the handle that + /// filters it. + /// + /// Every field a pane listing fetches reads this way, including those + /// with no getter of their own. Nothing is sent to tmux, so the value is + /// as old as the snapshot; [`Self::refresh`] renews it. The result says + /// why a field holds no value: see [`Availability`]. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// use libtmux::query::Filterable as _; + /// use libtmux::{Availability, Pane, TmuxText}; + /// + /// let guard = libtmux::test::TestServer::new().await?; + /// let session = guard.server().new_session("read").await?; + /// let mut pane = session.panes().await?.remove(0); + /// let fields = Pane::filter_fields(); + /// + /// // Outside copy mode tmux reports no scroll position at all. + /// assert_eq!(pane.get(fields.scroll_position), Availability::Absent); + /// + /// pane.copy_mode().await?; + /// pane.refresh().await?; + /// assert_eq!(pane.get(fields.scroll_position), Availability::Available(0)); + /// assert_eq!( + /// pane.get(fields.pane_mode), + /// Availability::Available(&TmuxText::from("copy-mode")), + /// ); + /// assert_eq!(pane.get(fields.pane_id), Availability::Available(pane.id())); + /// + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn get>(&self, field: F) -> Availability> { + field.__read(self).unwrap_or(Availability::Absent) + } + + /// Return the stored field tmux names `name`. + pub(crate) fn stored(&self, name: &str) -> Option>> { + self.projection.pane().stored(name) + } +} + /// Filtering a pane uses the same handles as the snapshot beneath it. /// /// Matching and validation delegate to that snapshot, so an expression can diff --git a/crates/libtmux/src/query.rs b/crates/libtmux/src/query.rs index 54ccb795..aeaac9ab 100644 --- a/crates/libtmux/src/query.rs +++ b/crates/libtmux/src/query.rs @@ -391,6 +391,8 @@ use matching::{ mod schema; pub use schema::FilterSchema; +pub use crate::snapshot::ReadField; + /// A predicate that evaluates a borrowed candidate. /// /// Functions and closures with the same signature implement this trait. diff --git a/crates/libtmux/src/query/fields.rs b/crates/libtmux/src/query/fields.rs index 5253865c..bf3566ae 100644 --- a/crates/libtmux/src/query/fields.rs +++ b/crates/libtmux/src/query/fields.rs @@ -8,6 +8,7 @@ use super::{ PredicateData, RelationPredicate, RelationQuantifier, SetOperator, SetPredicate, TextOperator, TextPredicate, }; +use crate::TmuxText; /// A typed handle for a portable text field on `T`. /// @@ -17,6 +18,10 @@ use super::{ /// array. A scalar string is not substring membership: use /// [`TextField::contains`] for that operation. /// +/// `V` is the type a snapshot reads the field as: [`TmuxText`] unless the +/// field is a typed ID such as [`crate::PaneId`]. Filtering matches the text +/// either way. +/// /// # Examples /// /// ``` @@ -27,9 +32,9 @@ use super::{ /// let field: TextField = __private::text_field("row", "name"); /// let _ = field.contains("build"); /// ``` -pub struct TextField { +pub struct TextField { pub(super) id: FieldId, - pub(super) marker: PhantomData T>, + pub(super) marker: PhantomData (T, V)>, } /// A typed handle for a portable boolean field on `T`. @@ -245,6 +250,14 @@ impl OneRelation { macro_rules! impl_handle_traits { ($name:ident<$($type_parameter:ident),+>) => { + impl<$($type_parameter),+> $name<$($type_parameter),+> { + /// Return the field name this handle names. + #[allow(dead_code, reason = "only snapshot reads ask a handle its name")] + pub(crate) const fn field_name(self) -> &'static str { + self.id.field + } + } + impl<$($type_parameter),+> Copy for $name<$($type_parameter),+> {} impl<$($type_parameter),+> Clone for $name<$($type_parameter),+> { @@ -273,14 +286,22 @@ macro_rules! impl_handle_traits { }; } -impl_handle_traits!(TextField); +impl_handle_traits!(TextField); impl_handle_traits!(BoolField); impl_handle_traits!(IntegerField); impl_handle_traits!(EnumField); impl_handle_traits!(ManyRelation); impl_handle_traits!(OneRelation); -impl TextField { +impl TextField { + /// Build a handle whose snapshot value is `V`. + pub(crate) const fn typed(target: &'static str, field: &'static str) -> Self { + Self { + id: FieldId { target, field }, + marker: PhantomData, + } + } + fn expression( self, operator: TextOperator, diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index 5a6e1d81..b4c2b13d 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -13,10 +13,10 @@ use crate::internal::listing::{self, Pushdown as _}; use crate::internal::scoped; use crate::pane::Pane; #[cfg(feature = "query")] -use crate::query::{FilterSchema, Filterable}; -#[cfg(feature = "query")] -use crate::snapshot::SessionFields; +use crate::query::{FilterSchema, Filterable, ReadField}; use crate::snapshot::SessionInfo; +#[cfg(feature = "query")] +use crate::snapshot::{Availability, FieldRef, SessionFields}; use crate::target::{ServerIdentity, SessionId}; use crate::window::Window; use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; @@ -850,6 +850,50 @@ impl fmt::Debug for Session { } } +#[cfg(feature = "query")] +impl Session { + /// Read one field of this session's snapshot, named by the handle that + /// filters it. + /// + /// Every field in [`SessionFields`] reads this way, including those with no + /// getter of their own. Nothing is sent to tmux, so the value is as old as + /// the snapshot. The result says why a field holds no value: see + /// [`Availability`]. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// use libtmux::query::Filterable as _; + /// use libtmux::{Availability, Session}; + /// + /// let guard = libtmux::test::TestServer::new().await?; + /// let session = guard.server().new_session("read").await?; + /// let fields = Session::filter_fields(); + /// + /// // Nobody is attached, so nobody is attached twice. + /// assert_eq!(session.get(fields.session_many_attached), Availability::Available(false)); + /// assert_eq!(session.get(fields.session_id), Availability::Available(session.id())); + /// + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn get>(&self, field: F) -> Availability> { + field.__read(self).unwrap_or(Availability::Absent) + } + + /// Return the stored field tmux names `name`. + pub(crate) fn stored(&self, name: &str) -> Option>> { + self.info.stored(name) + } +} + /// Filtering a session uses the same handles as the snapshot beneath it. /// /// Matching and validation delegate to that snapshot, so an expression can diff --git a/crates/libtmux/src/snapshot.rs b/crates/libtmux/src/snapshot.rs index 23b6a84a..bda4ea60 100644 --- a/crates/libtmux/src/snapshot.rs +++ b/crates/libtmux/src/snapshot.rs @@ -19,30 +19,67 @@ use crate::query::{ use crate::target::WindowLinkIdentity; use crate::{PaneId, ServerIdentity, SessionId, TmuxVersion, WindowId}; -/// Availability evidence retained for a modeled snapshot field. -#[allow( - dead_code, - reason = "modelled and tested; only a projection of it is hydrated today" -)] -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum Availability { - /// The detected numbered release predates the field. +/// A snapshot field's value, or the reason the snapshot holds none. +/// +/// A listing asks tmux for every field its snapshot models, except one the +/// running tmux cannot have: a release older than the field gives +/// [`Unsupported`](Self::Unsupported), and a development build, which names no +/// release to compare, gives [`Unproven`](Self::Unproven) for any field newer +/// than the oldest supported release. Neither is fetched. +/// +/// [`Absent`](Self::Absent) means tmux was asked and answered with nothing, +/// which for some fields is the ordinary state: a pane outside copy mode has +/// no `scroll_position`, and a pane straight from +/// [`Pane::split`](crate::Pane::split) can report no `pane_current_path` until +/// its process has started. tmux prints the same empty string for "no value" +/// and "not yet", so re-reading is the only way to tell them apart. +/// +/// Every snapshot fetches every field it models, so no variant means "this +/// snapshot did not ask". +/// +/// # Examples +/// +/// ``` +/// use libtmux::Availability; +/// +/// fn describe(scroll: Availability) -> String { +/// match scroll { +/// Availability::Available(lines) => format!("{lines} lines up"), +/// Availability::Absent => String::from("not in copy mode"), +/// _ => String::from("this tmux cannot say"), +/// } +/// } +/// +/// assert_eq!(describe(Availability::Available(3)), "3 lines up"); +/// assert_eq!(Availability::Available(3).available(), Some(3)); +/// assert_eq!(Availability::::Absent.available(), None); +/// ``` +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Availability { + /// The running tmux release predates the field, so it was not fetched. Unsupported, - /// A development build provides no numbered availability proof. + /// A development build names no release to prove the field exists, so it + /// was not fetched. Unproven, - /// Tmux emitted an empty value for a conditionally available field. + /// tmux was asked and reported nothing. Absent, - /// Tmux emitted a decoded value, including preserved empty text. + /// tmux reported a value, which for text may be empty. Available(T), } -#[allow( - dead_code, - reason = "modelled and tested; only a projection of it is hydrated today" -)] impl Availability { - /// Borrow the contained value without cloning the evidence. - pub(crate) const fn as_ref(&self) -> Availability<&T> { + /// Borrow the value, keeping the reason when there is none. + /// + /// # Examples + /// + /// ``` + /// use libtmux::{Availability, TmuxText}; + /// + /// let owned = Availability::Available(TmuxText::from("copy-mode")); + /// assert_eq!(owned.as_ref().available(), Some(&TmuxText::from("copy-mode"))); + /// ``` + pub const fn as_ref(&self) -> Availability<&T> { match self { Self::Unsupported => Availability::Unsupported, Self::Unproven => Availability::Unproven, @@ -51,28 +88,49 @@ impl Availability { } } - /// Discard the reason a value is missing and keep only the value. + /// Return the value, discarding the reason when there is none. + /// + /// # Examples + /// + /// ``` + /// use libtmux::Availability; /// - /// Callers that treat every unavailable state alike use this. Callers that - /// must distinguish an unsupported release from a genuinely absent value - /// match on the evidence instead. - pub(crate) fn available(self) -> Option { + /// assert_eq!(Availability::Available(7_u32).available(), Some(7)); + /// assert_eq!(Availability::::Unsupported.available(), None); + /// ``` + pub fn available(self) -> Option { match self { Self::Available(value) => Some(value), Self::Unsupported | Self::Unproven | Self::Absent => None, } } - /// Report whether tmux emitted a decoded value. - pub(crate) const fn is_available(&self) -> bool { + /// Report whether tmux reported a value. + /// + /// # Examples + /// + /// ``` + /// use libtmux::Availability; + /// + /// assert!(Availability::Available(false).is_available()); + /// assert!(!Availability::::Absent.is_available()); + /// ``` + pub const fn is_available(&self) -> bool { matches!(self, Self::Available(_)) } + + /// Transform the value, keeping the reason when there is none. + #[cfg(feature = "query")] + pub(crate) fn map(self, transform: impl FnOnce(T) -> U) -> Availability { + match self { + Self::Unsupported => Availability::Unsupported, + Self::Unproven => Availability::Unproven, + Self::Absent => Availability::Absent, + Self::Available(value) => Availability::Available(transform(value)), + } + } } -#[allow( - dead_code, - reason = "modelled and tested; only a projection of it is hydrated today" -)] impl Availability<&T> { /// Copy a borrowed scalar out of its evidence. pub(crate) const fn copied(self) -> Availability { @@ -137,6 +195,153 @@ impl FilterEnum for PaneProgressState { } } +/// One stored field value, borrowed for a typed read. +#[cfg(feature = "query")] +#[derive(Clone, Copy)] +pub(crate) enum FieldRef<'a> { + Text(&'a TmuxText), + SessionId(&'a SessionId), + WindowId(&'a WindowId), + PaneId(&'a PaneId), + Bool(bool), + U8(u8), + U32(u32), + U64(u64), + I32(i32), + I64(i64), + ProgressState(PaneProgressState), +} + +#[cfg(feature = "query")] +mod sealed { + /// Keeps [`super::ReadField`] implemented by this crate's handles only. + pub trait Sealed {} +} + +/// A field handle whose value a `Target` snapshot holds. +/// +/// Every handle in [`PaneFields`], [`WindowFields`], [`SessionFields`] and +/// [`ClientFields`] implements it for the handle type it filters, so the name +/// that filters a field also reads it: pass the handle to +/// [`Pane::get`](crate::Pane::get), [`Window::get`](crate::Window::get), +/// [`Session::get`](crate::Session::get) or +/// [`Client::get`](crate::Client::get). A read never asks tmux. The trait is +/// sealed. +/// +/// # Examples +/// +/// ``` +/// use libtmux::query::{Filterable as _, ReadField}; +/// use libtmux::{Availability, Pane}; +/// +/// // Any field of a pane, read the same way. +/// fn read>(pane: &Pane, field: F) -> Availability> { +/// pane.get(field) +/// } +/// +/// let fields = Pane::filter_fields(); +/// # let _ = |pane: &Pane| { +/// let column: Availability = read(pane, fields.cursor_x); +/// # let _ = column; +/// # }; +/// ``` +#[cfg(feature = "query")] +pub trait ReadField: Copy + sealed::Sealed { + /// What a read yields: `&TmuxText` or a borrowed typed ID for a text + /// field, the value itself for any other. + type Value<'a> + where + Target: 'a; + + /// Read this field from `target`; `None` when `target` stores no field of + /// this name and type, which only a hand-built handle can ask for. + #[doc(hidden)] + fn __read(self, target: &Target) -> Option>>; +} + +/// Keep a stored value only when it has the type the handle promises. +#[cfg(feature = "query")] +fn narrow<'a, V>( + stored: Availability>, + pick: impl FnOnce(FieldRef<'a>) -> Option, +) -> Option> { + match stored { + Availability::Available(value) => pick(value).map(Availability::Available), + Availability::Unsupported => Some(Availability::Unsupported), + Availability::Unproven => Some(Availability::Unproven), + Availability::Absent => Some(Availability::Absent), + } +} + +/// Implement [`ReadField`] for each handle type a target's fields use. +/// +/// Each entry names a handle kind, its value type and the stored variant it +/// reads. `unread` in the generated field sets fails to compile when a handle +/// type is missing here. +#[cfg(feature = "query")] +macro_rules! read_field_impls { + ([$($target:ty),+ $(,)?] $entries:tt) => { + $(read_field_impls!(@target $target, $entries);)+ + }; + (@target $target:ty, [$($kind:ident $(<$param:ty>)? => $value:ty, $variant:ident;)*]) => { + $( + impl sealed::Sealed for $kind<$target $(, $param)?> {} + + impl ReadField<$target> for $kind<$target $(, $param)?> { + type Value<'a> = $value; + + fn __read(self, target: &$target) -> Option>> { + narrow(target.stored(self.field_name())?, |value| match value { + FieldRef::$variant(value) => Some(value), + _ => None, + }) + } + } + )* + }; +} + +// The info types read too, so the gate can run on fixtures without a server. +#[cfg(feature = "query")] +read_field_impls!([crate::Pane, PaneInfo] [ + TextField => &'a TmuxText, Text; + TextField => &'a PaneId, PaneId; + BoolField => bool, Bool; + IntegerField => u8, U8; + IntegerField => u32, U32; + IntegerField => u64, U64; + IntegerField => i32, I32; + IntegerField => i64, I64; + EnumField => PaneProgressState, ProgressState; +]); + +#[cfg(feature = "query")] +read_field_impls!([crate::Window, WindowInfo] [ + TextField => &'a TmuxText, Text; + TextField => &'a WindowId, WindowId; + BoolField => bool, Bool; + IntegerField => u32, U32; + IntegerField => i64, I64; +]); + +#[cfg(feature = "query")] +read_field_impls!([crate::Session, SessionInfo] [ + TextField => &'a TmuxText, Text; + TextField => &'a SessionId, SessionId; + BoolField => bool, Bool; + IntegerField => u32, U32; + IntegerField => i64, I64; +]); + +#[cfg(feature = "query")] +read_field_impls!([crate::Client, ClientInfo] [ + TextField => &'a TmuxText, Text; + BoolField => bool, Bool; + IntegerField => u32, U32; + IntegerField => u64, U64; + IntegerField => i64, I64; +]); + /// Selected slot or retained unavailability for one planned field. enum PlannedSlot<'row> { Unsupported, @@ -595,9 +800,9 @@ macro_rules! decode_catalog_field { #[cfg(feature = "query")] macro_rules! filter_field_type { ($info:ident, Text) => { TextField<$info> }; - ($info:ident, SessionId) => { TextField<$info> }; - ($info:ident, WindowId) => { TextField<$info> }; - ($info:ident, PaneId) => { TextField<$info> }; + ($info:ident, SessionId) => { TextField<$info, SessionId> }; + ($info:ident, WindowId) => { TextField<$info, WindowId> }; + ($info:ident, PaneId) => { TextField<$info, PaneId> }; ($info:ident, Bool) => { BoolField<$info> }; ($info:ident, U8) => { IntegerField<$info, u8> }; ($info:ident, U32) => { IntegerField<$info, u32> }; @@ -614,13 +819,13 @@ macro_rules! filter_field_value { crate::query::__private::text_field::<$info>($target, $name) }; ($info:ident, $target:expr, $name:expr, SessionId) => { - crate::query::__private::text_field::<$info>($target, $name) + TextField::<$info, SessionId>::typed($target, $name) }; ($info:ident, $target:expr, $name:expr, WindowId) => { - crate::query::__private::text_field::<$info>($target, $name) + TextField::<$info, WindowId>::typed($target, $name) }; ($info:ident, $target:expr, $name:expr, PaneId) => { - crate::query::__private::text_field::<$info>($target, $name) + TextField::<$info, PaneId>::typed($target, $name) }; ($info:ident, $target:expr, $name:expr, Bool) => { crate::query::__private::bool_field::<$info>($target, $name) @@ -833,6 +1038,61 @@ macro_rules! decode_stored { }; } +/// Borrow one stored scalar as the [`FieldRef`] variant its decoder implies. +#[cfg(feature = "query")] +macro_rules! field_ref { + ($value:expr, Text) => { + FieldRef::Text($value) + }; + ($value:expr, SessionId) => { + FieldRef::SessionId($value) + }; + ($value:expr, WindowId) => { + FieldRef::WindowId($value) + }; + ($value:expr, PaneId) => { + FieldRef::PaneId($value) + }; + ($value:expr, Bool) => { + FieldRef::Bool(*$value) + }; + ($value:expr, U8) => { + FieldRef::U8(*$value) + }; + ($value:expr, U32) => { + FieldRef::U32(*$value) + }; + ($value:expr, U64) => { + FieldRef::U64(*$value) + }; + ($value:expr, I32) => { + FieldRef::I32(*$value) + }; + ($value:expr, Timestamp) => { + FieldRef::I64(*$value) + }; + ($value:expr, PaneProgress) => { + FieldRef::U8(*$value) + }; + ($value:expr, PaneProgressState) => { + FieldRef::ProgressState(*$value) + }; +} + +/// Read a stored field as evidence, matching [`stored_type`]. +#[cfg(feature = "query")] +macro_rules! read_stored { + ($value:expr, $decoder:ident, V3_2A, Required) => { + Availability::Available(field_ref!(&$value, $decoder)) + }; + ($value:expr, $decoder:ident, V3_2A, Available) => { + Availability::Available(field_ref!(&$value, $decoder)) + }; + ($value:expr, $decoder:ident, $floor:ident, $empty:ident) => { + $value.as_ref().map(|value| field_ref!(value, $decoder)) + }; +} + /// Match a predicate against a stored field, matching [`stored_type`]. #[cfg(feature = "query")] macro_rules! match_stored { @@ -888,6 +1148,20 @@ macro_rules! define_snapshot_info { borrow_stored!(self.$field, $floor, $empty) } )* + + /// Return the field tmux names `name`, or `None` for a name this + /// snapshot does not model. + #[cfg(feature = "query")] + pub(crate) fn stored(&self, name: &str) -> Option>> { + match name { + $baseline_name => Some(Availability::Available(field_ref!( + &self.$baseline_field, + $baseline_decoder + ))), + $($name => Some(read_stored!(self.$field, $decoder, $floor, $empty)),)* + _ => None, + } + } } #[doc = concat!("Typed filter field handles for a `", $target, "`.")] @@ -941,6 +1215,27 @@ macro_rules! define_snapshot_info { } } + // The gate for "every handle reads": naming this function for a target + // fails to compile unless every handle type implements `ReadField` for + // it, and calling it names each handle whose read found no field. + #[cfg(all(test, feature = "query"))] + impl $fields { + pub(crate) fn unread(&self, target: &Target) -> Vec<&'static str> + where + filter_field_type!(Target, $baseline_decoder): ReadField, + $(filter_field_type!(Target, $decoder): ReadField,)* + { + let mut unread = Vec::new(); + if self.$baseline_field.__read(target).is_none() { + unread.push($baseline_name); + } + $(if self.$field.__read(target).is_none() { + unread.push($name); + })* + unread + } + } + #[cfg(feature = "query")] impl $fields { /// Build handles bound to one filter target name. diff --git a/crates/libtmux/src/snapshot/tests.rs b/crates/libtmux/src/snapshot/tests.rs index ffd0ae99..e3a8783a 100644 --- a/crates/libtmux/src/snapshot/tests.rs +++ b/crates/libtmux/src/snapshot/tests.rs @@ -21,7 +21,9 @@ use crate::formats::{ #[cfg(all(feature = "query", feature = "serde"))] use crate::query::FilterExpr; #[cfg(feature = "query")] -use crate::query::{BoolField, EnumField, FilterEnum, Filterable, IntegerField, TextField}; +use crate::query::{ + BoolField, EnumField, FilterEnum, Filterable, IntegerField, ReadField, TextField, +}; use crate::target::WindowLinkIdentity; #[cfg(feature = "test-support")] use crate::test::TestServer; @@ -364,6 +366,30 @@ macro_rules! assert_stored_fields { #[cfg(feature = "query")] macro_rules! assert_text_handles { + ( + @typed $text:ty, + $value:expr, + $info:ty, + $fixture:ident, + $raw:expr, + $expected:expr, + [$($field:ident),+ $(,)?] + ) => { + $( + let handle: &TextField<$info, $text> = &$value.$field; + assert_eq!( + *handle, + TextField::<$info, $text>::typed( + <$info as Filterable>::FILTER_TARGET, + stringify!($field), + ) + ); + let candidate = $fixture(b"tmux 3.7\n", &[(stringify!($field), $raw)]) + .ok() + .expect(concat!("distinct ", stringify!($field), " fixture hydrates")); + assert!((*handle).eq($expected).matches(&candidate)); + )+ + }; ( $value:expr, $info:ty, @@ -886,6 +912,7 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { ); assert_text_handles!( + @typed SessionId, session_fields, SessionInfo, session_fixture, @@ -927,6 +954,7 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { ); assert_text_handles!( + @typed WindowId, window_fields, WindowInfo, window_fixture, @@ -973,7 +1001,15 @@ fn snapshot_catalog_info_and_scalar_handle_shapes_are_exact() { [window_zoomed_flag] ); - assert_text_handles!(pane_fields, PaneInfo, pane_fixture, b"%7", "%7", [pane_id]); + assert_text_handles!( + @typed PaneId, + pane_fields, + PaneInfo, + pane_fixture, + b"%7", + "%7", + [pane_id] + ); assert_text_handles!( pane_fields, PaneInfo, @@ -1288,6 +1324,84 @@ fn snapshot_catalog_empty_policy_distinguishes_all_three_states() { assert_safe_diagnostic(&error); } +/// Every filter handle reads the field it filters. +/// +/// Naming `unread` for a public target compiles only when every handle type +/// in its field set implements `ReadField` for it. Calling it on a complete +/// fixture names any handle whose name or type finds no stored field. +#[test] +#[cfg(feature = "query")] +fn every_filter_handle_reads_its_field() { + let _ = SessionFields::::unread; + let _ = WindowFields::::unread; + let _ = PaneFields::::unread; + let _ = ClientFields::::unread; + + let none: Vec<&str> = Vec::new(); + let session = session_fixture(b"tmux 3.7\n", &[]) + .ok() + .expect("complete SessionInfo hydrates"); + assert_eq!(generated_fields::().unread(&session), none); + let window = window_fixture(b"tmux 3.7\n", &[]) + .ok() + .expect("complete WindowInfo hydrates"); + assert_eq!(generated_fields::().unread(&window), none); + let pane = pane_fixture(b"tmux 3.7\n", &[]) + .ok() + .expect("complete PaneInfo hydrates"); + assert_eq!(generated_fields::().unread(&pane), none); + let client = client_fixture(b"tmux 3.7\n", &[]) + .ok() + .expect("complete ClientInfo hydrates"); + assert_eq!(generated_fields::().unread(&client), none); +} + +/// A read keeps the reason a field has no value. +#[test] +#[cfg(feature = "query")] +fn a_read_through_a_handle_keeps_the_reason_a_value_is_missing() { + fn read>(pane: &PaneInfo, field: F) -> Availability> { + field + .__read(pane) + .expect("a generated handle names a stored field") + } + + let fields = generated_fields::(); + let current = pane_fixture(b"tmux 3.7\n", &[("pane_current_path", b"")]) + .ok() + .expect("3.7 pane hydrates"); + assert_eq!( + read(¤t, fields.pane_current_path), + Availability::Absent + ); + assert_eq!( + read(¤t, fields.pane_pb_state), + Availability::Available(PaneProgressState::Normal) + ); + assert_eq!(read(¤t, fields.cursor_x), Availability::Available(0)); + assert_eq!( + read(¤t, fields.pane_id), + Availability::Available(&"%1".parse::().expect("a pane id")) + ); + + let old = pane_fixture(b"tmux 3.2a\n", &[]) + .ok() + .expect("3.2a pane hydrates"); + assert_eq!(read(&old, fields.pane_pb_state), Availability::Unsupported); + assert_eq!( + read(&old, fields.scroll_position), + Availability::Available(0) + ); + + let development = pane_fixture(b"tmux master\n", &[]) + .ok() + .expect("development pane hydrates"); + assert_eq!( + read(&development, fields.pane_pb_state), + Availability::Unproven + ); +} + #[test] #[allow( clippy::too_many_lines, diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index bd43d303..4431ffba 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -11,11 +11,11 @@ use crate::internal::listing; use crate::internal::scoped; use crate::pane::Pane; #[cfg(feature = "query")] -use crate::query::{FilterSchema, Filterable}; +use crate::query::{FilterSchema, Filterable, ReadField}; use crate::session::Session; use crate::snapshot::WindowProjection; #[cfg(feature = "query")] -use crate::snapshot::{WindowFields, WindowInfo}; +use crate::snapshot::{Availability, FieldRef, WindowFields, WindowInfo}; use crate::target::{ServerIdentity, SessionId, WindowId}; use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; @@ -1137,6 +1137,54 @@ impl fmt::Debug for Window { } } +#[cfg(feature = "query")] +impl Window { + /// Read one field of this window's snapshot, named by the handle that + /// filters it. + /// + /// Every field in [`WindowFields`] reads this way, including those with no + /// getter of their own. Nothing is sent to tmux, so the value is as old as + /// the snapshot. The result says why a field holds no value: see + /// [`Availability`]. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// use libtmux::query::Filterable as _; + /// use libtmux::{Availability, Window}; + /// + /// let guard = libtmux::test::TestServer::new().await?; + /// let session = guard.server().new_session("read").await?; + /// let window = session.active_window().await?.expect("a session has a window"); + /// let fields = Window::filter_fields(); + /// + /// // The layout as shown, which only differs from the layout while zoomed. + /// assert_eq!( + /// window.get(fields.window_visible_layout), + /// Availability::Available(window.layout()), + /// ); + /// assert_eq!(window.get(fields.window_id), Availability::Available(window.id())); + /// + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn get>(&self, field: F) -> Availability> { + field.__read(self).unwrap_or(Availability::Absent) + } + + /// Return the stored field tmux names `name`. + pub(crate) fn stored(&self, name: &str) -> Option>> { + self.projection.window().stored(name) + } +} + /// Filtering a window uses the same handles as the snapshot beneath it. /// /// Matching and validation delegate to that snapshot, so an expression can diff --git a/crates/libtmux/tests/command_budget.rs b/crates/libtmux/tests/command_budget.rs index 5b561d0e..82bb0b0f 100644 --- a/crates/libtmux/tests/command_budget.rs +++ b/crates/libtmux/tests/command_budget.rs @@ -217,3 +217,92 @@ async fn asking_a_client_what_it_is_attached_to_costs_one_command_each() { control.shutdown().await.expect("control shuts down"); guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// What it costs to read a field a pane listing already carries. +/// +/// These four arrive with every pane listing, so reading them sends nothing, +/// and each agrees with what `display-message` reports for the same pane: an +/// empty expansion where the read says `Absent`. +#[cfg(feature = "query")] +#[tokio::test] +async fn reading_a_listed_pane_field_costs_no_command() { + use libtmux::query::Filterable as _; + use libtmux::{Availability, Command, NewSessionOptions, Pane, PaneWait}; + + let counter = CommandCounter::default(); + let subscriber = tracing_subscriber::registry().with(counter.clone()); + let _guard = tracing::subscriber::set_default(subscriber); + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + // History to scroll back through, then a cursor off the origin, then a + // reader that prints nothing more, so the cursor holds still. + let session = server + .new_session(NewSessionOptions::new("read").command("seq 1 200; printf abc; exec cat")) + .await + .expect("session"); + let mut pane = session.panes().await.expect("panes").remove(0); + let arrived = pane + .wait_for_text("abc", std::time::Duration::from_secs(10)) + .await + .expect("wait"); + assert_eq!(arrived, PaneWait::Arrived); + let fields = Pane::filter_fields(); + + for scrolled in [None, Some(5)] { + if let Some(lines) = scrolled { + pane.copy_mode().await.expect("copy mode"); + pane.cmd( + Command::new("send-keys") + .arg("-X") + .arg("-N") + .arg(lines.to_string()) + .arg("scroll-up"), + ) + .await + .expect("scroll"); + } + pane.refresh().await.expect("listing"); + + counter.reset(); + let cursor_x = pane.get(fields.cursor_x); + let cursor_y = pane.get(fields.cursor_y); + let pane_mode = pane.get(fields.pane_mode); + let scroll_position = pane.get(fields.scroll_position); + assert_eq!(counter.commands(), 0, "a read sends tmux nothing"); + + assert_eq!(cursor_x, Availability::Available(3), "after `abc`"); + if let Some(lines) = scrolled { + assert!(pane_mode.is_available()); + assert_eq!(scroll_position, Availability::Available(lines)); + } else { + assert_eq!(pane_mode, Availability::Absent); + assert_eq!(scroll_position, Availability::Absent); + } + + let reads = [ + ("#{cursor_x}", cursor_x.available().map(|x| x.to_string())), + ("#{cursor_y}", cursor_y.available().map(|y| y.to_string())), + ( + "#{pane_mode}", + pane_mode + .available() + .map(|mode| mode.to_string_lossy().into_owned()), + ), + ( + "#{scroll_position}", + scroll_position.available().map(|lines| lines.to_string()), + ), + ]; + for (format, read) in reads { + let asked = pane.format(format).await.expect("display-message"); + assert_eq!( + asked.to_string_lossy(), + read.unwrap_or_default(), + "{format} with scroll {scrolled:?}", + ); + } + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From c279ef749b6aa001c15010b018b0bdc9e87e3cf7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 22:00:05 -0500 Subject: [PATCH 063/117] Options(feat[schema]): Record choices and ranges why: A typed write cannot refuse a word outside a choice's set, or a number outside its range, unless the schema knows them. tmux's table declares both; the generator dropped them. what: - generate-option-schema.py reads each choice list and each number's minimum and maximum, resolving the names and tmux.h defines, and fails on a choice with no list or a number with no range - Regenerate from tmux 3.7b, the source of the checked-in table: every row is unchanged apart from the new builders - Add OptionSchema::choices and OptionSchema::range --- crates/libtmux/docs/public-api.txt | 2 + crates/libtmux/src/options.rs | 64 +++++++- crates/libtmux/src/options/generated.rs | 187 +++++++++++++++++------- scripts/generate-option-schema.py | 79 ++++++++-- 4 files changed, 269 insertions(+), 63 deletions(-) diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 6f78968f..e86111a3 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -402,8 +402,10 @@ function libtmux::NewWindowOptions::select: const fn(self) -> Self function libtmux::NewWindowOptions::start_directory: fn(self, directory: impl Into) -> Self function libtmux::NewWindowOptions::unnamed: const fn() -> Self function libtmux::OptionSchema::accepts: fn(&self, scope: libtmux::OptionScope) -> bool +function libtmux::OptionSchema::choices: const fn(&self) -> &'static [&'static str] function libtmux::OptionSchema::kind: const fn(&self) -> libtmux::OptionKind function libtmux::OptionSchema::name: const fn(&self) -> &'static str +function libtmux::OptionSchema::range: fn(&self) -> Option> function libtmux::OptionSchema::scopes: const fn(&self) -> &'static [libtmux::OptionScope] function libtmux::OutputLimits::max_stderr_bytes: const fn(self, bytes: usize) -> Self function libtmux::OutputLimits::max_stdout_bytes: const fn(self, bytes: usize) -> Self diff --git a/crates/libtmux/src/options.rs b/crates/libtmux/src/options.rs index b9b1a695..a325873e 100644 --- a/crates/libtmux/src/options.rs +++ b/crates/libtmux/src/options.rs @@ -10,6 +10,8 @@ mod generated; pub use generated::names; +use std::ops::RangeInclusive; + use crate::formats::TmuxText; /// What kind of value an option holds. @@ -98,6 +100,8 @@ pub struct OptionSchema { name: &'static str, kind: OptionKind, scopes: &'static [OptionScope], + choices: &'static [&'static str], + range: Option<(i64, i64)>, } impl OptionSchema { @@ -106,7 +110,23 @@ impl OptionSchema { kind: OptionKind, scopes: &'static [OptionScope], ) -> Self { - Self { name, kind, scopes } + Self { + name, + kind, + scopes, + choices: &[], + range: None, + } + } + + pub(crate) const fn with_choices(mut self, choices: &'static [&'static str]) -> Self { + self.choices = choices; + self + } + + pub(crate) const fn with_range(mut self, minimum: i64, maximum: i64) -> Self { + self.range = Some((minimum, maximum)); + self } /// Return the option's tmux name. @@ -140,6 +160,48 @@ impl OptionSchema { pub fn accepts(&self, scope: OptionScope) -> bool { self.scopes.contains(&scope) } + + /// Return the words a [`OptionKind::Choice`] option accepts, in tmux's + /// order. + /// + /// Empty for every other kind. tmux compares a choice exactly, so case + /// matters. + /// + /// # Examples + /// + /// ``` + /// use libtmux::option_schema; + /// + /// let keys = option_schema("mode-keys").expect("a documented option"); + /// assert_eq!(keys.choices(), ["emacs", "vi"]); + /// + /// let limit = option_schema("history-limit").expect("a documented option"); + /// assert!(limit.choices().is_empty()); + /// ``` + #[must_use] + pub const fn choices(&self) -> &'static [&'static str] { + self.choices + } + + /// Return the inclusive range a [`OptionKind::Number`] option accepts. + /// + /// `None` for every other kind. + /// + /// # Examples + /// + /// ``` + /// use libtmux::option_schema; + /// + /// let limit = option_schema("buffer-limit").expect("a documented option"); + /// assert_eq!(limit.range(), Some(1..=i64::from(i32::MAX))); + /// + /// let keys = option_schema("mode-keys").expect("a documented option"); + /// assert_eq!(keys.range(), None); + /// ``` + #[must_use] + pub fn range(&self) -> Option> { + self.range.map(|(minimum, maximum)| minimum..=maximum) + } } /// Look up what tmux declares about one option. diff --git a/crates/libtmux/src/options/generated.rs b/crates/libtmux/src/options/generated.rs index 8fc66b75..a3f9bc63 100644 --- a/crates/libtmux/src/options/generated.rs +++ b/crates/libtmux/src/options/generated.rs @@ -11,7 +11,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "activity-action", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&["none", "any", "current", "other"]), OptionSchema::new( "after-bind-key", OptionKind::Command, @@ -218,7 +219,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "allow-passthrough", OptionKind::Choice, &[OptionScope::Window, OptionScope::Pane], - ), + ) + .with_choices(&["off", "on", "all"]), OptionSchema::new( "allow-rename", OptionKind::Flag, @@ -238,7 +240,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "assume-paste-time", OptionKind::Number, &[OptionScope::Session], - ), + ) + .with_range(0, 2_147_483_647), OptionSchema::new("automatic-rename", OptionKind::Flag, &[OptionScope::Window]), OptionSchema::new( "automatic-rename-format", @@ -246,9 +249,12 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ &[OptionScope::Window], ), OptionSchema::new("backspace", OptionKind::Key, &[OptionScope::Server]), - OptionSchema::new("base-index", OptionKind::Number, &[OptionScope::Session]), - OptionSchema::new("bell-action", OptionKind::Choice, &[OptionScope::Session]), - OptionSchema::new("buffer-limit", OptionKind::Number, &[OptionScope::Server]), + OptionSchema::new("base-index", OptionKind::Number, &[OptionScope::Session]) + .with_range(0, 2_147_483_647), + OptionSchema::new("bell-action", OptionKind::Choice, &[OptionScope::Session]) + .with_choices(&["none", "any", "current", "other"]), + OptionSchema::new("buffer-limit", OptionKind::Number, &[OptionScope::Server]) + .with_range(1, 2_147_483_647), OptionSchema::new( "client-active", OptionKind::Command, @@ -303,7 +309,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "clock-mode-style", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["12", "24", "12-with-seconds", "24-with-seconds"]), OptionSchema::new("codepoint-widths", OptionKind::Text, &[OptionScope::Server]), OptionSchema::new("command-alias", OptionKind::Text, &[OptionScope::Server]), OptionSchema::new( @@ -331,7 +338,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "copy-mode-line-numbers", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["off", "default", "absolute", "relative", "hybrid"]), OptionSchema::new( "copy-mode-mark-style", OptionKind::Text, @@ -366,7 +374,16 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "cursor-style", OptionKind::Choice, &[OptionScope::Window, OptionScope::Pane], - ), + ) + .with_choices(&[ + "default", + "blinking-block", + "block", + "blinking-underline", + "underline", + "blinking-bar", + "bar", + ]), OptionSchema::new( "default-client-command", OptionKind::Command, @@ -380,12 +397,14 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "destroy-unattached", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&["off", "on", "keep-last", "keep-group"]), OptionSchema::new( "detach-on-destroy", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&["off", "on", "no-detached", "previous", "next"]), OptionSchema::new( "display-panes-active-colour", OptionKind::Colour, @@ -400,18 +419,23 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "display-panes-time", OptionKind::Number, &[OptionScope::Session], - ), - OptionSchema::new("display-time", OptionKind::Number, &[OptionScope::Session]), + ) + .with_range(1, 2_147_483_647), + OptionSchema::new("display-time", OptionKind::Number, &[OptionScope::Session]) + .with_range(0, 2_147_483_647), OptionSchema::new("editor", OptionKind::Text, &[OptionScope::Server]), - OptionSchema::new("escape-time", OptionKind::Number, &[OptionScope::Server]), + OptionSchema::new("escape-time", OptionKind::Number, &[OptionScope::Server]) + .with_range(0, 2_147_483_647), OptionSchema::new("exit-empty", OptionKind::Flag, &[OptionScope::Server]), OptionSchema::new("exit-unattached", OptionKind::Flag, &[OptionScope::Server]), - OptionSchema::new("extended-keys", OptionKind::Choice, &[OptionScope::Server]), + OptionSchema::new("extended-keys", OptionKind::Choice, &[OptionScope::Server]) + .with_choices(&["off", "on", "always"]), OptionSchema::new( "extended-keys-format", OptionKind::Choice, &[OptionScope::Server], - ), + ) + .with_choices(&["csi-u", "xterm"]), OptionSchema::new("fill-character", OptionKind::Text, &[OptionScope::Window]), OptionSchema::new("focus-events", OptionKind::Flag, &[OptionScope::Server]), OptionSchema::new( @@ -419,25 +443,30 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ OptionKind::Flag, &[OptionScope::Session], ), - OptionSchema::new("get-clipboard", OptionKind::Choice, &[OptionScope::Server]), + OptionSchema::new("get-clipboard", OptionKind::Choice, &[OptionScope::Server]) + .with_choices(&["off", "buffer", "request", "both"]), OptionSchema::new("history-file", OptionKind::Text, &[OptionScope::Server]), - OptionSchema::new("history-limit", OptionKind::Number, &[OptionScope::Session]), + OptionSchema::new("history-limit", OptionKind::Number, &[OptionScope::Session]) + .with_range(0, 2_147_483_647), OptionSchema::new( "initial-repeat-time", OptionKind::Number, &[OptionScope::Session], - ), + ) + .with_range(0, 2_000_000), OptionSchema::new( "input-buffer-size", OptionKind::Number, &[OptionScope::Server], - ), + ) + .with_range(1_048_576, 4_294_967_295), OptionSchema::new("key-table", OptionKind::Text, &[OptionScope::Session]), OptionSchema::new( "lock-after-time", OptionKind::Number, &[OptionScope::Session], - ), + ) + .with_range(0, 2_147_483_647), OptionSchema::new("lock-command", OptionKind::Text, &[OptionScope::Session]), OptionSchema::new("main-pane-height", OptionKind::Text, &[OptionScope::Window]), OptionSchema::new("main-pane-width", OptionKind::Text, &[OptionScope::Window]), @@ -445,7 +474,10 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "menu-border-lines", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&[ + "single", "double", "heavy", "simple", "rounded", "padded", "none", + ]), OptionSchema::new( "menu-border-style", OptionKind::Text, @@ -463,10 +495,13 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ &[OptionScope::Session], ), OptionSchema::new("message-format", OptionKind::Text, &[OptionScope::Session]), - OptionSchema::new("message-limit", OptionKind::Number, &[OptionScope::Server]), - OptionSchema::new("message-line", OptionKind::Choice, &[OptionScope::Session]), + OptionSchema::new("message-limit", OptionKind::Number, &[OptionScope::Server]) + .with_range(0, 2_147_483_647), + OptionSchema::new("message-line", OptionKind::Choice, &[OptionScope::Session]) + .with_choices(&["0", "1", "2", "3", "4"]), OptionSchema::new("message-style", OptionKind::Text, &[OptionScope::Session]), - OptionSchema::new("mode-keys", OptionKind::Choice, &[OptionScope::Window]), + OptionSchema::new("mode-keys", OptionKind::Choice, &[OptionScope::Window]) + .with_choices(&["emacs", "vi"]), OptionSchema::new("mode-style", OptionKind::Text, &[OptionScope::Window]), OptionSchema::new("monitor-activity", OptionKind::Flag, &[OptionScope::Window]), OptionSchema::new("monitor-bell", OptionKind::Flag, &[OptionScope::Window]), @@ -474,7 +509,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "monitor-silence", OptionKind::Number, &[OptionScope::Window], - ), + ) + .with_range(0, 2_147_483_647), OptionSchema::new("mouse", OptionKind::Flag, &[OptionScope::Session]), OptionSchema::new( "other-pane-height", @@ -491,7 +527,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "pane-base-index", OptionKind::Number, &[OptionScope::Window], - ), + ) + .with_range(0, 65_535), OptionSchema::new( "pane-border-format", OptionKind::Text, @@ -501,17 +538,20 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "pane-border-indicators", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["off", "colour", "arrows", "both"]), OptionSchema::new( "pane-border-lines", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["single", "double", "heavy", "simple", "number", "spaces"]), OptionSchema::new( "pane-border-status", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["off", "top", "bottom"]), OptionSchema::new( "pane-border-style", OptionKind::Text, @@ -551,12 +591,14 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "pane-scrollbars", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["off", "modal", "on"]), OptionSchema::new( "pane-scrollbars-position", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&["right", "left"]), OptionSchema::new( "pane-scrollbars-style", OptionKind::Text, @@ -586,7 +628,10 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "popup-border-lines", OptionKind::Choice, &[OptionScope::Window], - ), + ) + .with_choices(&[ + "single", "double", "heavy", "simple", "rounded", "padded", "none", + ]), OptionSchema::new( "popup-border-style", OptionKind::Text, @@ -594,13 +639,23 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ ), OptionSchema::new("popup-style", OptionKind::Text, &[OptionScope::Window]), OptionSchema::new("prefix", OptionKind::Key, &[OptionScope::Session]), - OptionSchema::new("prefix-timeout", OptionKind::Number, &[OptionScope::Server]), + OptionSchema::new("prefix-timeout", OptionKind::Number, &[OptionScope::Server]) + .with_range(0, 2_147_483_647), OptionSchema::new("prefix2", OptionKind::Key, &[OptionScope::Session]), OptionSchema::new( "prompt-command-cursor-style", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&[ + "default", + "blinking-block", + "block", + "blinking-underline", + "underline", + "blinking-bar", + "bar", + ]), OptionSchema::new( "prompt-cursor-colour", OptionKind::Colour, @@ -610,17 +665,28 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "prompt-cursor-style", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&[ + "default", + "blinking-block", + "block", + "blinking-underline", + "underline", + "blinking-bar", + "bar", + ]), OptionSchema::new( "prompt-history-limit", OptionKind::Number, &[OptionScope::Server], - ), + ) + .with_range(0, 2_147_483_647), OptionSchema::new( "remain-on-exit", OptionKind::Choice, &[OptionScope::Window, OptionScope::Pane], - ), + ) + .with_choices(&["off", "on", "failed", "key"]), OptionSchema::new( "remain-on-exit-format", OptionKind::Text, @@ -631,7 +697,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ OptionKind::Flag, &[OptionScope::Session], ), - OptionSchema::new("repeat-time", OptionKind::Number, &[OptionScope::Session]), + OptionSchema::new("repeat-time", OptionKind::Number, &[OptionScope::Session]) + .with_range(0, 2_000_000), OptionSchema::new( "scroll-on-clear", OptionKind::Flag, @@ -667,7 +734,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ OptionKind::Command, &[OptionScope::Session], ), - OptionSchema::new("set-clipboard", OptionKind::Choice, &[OptionScope::Server]), + OptionSchema::new("set-clipboard", OptionKind::Choice, &[OptionScope::Server]) + .with_choices(&["off", "external", "on"]), OptionSchema::new("set-titles", OptionKind::Flag, &[OptionScope::Session]), OptionSchema::new( "set-titles-string", @@ -678,8 +746,10 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "silence-action", OptionKind::Choice, &[OptionScope::Session], - ), - OptionSchema::new("status", OptionKind::Choice, &[OptionScope::Session]), + ) + .with_choices(&["none", "any", "current", "other"]), + OptionSchema::new("status", OptionKind::Choice, &[OptionScope::Session]) + .with_choices(&["off", "on", "2", "3", "4", "5"]), OptionSchema::new("status-bg", OptionKind::Colour, &[OptionScope::Session]), OptionSchema::new("status-fg", OptionKind::Colour, &[OptionScope::Session]), OptionSchema::new("status-format", OptionKind::Text, &[OptionScope::Session]), @@ -687,19 +757,23 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "status-interval", OptionKind::Number, &[OptionScope::Session], - ), + ) + .with_range(0, 2_147_483_647), OptionSchema::new( "status-justify", OptionKind::Choice, &[OptionScope::Session], - ), - OptionSchema::new("status-keys", OptionKind::Choice, &[OptionScope::Session]), + ) + .with_choices(&["left", "centre", "right", "absolute-centre"]), + OptionSchema::new("status-keys", OptionKind::Choice, &[OptionScope::Session]) + .with_choices(&["emacs", "vi"]), OptionSchema::new("status-left", OptionKind::Text, &[OptionScope::Session]), OptionSchema::new( "status-left-length", OptionKind::Number, &[OptionScope::Session], - ), + ) + .with_range(0, 32_767), OptionSchema::new( "status-left-style", OptionKind::Text, @@ -709,13 +783,15 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "status-position", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&["top", "bottom"]), OptionSchema::new("status-right", OptionKind::Text, &[OptionScope::Session]), OptionSchema::new( "status-right-length", OptionKind::Number, &[OptionScope::Session], - ), + ) + .with_range(0, 32_767), OptionSchema::new( "status-right-style", OptionKind::Text, @@ -741,7 +817,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "tiled-layout-max-columns", OptionKind::Number, &[OptionScope::Window], - ), + ) + .with_range(0, 65_535), OptionSchema::new( "tree-mode-preview-format", OptionKind::Text, @@ -767,13 +844,16 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ "visual-activity", OptionKind::Choice, &[OptionScope::Session], - ), - OptionSchema::new("visual-bell", OptionKind::Choice, &[OptionScope::Session]), + ) + .with_choices(&["off", "on", "both"]), + OptionSchema::new("visual-bell", OptionKind::Choice, &[OptionScope::Session]) + .with_choices(&["off", "on", "both"]), OptionSchema::new( "visual-silence", OptionKind::Choice, &[OptionScope::Session], - ), + ) + .with_choices(&["off", "on", "both"]), OptionSchema::new( "window-active-style", OptionKind::Text, @@ -794,7 +874,8 @@ pub(crate) static OPTION_SCHEMA: [OptionSchema; 217] = [ OptionKind::Text, &[OptionScope::Window], ), - OptionSchema::new("window-size", OptionKind::Choice, &[OptionScope::Window]), + OptionSchema::new("window-size", OptionKind::Choice, &[OptionScope::Window]) + .with_choices(&["largest", "smallest", "manual", "latest"]), OptionSchema::new( "window-status-activity-style", OptionKind::Text, diff --git a/scripts/generate-option-schema.py b/scripts/generate-option-schema.py index d7490e6d..c880ae83 100644 --- a/scripts/generate-option-schema.py +++ b/scripts/generate-option-schema.py @@ -34,6 +34,28 @@ r'(?P.*?)(?=\n\t\{\s*\.name|\n\t\{\s*\{|\Z)', re.S, ) +CHOICE_LIST = re.compile(r"static const char \*(\w+)\[\] = \{(.*?)\};", re.S) +DEFINE = re.compile(r"^#define\s+(\w+)\s+(\d+)\s*$", re.M) + +# The limits tmux's table spells as names. The rest are defined in +# tmux.h beside the table and read from there. +LIMITS = { + "INT_MAX": 2**31 - 1, + "SHRT_MAX": 2**15 - 1, + "UINT_MAX": 2**32 - 1, + "USHRT_MAX": 2**16 - 1, +} + + +def bound(text: str, defines: dict[str, int]) -> int: + text = text.strip() + if re.fullmatch(r"-?\d+", text): + return int(text) + if text in LIMITS: + return LIMITS[text] + if text in defines: + return defines[text] + raise SystemExit(f"cannot resolve the bound {text!r}") def main() -> int: @@ -41,8 +63,19 @@ def main() -> int: print("usage: generate-option-schema.py ", file=sys.stderr) return 2 - source = pathlib.Path(sys.argv[1]).read_text() - rows: list[tuple[str, str, tuple[str, ...]]] = [] + table = pathlib.Path(sys.argv[1]) + source = table.read_text() + header = table.with_name("tmux.h") + defines = { + name: int(value) + for name, value in DEFINE.findall(header.read_text() if header.exists() else "") + } + choice_lists = { + name: tuple(re.findall(r'"([^"]*)"', body)) + for name, body in CHOICE_LIST.findall(source) + } + + rows: list[tuple[str, str, tuple[str, ...], tuple[str, ...], tuple[int, int] | None]] = [] for match in ENTRY.finditer(source): body = match.group("body") kind = re.search(r"\.type\s*=\s*(OPTIONS_TABLE_[A-Z]+)", body) @@ -55,7 +88,31 @@ def main() -> int: scopes = [] if scope is None else [ SCOPES[part] for part in scope.group(1).split("|") if part in SCOPES ] - rows.append((match.group("name"), TYPES[kind.group(1)], tuple(scopes or ["Server"]))) + + choices: tuple[str, ...] = () + if kind.group(1) == "OPTIONS_TABLE_CHOICE": + listed = re.search(r"\.choices\s*=\s*(\w+)", body) + if listed is None or listed.group(1) not in choice_lists: + raise SystemExit(f"{match.group('name')}: a choice with no list") + choices = choice_lists[listed.group(1)] + + # tmux checks a number with strtonum(value, minimum, maximum), and an + # absent bound is zero, so a number without both would take only 0. + limits = None + if kind.group(1) == "OPTIONS_TABLE_NUMBER": + low = re.search(r"\.minimum\s*=\s*([^,\n]+)", body) + high = re.search(r"\.maximum\s*=\s*([^,\n]+)", body) + if low is None or high is None: + raise SystemExit(f"{match.group('name')}: a number with no range") + limits = (bound(low.group(1), defines), bound(high.group(1), defines)) + + rows.append(( + match.group("name"), + TYPES[kind.group(1)], + tuple(scopes or ["Server"]), + choices, + limits, + )) # Hooks are declared through macros rather than the struct form. Both # expand to a command-typed option. The pane variant declares @@ -66,7 +123,7 @@ def main() -> int: ("OPTIONS_TABLE_PANE_HOOK", ("Window", "Pane")), ): for hook in re.finditer(rf'\n\t{macro}\("([a-z0-9-]+)"', source): - rows.append((hook.group(1), "Command", scopes)) + rows.append((hook.group(1), "Command", scopes, (), None)) # tmux resolves a handful of legacy spellings before it looks a name up, # so a caller reaching an option through one reaches the same option. @@ -84,11 +141,15 @@ def main() -> int: print() print(f"/// Every option tmux's table declares, sorted by name.") print(f"pub(crate) static OPTION_SCHEMA: [OptionSchema; {len(rows)}] = [") - for name, kind, scopes in rows: + for name, kind, scopes, choices, limits in rows: rendered = ", ".join(f"OptionScope::{scope}" for scope in scopes) - print( - f' OptionSchema::new("{name}", OptionKind::{kind}, &[{rendered}]),' - ) + entry = f'OptionSchema::new("{name}", OptionKind::{kind}, &[{rendered}])' + if choices: + words = ", ".join(f'"{choice}"' for choice in choices) + entry += f".with_choices(&[{words}])" + if limits is not None: + entry += f".with_range({limits[0]:_}, {limits[1]:_})" + print(f" {entry},") print("];") print() print("/// The spellings tmux maps to another option before looking it up.") @@ -103,7 +164,7 @@ def main() -> int: print("/// begins with `@`, has no constant because tmux does not declare one.") print("pub mod names {") seen: set[str] = set() - for name, _, _ in rows: + for name, *_ in rows: ident = name.upper().replace("-", "_") if ident in seen: continue From 37ae7f0cb763198479fecbac80e5b3ef36bfada9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 22:30:26 -0500 Subject: [PATCH 064/117] Options(feat[typed]): Check writes; one reader why: Reads were typed and every write took text, so a caller holding a bool rendered "on" by hand and a value tmux's table refuses went out anyway. Beside that, six get_*option readers returned the same option typed_option did, undecoded: three ways to read one thing. what: - Add set_typed_option on Server, Session, Window and Pane, and Server::set_typed_global_option and set_typed_global_window_option. The value must be the variant typed_option reads back; the wrong variant, a word outside a choice's set, or a number outside its range fails with Error::OptionValueRefused before anything is sent. The check runs before ensure_scope, so a refusal costs no tmux call - A user option, and a name the table does not declare, are sent as written: there is nothing to check against. A user option reads back as text - OptionValue converts from bool, integers that fit an i64 losslessly, &str, String and TmuxText; TmuxText::from renders one as tmux stores it - Remove get_option on all four handles and Server::get_global_option and get_global_window_option; add Server::typed_global_window_option so every table keeps a reader. TmuxText::from(value) recovers the exact bytes - OptionValue's rustdoc carries the whole read and write story; each reader and writer points there - tmux-mcp's mouse, history-limit and synchronize-panes tools write typed; show_option reads typed and renders the same text as before The set_option family is unchanged. The table is tmux 3.7b's, whose choice sets contain every older supported release's. One kind moved: destroy-unattached is a flag on 3.2a and a choice since, so there the check refuses yes, no, 1 and 0, which 3.2a takes; on and off, what a read returns, pass. A newer tmux may add a word the table lacks, and set_option sends it. --- crates/libtmux/docs/migration.md | 23 ++ crates/libtmux/docs/parity.md | 8 +- crates/libtmux/docs/public-api.txt | 40 ++- crates/libtmux/src/error.rs | 20 ++ crates/libtmux/src/error/classification.rs | 4 +- crates/libtmux/src/error/refusal.rs | 2 +- crates/libtmux/src/internal/options.rs | 43 ++++ crates/libtmux/src/lib.rs | 9 +- crates/libtmux/src/options.rs | 242 +++++++++++++++++- crates/libtmux/src/pane/settings.rs | 77 ++++-- crates/libtmux/src/server.rs | 18 +- crates/libtmux/src/server/settings.rs | 191 ++++++++++---- crates/libtmux/src/session/settings.rs | 97 +++++-- crates/libtmux/src/window/settings.rs | 77 ++++-- crates/libtmux/tests/commands.rs | 35 +-- crates/libtmux/tests/control.rs | 8 +- crates/libtmux/tests/mutations.rs | 10 +- crates/libtmux/tests/options.rs | 283 +++++++++++++++++++-- crates/tmux-mcp/src/tools/contract.rs | 10 +- crates/tmux-mcp/src/tools/inspect.rs | 16 +- crates/tmux-mcp/tests/agent.rs | 6 +- crates/tmux-workspace/tests/build.rs | 18 +- 22 files changed, 994 insertions(+), 243 deletions(-) diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index b944f0c5..0fefbd53 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -32,6 +32,29 @@ let handle: TextField = Pane::filter_fields().pane_id; let _ = handle.eq("%1"); ``` +## One option reader: `typed_option` + +`get_option` on `Server`, `Session`, `Window` and `Pane`, and +`Server::{get_global_option, get_global_window_option}`, are gone. Read with +`typed_option`, `Server::typed_global_option` or +`Server::typed_global_window_option`. A flag now arrives as a `bool` and a +number as an `i64`; where the bytes are wanted, `TmuxText::from` gives back +exactly what the removed reader returned: + +```no_run +# async fn read(session: &libtmux::Session) -> Result<(), libtmux::Error> { +use libtmux::TmuxText; + +// was: session.get_option("status-left").await? +let left = session.typed_option("status-left").await?.map(TmuxText::from); +# let _ = left; +# Ok(()) +# } +``` + +`set_typed_option` writes the same types back, checked against tmux's option +table; `set_option` is unchanged. + ## The `_or_empty` listing twins are gone Replace `x_or_empty().await` with `x().await.unwrap_or_default()`: diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index e916011e..22619d4f 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -120,7 +120,7 @@ Source: `src/libtmux/exc.py`. Query behavior is also exercised in | `VersionTooLow` during connection | tmux is below the library floor. | `Error::UnsupportedTmuxVersion`; construction and diagnostics are covered by [`tests/version.rs`](../tests/version.rs). | Foundation | `implemented` | | `VersionTooLow` for an optional operation | A requested operation or flag is unavailable on the detected tmux. | `Error::UnsupportedCapability`, checked before dispatch. Verified refusing on tmux 3.2a and accepting on 3.3a and 3.7b; covered by [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | | `BadSessionName` | Invalid session-name reason and optional name. | `SessionNameError`, raised by `SessionName::new` rather than by a command: the name is wrong before tmux is asked, so the failure belongs at construction. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `OptionError`, `UnknownOption`, `InvalidOption`, `AmbiguousOption` | Option-error hierarchy selected from tmux stderr. | `Error::OptionRejected { kind, detail }` with `OptionErrorKind::{Unknown, Ambiguous, BadValue}`. Three kinds, not four: tmux resolves a name with `options_match` before the branch that says `unknown option`, so Python's `UnknownOption` cannot fire on any supported release. | Options, hooks, and advanced command families | `implemented` | +| `OptionError`, `UnknownOption`, `InvalidOption`, `AmbiguousOption` | Option-error hierarchy selected from tmux stderr. | `Error::OptionRejected { kind, detail }` with `OptionErrorKind::{Unknown, Ambiguous, BadValue}`. Three kinds, not four: tmux resolves a name with `options_match` before the branch that says `unknown option`, so Python's `UnknownOption` cannot fire on any supported release. A typed write that tmux's option table refuses never reaches tmux and fails with `Error::OptionValueRefused`, whose `OptionValueRefusal` says why. | Options, hooks, and advanced command families | `implemented` | | `UnknownColorOption` | Invalid color mode is raised locally by `Server.cmd`. | Eager `ServerBuilder` color validation through `Error::InvalidServerConfiguration`; covered by [`tests/server_command.rs`](../tests/server_command.rs). | Foundation | `implemented` | | `WaitTimeout` | Retry condition did not become true before timeout. | `test::RetryTimeout`, returned by `test::retry_until`, and distinct from a subprocess timeout so a condition that never held is not read as tmux failing to answer. | Documentation, compatibility, and parity closure | `implemented` | | `VariableUnpackingError` | Malformed environment-variable row. | A future typed environment-row parse error owned by discovery; the current public `Error` has no environment or format-row variant. | Discovery, traversal, refresh, and environment resolution | `planned` | @@ -480,9 +480,9 @@ Sources: `src/libtmux/options.py` and `tests/test_options.py`. | `explode_arrays` | Converts indexed names to sparse arrays; forced unindexed values use index zero. Parse failures warn and preserve or fall back. | Typed sparse parser with explicit parse diagnostics. | Options, hooks, and advanced command families | `planned` | | `explode_complex` | Special decoding for terminal features, terminal overrides, and command aliases. Malformed elements warn and are skipped or preserved. | Typed decoders with structured errors or diagnostics. | Options, hooks, and advanced command families | `planned` | | `OptionsMixin`, `OptionsMixin.__init__`, `default_option_scope` | Public mixin and scope contract exposed by `Server`, `Session`, `Window`, and `Pane`. | Inherent option methods on each handle, sharing `internal::options`. Nothing to import. `Server::options`, `Session::options`, `Window::options`, and `Pane::options` are the inherent surface. | Options, hooks, and advanced command families | `implemented` | -| `set_option`, `unset_option` | Return the same object. Bool values become `on` or `off`; stderr is classified into option exceptions. Deprecated `g` warns and aliases `global_`. | `set_option` and `unset_option` on `Server`, `Session`, `Window`, and `Pane`, with typed scope and `g` folded into the scope; covered by [`tests/options.rs`](../tests/options.rs). | Options, hooks, and advanced command families | `implemented` | +| `set_option`, `unset_option` | Return the same object. Bool values become `on` or `off`; stderr is classified into option exceptions. Deprecated `g` warns and aliases `global_`. | `set_option` and `unset_option` on `Server`, `Session`, `Window`, and `Pane`, with typed scope and `g` folded into the scope. `Server::set_typed_option`, `Session::set_typed_option`, `Window::set_typed_option`, `Pane::set_typed_option`, `Server::set_typed_global_option`, and `Server::set_typed_global_window_option` take a `bool`, a number, or text as `OptionValue` and refuse what tmux's option table would before sending it; covered by [`tests/options.rs`](../tests/options.rs). | Options, hooks, and advanced command families | `implemented` | | `show_options` | One converted dictionary with zero or more entries. Stderr is ignored, so failure may look empty. | `Server::options`, `Session::options`, `Window::options`, and `Pane::options` return decoded maps and fail loudly; covered by [`tests/options.rs`](../tests/options.rs). | Options, hooks, and advanced command families | `implemented` | -| `show_option` | One scalar, sparse or complex collection, or `None`. Stderr is classified; indexed and inherited values are interpreted. | `Server::typed_option`, `Session::typed_option`, `Window::typed_option`, and `Pane::typed_option` return one decoded value; covered by [`tests/options.rs`](../tests/options.rs). | Options, hooks, and advanced command families | `implemented` | +| `show_option` | One scalar, sparse or complex collection, or `None`. Stderr is classified; indexed and inherited values are interpreted. | `Server::typed_option`, `Server::typed_global_option`, `Server::typed_global_window_option`, `Session::typed_option`, `Window::typed_option`, and `Pane::typed_option` return one decoded value, and are the only single-option reads; covered by [`tests/options.rs`](../tests/options.rs). | Options, hooks, and advanced command families | `implemented` | ## Hooks @@ -610,7 +610,7 @@ Sources: `src/libtmux/window.py`, `docs/api/libtmux.window.md`, | `new_window` | Creates and returns a sibling Window through the parent Session. The public `start_directory` annotation incorrectly permits only `None`. | `Session::new_window` takes `NewWindowOptions`, whose start directory is an `Option`. | Object mutations and interactions | `implemented` | | `id`, `name`, `index`, `height`, `width` | Synchronous raw snapshot strings or `None`. | `Window::id`, `Window::name`, `Window::index`, `Window::height`, and `Window::width` are typed synchronous getters. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `__eq__`, `__repr__` | Equality compares only Window ID, ignoring Server identity. | `Window` equality and hashing use `(ServerIdentity, WindowId)`. | Discovery, traversal, refresh, and environment resolution | `implemented` | -| `set_window_option`, `show_window_options`, `show_window_option` | Working deprecated aliases that warn and delegate; return the same Window, a dictionary, or one value/`None`. | Omitted as duplicate names. `Window::set_option`, `Window::options`, `Window::get_option`, and `Window::typed_option` are the primary spelling. Covered by [`tests/options.rs`](../tests/options.rs). | Documentation, compatibility, and parity closure | `implemented` | +| `set_window_option`, `show_window_options`, `show_window_option` | Working deprecated aliases that warn and delegate; return the same Window, a dictionary, or one value/`None`. | Omitted as duplicate names. `Window::set_option`, `Window::set_typed_option`, `Window::options`, and `Window::typed_option` are the primary spelling. Covered by [`tests/options.rs`](../tests/options.rs). | Documentation, compatibility, and parity closure | `implemented` | | `split_window`, `attached_pane`, `select_window`, `kill_window`, `get`, `__getitem__`, `get_by_id`, `where`, `find_where`, `list_panes`, `children` | Public names whose only behavior is raising `DeprecatedError`. | Omit. | Documentation, compatibility, and parity closure | `excluded` | `Window` also exposes the option and hook capabilities listed under their diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index e86111a3..24bec8b3 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -258,6 +258,7 @@ enum libtmux::OptionErrorKind enum libtmux::OptionKind enum libtmux::OptionScope enum libtmux::OptionValue +enum libtmux::OptionValueRefusal enum libtmux::PaneDirection enum libtmux::PaneProgressState enum libtmux::PaneSize @@ -428,7 +429,6 @@ function libtmux::Pane::format: async fn(&self, template: &str) -> Result Result, libtmux::Error> function libtmux::Pane::from_env_value: async fn(server: &libtmux::Server, value: Option>) -> Result, libtmux::Error> function libtmux::Pane::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> -function libtmux::Pane::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Pane::height: fn(&self) -> u32 function libtmux::Pane::hook: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Pane::id: const fn(&self) -> &libtmux::PaneId @@ -466,6 +466,7 @@ function libtmux::Pane::session_id: const fn(&self) -> &libtmux::SessionId function libtmux::Pane::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::set_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::set_title: async fn(&mut self, title: impl Into) -> Result<&mut Self, libtmux::Error> +function libtmux::Pane::set_typed_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Pane::split: async fn(&self, options: impl Into) -> Result function libtmux::Pane::stream_output: async fn(&self) -> Result function libtmux::Pane::stream_output_with_limits: async fn(&self, limits: libtmux::ControlLimits) -> Result @@ -528,9 +529,6 @@ function libtmux::Server::format: async fn(&self, pane: Option<&libtmux::Pane>, function libtmux::Server::from_env: fn() -> Result function libtmux::Server::from_env_value: fn(value: Option>) -> Result function libtmux::Server::generation: async fn(&self) -> Result -function libtmux::Server::get_global_option: async fn(&self, name: &str) -> Result, libtmux::Error> -function libtmux::Server::get_global_window_option: async fn(&self, name: &str) -> Result, libtmux::Error> -function libtmux::Server::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::grant_access: async fn(&self, user: &str, mode: libtmux::AccessMode) -> Result<(), libtmux::Error> function libtmux::Server::has_session: async fn(&self, name: impl AsRef<[u8]>) -> Result function libtmux::Server::hide_environment: async fn(&self, name: &str) -> Result<(), libtmux::Error> @@ -569,6 +567,9 @@ function libtmux::Server::set_global_window_option: async fn(&self, name: &str, function libtmux::Server::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::set_hooks: async fn(&self, name: &str, hooks: &libtmux::IndexedHooks, replace: libtmux::ReplaceMode) -> Result<(), libtmux::Error> function libtmux::Server::set_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> +function libtmux::Server::set_typed_global_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> +function libtmux::Server::set_typed_global_window_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> +function libtmux::Server::set_typed_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::shutdown: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Server::signal_channel: async fn(&self, channel: &str) -> Result<(), libtmux::Error> function libtmux::Server::socket_name: fn(&self) -> Option<&OsStr> @@ -578,6 +579,7 @@ function libtmux::Server::spawn_shell: async fn(&self, command: impl Into Result<(), libtmux::Error> function libtmux::Server::tmux_executable: fn(&self) -> &OsStr function libtmux::Server::typed_global_option: async fn(&self, name: &str) -> Result, libtmux::Error> +function libtmux::Server::typed_global_window_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::typed_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::unbind_key: async fn(&self, table: &str, key: &str) -> Result<(), libtmux::Error> function libtmux::Server::unlock_channel: async fn(&self, channel: &str) -> Result<(), libtmux::Error> @@ -615,7 +617,6 @@ function libtmux::Session::format: async fn(&self, template: &str) -> Result Result, libtmux::Error> function libtmux::Session::from_env_value: async fn(server: &libtmux::Server, value: Option>) -> Result, libtmux::Error> function libtmux::Session::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> -function libtmux::Session::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Session::hide_environment: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Session::hook: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Session::hooks: async fn(&self) -> Result, libtmux::Error> @@ -641,6 +642,7 @@ function libtmux::Session::set_environment: async fn(&self, name: &str, value: i function libtmux::Session::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Session::set_hooks: async fn(&self, name: &str, hooks: &libtmux::IndexedHooks, replace: libtmux::ReplaceMode) -> Result<(), libtmux::Error> function libtmux::Session::set_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> +function libtmux::Session::set_typed_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Session::typed_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Session::unset_environment: async fn(&self, name: &str) -> Result<(), libtmux::Error> function libtmux::Session::unset_hook: async fn(&self, name: &str) -> Result<(), libtmux::Error> @@ -693,7 +695,6 @@ function libtmux::Window::format: async fn(&self, template: &str) -> Result Result, libtmux::Error> function libtmux::Window::from_env_value: async fn(server: &libtmux::Server, value: Option>) -> Result, libtmux::Error> function libtmux::Window::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> -function libtmux::Window::get_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Window::has_activity: const fn(&self) -> bool function libtmux::Window::has_bell: const fn(&self) -> bool function libtmux::Window::height: fn(&self) -> u32 @@ -733,6 +734,7 @@ function libtmux::Window::session_id: const fn(&self) -> &libtmux::SessionId function libtmux::Window::set_hook: async fn(&self, name: &str, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Window::set_hooks: async fn(&self, name: &str, hooks: &libtmux::IndexedHooks, replace: libtmux::ReplaceMode) -> Result<(), libtmux::Error> function libtmux::Window::set_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> +function libtmux::Window::set_typed_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Window::split: async fn(&self, options: impl Into) -> Result function libtmux::Window::swap_with: async fn(&mut self, other: &Self) -> Result<&mut Self, libtmux::Error> function libtmux::Window::typed_option: async fn(&self, name: &str) -> Result, libtmux::Error> @@ -1027,6 +1029,7 @@ impl Clone for libtmux::OptionKind impl Clone for libtmux::OptionSchema impl Clone for libtmux::OptionScope impl Clone for libtmux::OptionValue +impl Clone for libtmux::OptionValueRefusal impl Clone for libtmux::OutputLimits impl Clone for libtmux::Pane impl Clone for libtmux::PaneDirection @@ -1201,6 +1204,7 @@ impl Debug for libtmux::OptionKind impl Debug for libtmux::OptionSchema impl Debug for libtmux::OptionScope impl Debug for libtmux::OptionValue +impl Debug for libtmux::OptionValueRefusal impl Debug for libtmux::OutputLimits impl Debug for libtmux::Pane impl Debug for libtmux::PaneDirection @@ -1308,6 +1312,7 @@ impl Display for libtmux::IdParseError impl Display for libtmux::Layout impl Display for libtmux::ListingDecodeError impl Display for libtmux::ObjectKind +impl Display for libtmux::OptionValueRefusal impl Display for libtmux::Pane impl Display for libtmux::PaneId impl Display for libtmux::PaneSize @@ -1359,6 +1364,7 @@ impl Eq for libtmux::OptionKind impl Eq for libtmux::OptionSchema impl Eq for libtmux::OptionScope impl Eq for libtmux::OptionValue +impl Eq for libtmux::OptionValueRefusal impl Eq for libtmux::OutputLimits impl Eq for libtmux::Pane impl Eq for libtmux::PaneDirection @@ -1441,21 +1447,30 @@ impl From<&String> for libtmux::TmuxArg impl From<&libtmux::TmuxText> for libtmux::LayoutSpec impl From<&libtmux::TmuxText> for libtmux::TmuxArg impl From<&str> for libtmux::LayoutSpec +impl From<&str> for libtmux::OptionValue impl From<&str> for libtmux::TmuxArg impl From<&str> for libtmux::TmuxText impl From for libtmux::LayoutSpec impl From for libtmux::TmuxArg impl From for libtmux::TmuxArg impl From for libtmux::LayoutSpec +impl From for libtmux::OptionValue impl From for libtmux::TmuxArg impl From for libtmux::TmuxText impl From> for libtmux::TmuxText +impl From for libtmux::OptionValue +impl From for libtmux::OptionValue +impl From for libtmux::OptionValue +impl From for libtmux::OptionValue +impl From for libtmux::OptionValue impl From for libtmux::LayoutSpec +impl From for libtmux::TmuxText impl From for libtmux::PaneTarget impl From for libtmux::plan::PaneTarget impl From for libtmux::SessionTarget impl From for libtmux::plan::SessionTarget impl From for libtmux::SplitOptions +impl From for libtmux::OptionValue impl From for libtmux::WindowTarget impl From for libtmux::plan::WindowTarget impl From for libtmux::plan::Op @@ -1474,6 +1489,9 @@ impl From> for libtmux::plan::PaneT impl From> for libtmux::plan::SessionTarget impl From> for libtmux::plan::WindowTarget impl From for libtmux::plan::Op +impl From for libtmux::OptionValue +impl From for libtmux::OptionValue +impl From for libtmux::OptionValue impl FromStr for libtmux::PaneId impl FromStr for libtmux::SessionId impl FromStr for libtmux::SessionName @@ -1569,6 +1587,7 @@ impl PartialEq for libtmux::OptionKind impl PartialEq for libtmux::OptionSchema impl PartialEq for libtmux::OptionScope impl PartialEq for libtmux::OptionValue +impl PartialEq for libtmux::OptionValueRefusal impl PartialEq for libtmux::OutputLimits impl PartialEq for libtmux::Pane impl PartialEq for libtmux::PaneDirection @@ -1988,6 +2007,8 @@ struct_field libtmux::Error::OptionRejected::kind: libtmux::OptionErrorKind struct_field libtmux::Error::OptionScopeMismatch::declared: &'static [libtmux::OptionScope] struct_field libtmux::Error::OptionScopeMismatch::option: String struct_field libtmux::Error::OptionScopeMismatch::requested: libtmux::OptionScope +struct_field libtmux::Error::OptionValueRefused::option: &'static str +struct_field libtmux::Error::OptionValueRefused::reason: libtmux::OptionValueRefusal struct_field libtmux::Error::OutputLimitExceeded::command: libtmux::CommandSummary struct_field libtmux::Error::OutputLimitExceeded::limit: usize struct_field libtmux::Error::OutputLimitExceeded::request_id: u64 @@ -2034,6 +2055,9 @@ struct_field libtmux::LayoutSpec::Saved::0: std::ffi::OsString struct_field libtmux::OptionValue::Flag::0: bool struct_field libtmux::OptionValue::Number::0: i64 struct_field libtmux::OptionValue::Text::0: libtmux::TmuxText +struct_field libtmux::OptionValueRefusal::NotAChoice::choices: &'static [&'static str] +struct_field libtmux::OptionValueRefusal::OutOfRange::range: std::ops::RangeInclusive +struct_field libtmux::OptionValueRefusal::WrongKind::expected: libtmux::OptionKind struct_field libtmux::PaneFields::alternate_saved_x: libtmux::query::IntegerField struct_field libtmux::PaneFields::alternate_saved_y: libtmux::query::IntegerField struct_field libtmux::PaneFields::bracket_paste_flag: libtmux::query::BoolField @@ -2275,6 +2299,7 @@ variant libtmux::Error::NoEffect variant libtmux::Error::ObjectGone variant libtmux::Error::OptionRejected variant libtmux::Error::OptionScopeMismatch +variant libtmux::Error::OptionValueRefused variant libtmux::Error::OutputLimitExceeded variant libtmux::Error::Overloaded variant libtmux::Error::ReadOutput @@ -2334,6 +2359,9 @@ variant libtmux::OptionScope::Window variant libtmux::OptionValue::Flag variant libtmux::OptionValue::Number variant libtmux::OptionValue::Text +variant libtmux::OptionValueRefusal::NotAChoice +variant libtmux::OptionValueRefusal::OutOfRange +variant libtmux::OptionValueRefusal::WrongKind variant libtmux::PaneDirection::Above variant libtmux::PaneDirection::Below variant libtmux::PaneDirection::Left diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index 9954e62b..c25d9b95 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -498,6 +498,21 @@ pub enum Error { declared: &'static [crate::OptionScope], }, + /// A typed option write that tmux's option table refuses, so nothing was + /// sent. + /// + /// Raised by `set_typed_option` and its siblings before dispatch, never by + /// tmux, and the option is left as it was. [`crate::OptionValue`] says + /// which variant each kind of option takes. The value is not kept, as no + /// option value is; `reason` says what the option takes instead. + #[error("refused to write {option} before sending it: {reason}")] + OptionValueRefused { + /// The option, as tmux's table names it. + option: &'static str, + /// What the option takes that the value is not. + reason: crate::OptionValueRefusal, + }, + /// tmux answered a format query with a value this crate cannot read. /// /// Reports a disagreement between the crate and the tmux that answered, @@ -1377,6 +1392,11 @@ impl fmt::Debug for Error { .field("requested", requested) .field("declared", declared) .finish(), + Self::OptionValueRefused { option, reason } => formatter + .debug_struct("OptionValueRefused") + .field("option", option) + .field("reason", reason) + .finish(), Self::RuntimeNested => formatter.debug_struct("RuntimeNested").finish(), Self::UnrecognizedLayout => formatter.debug_struct("UnrecognizedLayout").finish(), Self::AmbiguousLayout { input, candidates } => formatter diff --git a/crates/libtmux/src/error/classification.rs b/crates/libtmux/src/error/classification.rs index 3f73f02d..002c00c8 100644 --- a/crates/libtmux/src/error/classification.rs +++ b/crates/libtmux/src/error/classification.rs @@ -96,7 +96,8 @@ impl Error { | Self::CapabilityDefective { .. } => ErrorKind::UnsupportedVersion, Self::InvalidCommandInput { .. } | Self::ServerMismatch { .. } - | Self::OptionScopeMismatch { .. } => ErrorKind::InvalidInput, + | Self::OptionScopeMismatch { .. } + | Self::OptionValueRefused { .. } => ErrorKind::InvalidInput, #[cfg(feature = "plan")] Self::InvalidPlan { .. } => ErrorKind::InvalidInput, Self::Spawn { .. } @@ -182,6 +183,7 @@ impl Error { | Self::VersionProbeFailed { .. } | Self::InvalidCommandInput { .. } | Self::OptionScopeMismatch { .. } + | Self::OptionValueRefused { .. } | Self::ServerMismatch { .. } | Self::ExecutableNotFound { .. } | Self::ExecutorShutdown { .. } diff --git a/crates/libtmux/src/error/refusal.rs b/crates/libtmux/src/error/refusal.rs index c66eff5b..03ef542a 100644 --- a/crates/libtmux/src/error/refusal.rs +++ b/crates/libtmux/src/error/refusal.rs @@ -59,7 +59,7 @@ impl Error { // different files. `cmd-find.c` resolves a target and says "can't // find"; `options.c` and the environment commands resolve their own // and say "no such". A caller asking `is_object_gone` about one dead - // session got `true` from `windows()` and `false` from `get_option` + // session got `true` from `windows()` and `false` from `typed_option` // until both were matched here. const MISSING: [(&str, ObjectKind); 7] = [ ("can't find session:", ObjectKind::Session), diff --git a/crates/libtmux/src/internal/options.rs b/crates/libtmux/src/internal/options.rs index 67f8320d..73000d37 100644 --- a/crates/libtmux/src/internal/options.rs +++ b/crates/libtmux/src/internal/options.rs @@ -121,6 +121,17 @@ pub(crate) async fn get( Ok(Some(TmuxText::from(value.to_vec()))) } +/// Read one option, decoded by its declared kind. +pub(crate) async fn get_typed( + core: &Core, + scope: Scope<'_>, + name: &str, +) -> Result, Error> { + Ok(get(core, scope, name) + .await? + .map(|value| OptionValue::decode(name, value))) +} + /// List the option names present at one scope. /// /// Array options repeat once per index, so a name may carry an `[n]` suffix @@ -170,6 +181,38 @@ pub(crate) async fn set( .await } +/// Check a typed value against tmux's option table, then set it. +/// +/// Checked before `set` runs `ensure_scope`, which may ask tmux for its +/// version, so a refused value costs no tmux command. +pub(crate) async fn set_typed( + core: &Core, + scope: Scope<'_>, + name: &str, + value: OptionValue, +) -> Result<(), Error> { + // A user option has no entry in the table, and neither has a name newer + // than it; both go to tmux as written. + if let Some(schema) = crate::option_schema(name) { + schema + .check(&value) + .map_err(|reason| Error::OptionValueRefused { + option: schema.name(), + reason, + })?; + } + + let text = TmuxText::from(value); + set( + core, + scope, + name, + OsString::from_vec(text.as_bytes().to_vec()), + false, + ) + .await +} + /// Remove one option, restoring whatever it inherits. pub(crate) async fn unset(core: &Core, scope: Scope<'_>, name: &str) -> Result<(), Error> { ensure_scope(core, scope, name).await?; diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index d485031b..5ed71935 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -88,6 +88,8 @@ //! tmux reports no type over the command line, so the crate generates the //! schema from tmux's own table. That matters more than it sounds: `status` //! holds `"on"` but is a choice, because tmux also accepts `2` through `5`. +//! A typed write is checked against the same table before it is sent; +//! [`OptionValue`] says how reads and writes fit together. //! //! ```no_run //! # async fn options(server: &libtmux::Server) -> Result<(), libtmux::Error> { @@ -96,6 +98,10 @@ //! // Names are constants, so a typo does not compile. //! let mouse = server.typed_global_option(option_names::MOUSE).await?; //! assert!(matches!(mouse, Some(OptionValue::Flag(_)))); +//! +//! // A write takes the type a read returns, and a value outside what the +//! // table declares is refused before tmux sees it. +//! server.set_typed_global_option(option_names::HISTORY_LIMIT, 50_000).await?; //! # Ok(()) //! # } //! ``` @@ -364,7 +370,8 @@ pub use hooks::{IndexedHooks, ReplaceMode, SparseValues}; pub use limits::{ControlClientLimits, ControlLimits}; pub use limits::{DispatchLimits, OutputLimits}; pub use options::{ - OptionKind, OptionSchema, OptionScope, OptionValue, names as option_names, option_schema, + OptionKind, OptionSchema, OptionScope, OptionValue, OptionValueRefusal, names as option_names, + option_schema, }; pub use pane::{CaptureOptions, CapturedLine, Pane, PaneWait}; pub use server::{ diff --git a/crates/libtmux/src/options.rs b/crates/libtmux/src/options.rs index a325873e..4745bb19 100644 --- a/crates/libtmux/src/options.rs +++ b/crates/libtmux/src/options.rs @@ -10,6 +10,7 @@ mod generated; pub use generated::names; +use std::fmt; use std::ops::RangeInclusive; use crate::formats::TmuxText; @@ -202,6 +203,118 @@ impl OptionSchema { pub fn range(&self) -> Option> { self.range.map(|(minimum, maximum)| minimum..=maximum) } + + /// Check a value against what the table declares, before it is sent. + /// + /// The value must be the variant [`typed_option`](crate::Server::typed_option) + /// reads back for this option. + pub(crate) fn check(&self, value: &OptionValue) -> Result<(), OptionValueRefusal> { + match (self.kind, value) { + (OptionKind::Number, OptionValue::Number(number)) => match self.range() { + Some(range) if !range.contains(number) => { + Err(OptionValueRefusal::OutOfRange { range }) + } + _ => Ok(()), + }, + (OptionKind::Choice, OptionValue::Text(text)) => { + if self + .choices + .iter() + .any(|choice| choice.as_bytes() == text.as_bytes()) + { + Ok(()) + } else { + Err(OptionValueRefusal::NotAChoice { + choices: self.choices, + }) + } + } + (OptionKind::Flag, OptionValue::Flag(_)) + | ( + OptionKind::Text | OptionKind::Colour | OptionKind::Key | OptionKind::Command, + OptionValue::Text(_), + ) => Ok(()), + _ => Err(OptionValueRefusal::WrongKind { + expected: self.kind, + }), + } + } +} + +/// Why a typed option write was refused before it reached tmux. +/// +/// Carried by [`crate::Error::OptionValueRefused`]. Never holds the value, +/// which is treated as sensitive like every option value. +/// +/// # Examples +/// +/// ``` +/// use libtmux::{OptionKind, OptionValueRefusal}; +/// +/// fn advise(refusal: &OptionValueRefusal) -> String { +/// match refusal { +/// OptionValueRefusal::WrongKind { expected } => format!("pass a {expected:?}"), +/// OptionValueRefusal::NotAChoice { choices } => format!("pick one of {choices:?}"), +/// OptionValueRefusal::OutOfRange { range } => format!("stay within {range:?}"), +/// _ => "check the value".to_owned(), +/// } +/// } +/// +/// let refusal = OptionValueRefusal::WrongKind { expected: OptionKind::Flag }; +/// assert_eq!(advise(&refusal), "pass a Flag"); +/// ``` +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OptionValueRefusal { + /// The value is not the variant this option reads back as: a flag needs + /// [`OptionValue::Flag`], a number [`OptionValue::Number`], and every + /// other kind [`OptionValue::Text`]. + WrongKind { + /// What the option holds. + expected: OptionKind, + }, + /// The option holds one of a fixed set of words, and the value is none of + /// them. + NotAChoice { + /// Every word the option accepts. + choices: &'static [&'static str], + }, + /// The number is outside the range the option accepts. + OutOfRange { + /// The inclusive range the option accepts. + range: RangeInclusive, + }, +} + +impl fmt::Display for OptionValueRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongKind { expected } => { + let (holds, variant) = match expected { + OptionKind::Flag => ("a flag", "Flag"), + OptionKind::Number => ("a number", "Number"), + OptionKind::Choice => ("one of a fixed set of words", "Text"), + OptionKind::Text => ("text", "Text"), + OptionKind::Colour => ("a colour", "Text"), + OptionKind::Key => ("a key", "Text"), + OptionKind::Command => ("a command", "Text"), + }; + write!( + formatter, + "it holds {holds}, written as OptionValue::{variant}" + ) + } + Self::NotAChoice { choices } => { + write!(formatter, "it accepts only {}", choices.join(", ")) + } + Self::OutOfRange { range } => write!( + formatter, + "it accepts {} through {}", + range.start(), + range.end() + ), + } + } } /// Look up what tmux declares about one option. @@ -260,11 +373,46 @@ pub fn option_schema(name: &str) -> Option<&'static OptionSchema> { matched } -/// One option's value, decoded according to what tmux declares about it. +/// One option's value, typed by what tmux's own option table declares. +/// +/// Every handle reads and writes options the same way: +/// +/// | To | Call | +/// | --- | --- | +/// | read one value | `typed_option`, and on [`Server`] also `typed_global_option` and `typed_global_window_option` | +/// | read every value set at one scope | `options` | +/// | list the names set at one scope | `option_names` | +/// | write a value checked against the table | `set_typed_option`, and on [`Server`] also `set_typed_global_option` and `set_typed_global_window_option` | +/// | write text unchecked | `set_option`, `append_option`, and on [`Server`] `set_global_option`, `set_global_window_option`, and the array writes | +/// +/// **Reads** decode by declared kind: a flag arrives as [`Self::Flag`], a +/// number as [`Self::Number`], and everything else as [`Self::Text`]. Nothing +/// is lost in decoding: `TmuxText::from(value)` gives back the bytes tmux +/// stored. +/// +/// **A typed write** takes the variant a read returns, and checks it against +/// the table before anything is sent. The wrong variant, a word outside a +/// choice's set, and a number outside its range each fail with +/// [`Error::OptionValueRefused`](crate::Error::OptionValueRefused) and leave +/// the option unchanged. `status` reads `on` and is a choice, not a flag, so +/// it takes `"on"`, not `true`. +/// +/// Two writes are not checked, because there is nothing to check against: +/// +/// - A user option, whose name begins with `@`. tmux keeps no type for one, so +/// the value is stored as the text a read would show for it -- `true` as +/// `on`, `3` as `3` -- and reads back as [`Self::Text`]. +/// - A name the table does not declare. It is sent as written, and tmux +/// answers for it. +/// +/// Appending and the array writes have no typed form: tmux appends to text, +/// and every array option holds text or commands. /// -/// This is what [`crate::Server::typed_option`] and its per-object siblings -/// return, so a caller reading `status` gets a flag without deciding for -/// itself that `on` means one. +/// The table is generated from the newest tmux release this crate supports. +/// An older release refuses what it lacks on its own. A newer one may accept a +/// word the table does not list; `set_option` sends that unchecked. +/// +/// [`Server`]: crate::Server /// /// # Examples /// @@ -272,15 +420,15 @@ pub fn option_schema(name: &str) -> Option<&'static OptionSchema> { /// # fn main() -> Result<(), Box> { /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; /// # runtime.block_on(async { -/// use libtmux::OptionValue; +/// use libtmux::{Error, OptionValue, OptionValueRefusal}; /// /// let guard = libtmux::test::TestServer::new().await?; /// let server = guard.server(); /// server.new_session("typed").await?; /// -/// // `mouse` is a flag, so `on` arrives as one. -/// let mouse = server.typed_global_option("mouse").await?.expect("mouse is set"); -/// assert!(matches!(mouse, OptionValue::Flag(false))); +/// // `mouse` is a flag, so it is written and read back as one. +/// server.set_typed_global_option("mouse", true).await?; +/// assert_eq!(server.typed_global_option("mouse").await?, Some(OptionValue::Flag(true))); /// /// // `status` also reads `on`, and is *not* a flag: tmux accepts `on`, `off`, /// // and `2` through `5`. Inferring the type from the value would call this a @@ -289,6 +437,16 @@ pub fn option_schema(name: &str) -> Option<&'static OptionSchema> { /// let status = server.typed_global_option("status").await?.expect("status is set"); /// assert!(matches!(status, OptionValue::Text(_))); /// +/// // A word tmux's table does not list for a choice is refused unsent. +/// let refused = server +/// .set_typed_global_option("status-position", "sideways") +/// .await +/// .expect_err("not a position tmux has"); +/// assert!(matches!( +/// refused, +/// Error::OptionValueRefused { reason: OptionValueRefusal::NotAChoice { .. }, .. }, +/// )); +/// /// guard.shutdown().await?; /// # Ok::<(), Box>(()) /// # })?; @@ -305,11 +463,75 @@ pub enum OptionValue { /// Text, which covers choices, colours, keys, commands, and user options. /// /// tmux validates a choice when it is set, so a value read back is one - /// tmux accepted. The variants are not enumerated here because they differ - /// per option and per release. + /// tmux accepted. [`OptionSchema::choices`] lists the words a choice + /// takes. Text(TmuxText), } +impl From for OptionValue { + fn from(value: bool) -> Self { + Self::Flag(value) + } +} + +// Only the integers every value of which fits tmux's `i64`: a `u64` or `usize` +// caller converts with `i64::try_from` and decides what an overflow means. +macro_rules! number_from { + ($($integer:ty),*) => {$( + impl From<$integer> for OptionValue { + fn from(value: $integer) -> Self { + Self::Number(i64::from(value)) + } + } + )*}; +} + +number_from!(i8, i16, i32, i64, u8, u16, u32); + +impl From<&str> for OptionValue { + fn from(value: &str) -> Self { + Self::Text(TmuxText::from(value)) + } +} + +impl From for OptionValue { + fn from(value: String) -> Self { + Self::Text(TmuxText::from(value)) + } +} + +impl From for OptionValue { + fn from(value: TmuxText) -> Self { + Self::Text(value) + } +} + +/// The bytes tmux stores for a value: `on` or `off` for a flag, decimal for a +/// number, and text unchanged. +/// +/// Exact for a value read back through `typed_option`, since tmux prints a +/// flag and a number in these same forms. +/// +/// # Examples +/// +/// ``` +/// use libtmux::{OptionValue, TmuxText}; +/// +/// assert_eq!(TmuxText::from(OptionValue::Flag(true)), "on"); +/// assert_eq!(TmuxText::from(OptionValue::Number(-3)), "-3"); +/// assert_eq!(TmuxText::from(OptionValue::from("vi")), "vi"); +/// ``` +impl From for TmuxText { + fn from(value: OptionValue) -> Self { + match value { + OptionValue::Flag(true) => Self::from("on"), + OptionValue::Flag(false) => Self::from("off"), + OptionValue::Number(number) => Self::from(number.to_string()), + OptionValue::Text(text) => text, + } + } +} + impl OptionValue { /// Decode a stored value according to an option's declared kind. /// diff --git a/crates/libtmux/src/pane/settings.rs b/crates/libtmux/src/pane/settings.rs index c501486f..c64a1bb1 100644 --- a/crates/libtmux/src/pane/settings.rs +++ b/crates/libtmux/src/pane/settings.rs @@ -3,33 +3,17 @@ use std::collections::BTreeMap; use std::ffi::OsString; -use crate::formats::TmuxText; use crate::internal::options; use crate::{Error, IndexedHooks, OptionValue}; use super::Pane; impl Pane { - /// Read one option's exact stored value. - /// - /// A user option, whose name begins with `@`, exists only while it is - /// set, so an unset one reports `None`. A built-in option always exists, - /// so an unset one also reports `None`. An unrecognized built-in name is - /// an error. - /// - /// # Errors - /// - /// Returns an error when tmux does not recognize the option name. - pub async fn get_option(&self, name: &str) -> Result, Error> { - let target = self.id().to_string(); - options::get(&self.core, options::Scope::Pane(&target), name).await - } - /// List the option names set at this pane's scope. /// /// Values are not included: tmux renders them for display with three /// different quoting styles, so re-parsing them would be guesswork. Read - /// each value with [`Self::get_option`], which returns exact bytes. + /// each value with [`Self::typed_option`], which decodes the exact bytes. /// /// # Errors /// @@ -80,10 +64,11 @@ impl Pane { options::typed_all(&self.core, options::Scope::Pane(&target)).await } - /// Set one option. + /// Set one option to text, unchecked. /// /// The value is marked sensitive, so it never reaches `Debug`, an error, - /// or a tracing span. + /// or a tracing span. tmux validates it; [`Self::set_typed_option`] + /// checks it against tmux's option table first. /// /// # Errors /// @@ -103,6 +88,46 @@ impl Pane { .await } + /// Set one option to a typed value, checked before it is sent. + /// + /// The value must be the variant [`Self::typed_option`] reads back for + /// the option; [`OptionValue`] gives the rules, and the two writes it + /// cannot check. The value is marked sensitive, as in + /// [`Self::set_option`]. + /// + /// # Errors + /// + /// Returns [`crate::Error::OptionValueRefused`] without sending anything + /// when tmux's option table refuses the value. + /// + /// Returns [`crate::Error::OptionScopeMismatch`] when tmux keeps the + /// option in another of its tables, and an error when tmux rejects the + /// name or value. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(pane: &libtmux::Pane) -> Result<(), libtmux::Error> { + /// // A choice takes the word it reads back as, not a flag. + /// pane.set_typed_option("remain-on-exit", "failed").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_typed_option( + &self, + name: &str, + value: impl Into, + ) -> Result<(), Error> { + let target = self.id().to_string(); + options::set_typed( + &self.core, + options::Scope::Pane(&target), + name, + value.into(), + ) + .await + } + /// Append to one option rather than replacing it. /// /// # Errors @@ -132,7 +157,7 @@ impl Pane { /// Set one hook to a tmux command. /// /// Hooks live in the same option tables, so a hook is an array option and - /// [`Self::get_option`] reads it under an indexed name such as + /// [`Self::typed_option`] reads it under an indexed name such as /// `after-new-window[0]`. /// /// # Errors @@ -204,16 +229,18 @@ impl Pane { /// A flag comes back as [`OptionValue::Flag`] and a number as /// [`OptionValue::Number`], so a caller does not decide for itself that /// `on` means one. Everything else, including user options, stays text. + /// The one way to read a single option; [`OptionValue`] says how reads + /// and writes fit together. + /// + /// `None` means the option holds nothing at this pane. A user option, + /// whose name begins with `@`, exists only while it is set, and a built-in + /// one always exists, so both report an unset option as `None`. /// /// # Errors /// /// Returns an error when tmux does not recognize the option name. pub async fn typed_option(&self, name: &str) -> Result, Error> { let target = self.id().to_string(); - Ok( - options::get(&self.core, options::Scope::Pane(&target), name) - .await? - .map(|value| OptionValue::decode(name, value)), - ) + options::get_typed(&self.core, options::Scope::Pane(&target), name).await } } diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 2dd48597..7cca3c70 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -260,10 +260,11 @@ impl PromptKind { /// **Changing things.** [`new_session`], [`kill`], and [`with_session`], /// which cleans up after itself whether the body succeeded or not. /// -/// **Options and hooks.** [`get_option`] and [`set_option`] for this server, -/// [`get_global_option`] and [`set_global_option`] for the session and window -/// defaults, [`typed_option`] to get a value tmux's own schema has typed, and -/// [`set_hook`] and [`unset_hook`]. +/// **Options and hooks.** [`typed_option`] and [`set_typed_option`] for this +/// server, [`typed_global_option`] and [`set_typed_global_option`] for the +/// session defaults, [`set_option`] to send text tmux's option table does not +/// check, and [`set_hook`] and [`unset_hook`]. [`OptionValue`] says how the +/// reads and writes fit together. /// /// **Everything else tmux keeps.** Paste buffers ([`buffer`], [`set_buffer`], /// [`buffer_names`], [`delete_buffer`]), key bindings ([`bind_key`], @@ -297,11 +298,12 @@ impl PromptKind { /// [`new_session`]: Server::new_session /// [`kill`]: Server::kill /// [`with_session`]: Server::with_session -/// [`get_option`]: Server::get_option -/// [`set_option`]: Server::set_option -/// [`get_global_option`]: Server::get_global_option -/// [`set_global_option`]: Server::set_global_option /// [`typed_option`]: Server::typed_option +/// [`set_typed_option`]: Server::set_typed_option +/// [`typed_global_option`]: Server::typed_global_option +/// [`set_typed_global_option`]: Server::set_typed_global_option +/// [`set_option`]: Server::set_option +/// [`OptionValue`]: crate::OptionValue /// [`set_hook`]: Server::set_hook /// [`unset_hook`]: Server::unset_hook /// [`buffer`]: Server::buffer diff --git a/crates/libtmux/src/server/settings.rs b/crates/libtmux/src/server/settings.rs index cf3a767b..aa2d9222 100644 --- a/crates/libtmux/src/server/settings.rs +++ b/crates/libtmux/src/server/settings.rs @@ -29,24 +29,11 @@ fn array_scope(name: &str) -> options::Scope<'static> { } impl Server { - /// Read one server option's exact stored value. - /// - /// Returns `None` when the option is known but holds no value. tmux - /// prints nothing in that case, so an option set to the empty string - /// cannot be told apart from an unset one. - /// - /// # Errors - /// - /// Returns an error when tmux does not recognize the option name. - pub async fn get_option(&self, name: &str) -> Result, Error> { - options::get(&self.core, options::Scope::Server, name).await - } - /// List the server option names. /// /// Values are not included: tmux renders them for display with three /// different quoting styles, so re-parsing them would be guesswork. Read - /// each value with [`Server::get_option`], which returns exact bytes. + /// each value with [`Self::typed_option`], which decodes the exact bytes. /// /// # Errors /// @@ -97,7 +84,10 @@ impl Server { options::typed_all(&self.core, options::Scope::Server).await } - /// Set one server option. + /// Set one server option to text, unchecked. + /// + /// tmux validates the value itself. [`Self::set_typed_option`] checks it + /// against tmux's option table first; [`OptionValue`] compares the two. /// /// # Errors /// @@ -106,6 +96,34 @@ impl Server { options::set(&self.core, options::Scope::Server, name, value, false).await } + /// Set one server option to a typed value, checked before it is sent. + /// + /// The value must be the variant [`Self::typed_option`] reads back for + /// the option; [`OptionValue`] gives the rules, and what is not checked. + /// + /// # Errors + /// + /// Returns [`Error::OptionValueRefused`] without sending anything when + /// tmux's option table refuses the value, and + /// [`Error::OptionScopeMismatch`] when the option is not a server option. + /// Otherwise returns an error when tmux rejects the name or value. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// server.set_typed_option("escape-time", 10).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_typed_option( + &self, + name: &str, + value: impl Into, + ) -> Result<(), Error> { + options::set_typed(&self.core, options::Scope::Server, name, value.into()).await + } + /// Remove one server option. /// /// # Errors @@ -115,18 +133,10 @@ impl Server { options::unset(&self.core, options::Scope::Server, name).await } - /// Read one global session option. + /// Set one global session option to text, unchecked. /// /// Sessions inherit from this table, so it is where a default belongs. - /// - /// # Errors - /// - /// Returns an error when tmux does not recognize the option name. - pub async fn get_global_option(&self, name: &str) -> Result, Error> { - options::get(&self.core, options::Scope::GlobalSession, name).await - } - - /// Set one global session option. + /// [`Self::set_typed_global_option`] checks the value first. /// /// # Errors /// @@ -146,6 +156,44 @@ impl Server { .await } + /// Set one global session option to a typed value, checked before it is + /// sent. + /// + /// The value must be the variant [`Self::typed_global_option`] reads back + /// for the option; [`OptionValue`] gives the rules, and what is not + /// checked. + /// + /// # Errors + /// + /// Returns [`Error::OptionValueRefused`] without sending anything when + /// tmux's option table refuses the value, and + /// [`Error::OptionScopeMismatch`] when the option is not a session option. + /// Otherwise returns an error when tmux rejects the name or value. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// server.set_typed_global_option("mouse", true).await?; + /// server.set_typed_global_option("history-limit", 50_000).await?; + /// server.set_typed_global_option("status-position", "top").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_typed_global_option( + &self, + name: &str, + value: impl Into, + ) -> Result<(), Error> { + options::set_typed( + &self.core, + options::Scope::GlobalSession, + name, + value.into(), + ) + .await + } + /// Set a variable in the server's own environment. /// /// tmux keeps this and each session's environment in separate stores, and @@ -363,16 +411,9 @@ impl Server { options::unset(&self.core, array_scope(name), &format!("{name}[{index}]")).await } - /// Read one global window option. + /// Set one global window option to text, unchecked. /// - /// # Errors - /// - /// Returns an error when tmux does not recognize the option name. - pub async fn get_global_window_option(&self, name: &str) -> Result, Error> { - options::get(&self.core, options::Scope::GlobalWindow, name).await - } - - /// Set one global window option. + /// [`Self::set_typed_global_window_option`] checks the value first. /// /// # Errors /// @@ -385,10 +426,40 @@ impl Server { options::set(&self.core, options::Scope::GlobalWindow, name, value, false).await } + /// Set one global window option to a typed value, checked before it is + /// sent. + /// + /// The value must be the variant [`Self::typed_global_window_option`] + /// reads back for the option; [`OptionValue`] gives the rules, and what + /// is not checked. + /// + /// # Errors + /// + /// Returns [`Error::OptionValueRefused`] without sending anything when + /// tmux's option table refuses the value, and + /// [`Error::OptionScopeMismatch`] when the option is not a window option. + /// Otherwise returns an error when tmux rejects the name or value. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// server.set_typed_global_window_option("mode-keys", "vi").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_typed_global_window_option( + &self, + name: &str, + value: impl Into, + ) -> Result<(), Error> { + options::set_typed(&self.core, options::Scope::GlobalWindow, name, value.into()).await + } + /// Set one global hook. /// - /// Hooks live in the option tables, so [`Server::get_global_option`] reads - /// one back under an indexed name such as `after-new-window[0]`. + /// Hooks live in the option tables, so [`Server::typed_global_option`] + /// reads one back under an indexed name such as `after-new-window[0]`. /// /// # Errors /// @@ -531,25 +602,57 @@ impl Server { /// Read one server option, decoded according to its declared kind. /// + /// The one way to read a single option; [`OptionValue`] says how reads + /// and writes fit together. `TmuxText::from(value)` recovers the exact + /// bytes tmux stored. + /// + /// `None` means the option holds nothing: a built-in option that is unset, + /// or a user option that was never set. tmux prints nothing for an option + /// set to the empty string either, so that also reads as `None`. + /// /// # Errors /// /// Returns an error when tmux does not recognize the option name. pub async fn typed_option(&self, name: &str) -> Result, Error> { - Ok(options::get(&self.core, options::Scope::Server, name) - .await? - .map(|value| OptionValue::decode(name, value))) + options::get_typed(&self.core, options::Scope::Server, name).await } /// Read one global session option, decoded according to its declared kind. /// + /// Sessions inherit from this table. Absence and decoding work as in + /// [`Self::typed_option`]. + /// /// # Errors /// /// Returns an error when tmux does not recognize the option name. pub async fn typed_global_option(&self, name: &str) -> Result, Error> { - Ok( - options::get(&self.core, options::Scope::GlobalSession, name) - .await? - .map(|value| OptionValue::decode(name, value)), - ) + options::get_typed(&self.core, options::Scope::GlobalSession, name).await + } + + /// Read one global window option, decoded according to its declared kind. + /// + /// Windows inherit from this table. Absence and decoding work as in + /// [`Self::typed_option`]. + /// + /// # Errors + /// + /// Returns an error when tmux does not recognize the option name. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(server: &libtmux::Server) -> Result<(), libtmux::Error> { + /// use libtmux::OptionValue; + /// + /// let base = server.typed_global_window_option("pane-base-index").await?; + /// assert!(matches!(base, Some(OptionValue::Number(_)))); + /// # Ok(()) + /// # } + /// ``` + pub async fn typed_global_window_option( + &self, + name: &str, + ) -> Result, Error> { + options::get_typed(&self.core, options::Scope::GlobalWindow, name).await } } diff --git a/crates/libtmux/src/session/settings.rs b/crates/libtmux/src/session/settings.rs index a31c0277..99ebda5a 100644 --- a/crates/libtmux/src/session/settings.rs +++ b/crates/libtmux/src/session/settings.rs @@ -3,7 +3,6 @@ use std::collections::BTreeMap; use std::ffi::OsString; -use crate::formats::TmuxText; use crate::internal::environment; use crate::internal::options; use crate::{EnvironmentEntry, Error, IndexedHooks, OptionValue, ReplaceMode}; @@ -11,26 +10,11 @@ use crate::{EnvironmentEntry, Error, IndexedHooks, OptionValue, ReplaceMode}; use super::Session; impl Session { - /// Read one option's exact stored value. - /// - /// A user option, whose name begins with `@`, exists only while it is - /// set, so an unset one reports `None`. A built-in option always exists, - /// so an unset one also reports `None`. An unrecognized built-in name is - /// an error. - /// - /// # Errors - /// - /// Returns an error when tmux does not recognize the option name. - pub async fn get_option(&self, name: &str) -> Result, Error> { - let target = self.id().to_string(); - options::get(&self.core, options::Scope::Session(&target), name).await - } - /// List the option names set at this session's scope. /// /// Values are not included: tmux renders them for display with three /// different quoting styles, so re-parsing them would be guesswork. Read - /// each value with [`Self::get_option`], which returns exact bytes. + /// each value with [`Self::typed_option`], which decodes the exact bytes. /// /// # Errors /// @@ -83,10 +67,11 @@ impl Session { options::typed_all(&self.core, options::Scope::Session(&target)).await } - /// Set one option. + /// Set one option to text, unchecked. /// /// The value is marked sensitive, so it never reaches `Debug`, an error, - /// or a tracing span. + /// or a tracing span. tmux validates it; [`Self::set_typed_option`] + /// checks it against tmux's option table first. /// /// # Errors /// @@ -106,6 +91,66 @@ impl Session { .await } + /// Set one option to a typed value, checked before it is sent. + /// + /// The value must be the variant [`Self::typed_option`] reads back for + /// the option: `true` for `mouse`, a number for `history-limit`, and + /// `"vi"` for the choice `status-keys`. [`OptionValue`] gives the rules, + /// and the two writes it cannot check. The value is marked sensitive, as + /// in [`Self::set_option`]. + /// + /// # Errors + /// + /// Returns [`crate::Error::OptionValueRefused`] without sending anything + /// when tmux's option table refuses the value: the wrong variant, a word + /// outside a choice's set, or a number outside its range. + /// + /// Returns [`crate::Error::OptionScopeMismatch`] when tmux keeps the + /// option in another of its tables, and an error when tmux rejects the + /// name or value. + /// + /// # Examples + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + /// # runtime.block_on(async { + /// use libtmux::{Error, OptionValue}; + /// + /// let guard = libtmux::test::TestServer::new().await?; + /// let session = guard.server().new_session("typed").await?; + /// + /// session.set_typed_option("history-limit", 5000).await?; + /// assert_eq!( + /// session.typed_option("history-limit").await?, + /// Some(OptionValue::Number(5000)), + /// ); + /// + /// // Below the range tmux's table declares, so it is never sent. + /// let refused = session.set_typed_option("history-limit", -1).await; + /// assert!(matches!(refused, Err(Error::OptionValueRefused { .. }))); + /// + /// guard.shutdown().await?; + /// # Ok::<(), Box>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_typed_option( + &self, + name: &str, + value: impl Into, + ) -> Result<(), Error> { + let target = self.id().to_string(); + options::set_typed( + &self.core, + options::Scope::Session(&target), + name, + value.into(), + ) + .await + } + /// Append to one option rather than replacing it. /// /// # Errors @@ -142,7 +187,7 @@ impl Session { /// Set one hook to a tmux command. /// /// Hooks live in the same option tables, so a hook is an array option and - /// [`Self::get_option`] reads it under an indexed name such as + /// [`Self::typed_option`] reads it under an indexed name such as /// `after-new-window[0]`. /// /// # Errors @@ -487,16 +532,18 @@ impl Session { /// A flag comes back as [`OptionValue::Flag`] and a number as /// [`OptionValue::Number`], so a caller does not decide for itself that /// `on` means one. Everything else, including user options, stays text. + /// The one way to read a single option; [`OptionValue`] says how reads + /// and writes fit together. + /// + /// `None` means the option holds nothing at this session. A user option, + /// whose name begins with `@`, exists only while it is set, and a built-in + /// one always exists, so both report an unset option as `None`. /// /// # Errors /// /// Returns an error when tmux does not recognize the option name. pub async fn typed_option(&self, name: &str) -> Result, Error> { let target = self.id().to_string(); - Ok( - options::get(&self.core, options::Scope::Session(&target), name) - .await? - .map(|value| OptionValue::decode(name, value)), - ) + options::get_typed(&self.core, options::Scope::Session(&target), name).await } } diff --git a/crates/libtmux/src/window/settings.rs b/crates/libtmux/src/window/settings.rs index a1fe3a9f..41fd9d7c 100644 --- a/crates/libtmux/src/window/settings.rs +++ b/crates/libtmux/src/window/settings.rs @@ -3,33 +3,17 @@ use std::collections::BTreeMap; use std::ffi::OsString; -use crate::formats::TmuxText; use crate::internal::options; use crate::{Error, IndexedHooks, OptionValue, ReplaceMode}; use super::Window; impl Window { - /// Read one option's exact stored value. - /// - /// A user option, whose name begins with `@`, exists only while it is - /// set, so an unset one reports `None`. A built-in option always exists, - /// so an unset one also reports `None`. An unrecognized built-in name is - /// an error. - /// - /// # Errors - /// - /// Returns an error when tmux does not recognize the option name. - pub async fn get_option(&self, name: &str) -> Result, Error> { - let target = self.id().to_string(); - options::get(&self.core, options::Scope::Window(&target), name).await - } - /// List the option names set at this window's scope. /// /// Values are not included: tmux renders them for display with three /// different quoting styles, so re-parsing them would be guesswork. Read - /// each value with [`Self::get_option`], which returns exact bytes. + /// each value with [`Self::typed_option`], which decodes the exact bytes. /// /// # Errors /// @@ -83,10 +67,11 @@ impl Window { options::typed_all(&self.core, options::Scope::Window(&target)).await } - /// Set one option. + /// Set one option to text, unchecked. /// /// The value is marked sensitive, so it never reaches `Debug`, an error, - /// or a tracing span. + /// or a tracing span. tmux validates it; [`Self::set_typed_option`] + /// checks it against tmux's option table first. /// /// # Errors /// @@ -106,6 +91,46 @@ impl Window { .await } + /// Set one option to a typed value, checked before it is sent. + /// + /// The value must be the variant [`Self::typed_option`] reads back for + /// the option; [`OptionValue`] gives the rules, and the two writes it + /// cannot check. The value is marked sensitive, as in + /// [`Self::set_option`]. + /// + /// # Errors + /// + /// Returns [`crate::Error::OptionValueRefused`] without sending anything + /// when tmux's option table refuses the value. + /// + /// Returns [`crate::Error::OptionScopeMismatch`] when tmux keeps the + /// option in another of its tables, and an error when tmux rejects the + /// name or value. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(window: &libtmux::Window) -> Result<(), libtmux::Error> { + /// window.set_typed_option("synchronize-panes", true).await?; + /// window.set_typed_option("main-pane-width", "50%").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_typed_option( + &self, + name: &str, + value: impl Into, + ) -> Result<(), Error> { + let target = self.id().to_string(); + options::set_typed( + &self.core, + options::Scope::Window(&target), + name, + value.into(), + ) + .await + } + /// Append to one option rather than replacing it. /// /// # Errors @@ -142,7 +167,7 @@ impl Window { /// Set one hook to a tmux command. /// /// Hooks live in the same option tables, so a hook is an array option and - /// [`Self::get_option`] reads it under an indexed name such as + /// [`Self::typed_option`] reads it under an indexed name such as /// `after-new-window[0]`. /// /// # Errors @@ -279,16 +304,18 @@ impl Window { /// A flag comes back as [`OptionValue::Flag`] and a number as /// [`OptionValue::Number`], so a caller does not decide for itself that /// `on` means one. Everything else, including user options, stays text. + /// The one way to read a single option; [`OptionValue`] says how reads + /// and writes fit together. + /// + /// `None` means the option holds nothing at this window. A user option, + /// whose name begins with `@`, exists only while it is set, and a built-in + /// one always exists, so both report an unset option as `None`. /// /// # Errors /// /// Returns an error when tmux does not recognize the option name. pub async fn typed_option(&self, name: &str) -> Result, Error> { let target = self.id().to_string(); - Ok( - options::get(&self.core, options::Scope::Window(&target), name) - .await? - .map(|value| OptionValue::decode(name, value)), - ) + options::get_typed(&self.core, options::Scope::Window(&target), name).await } } diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index b38d2332..65b0aa77 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -159,13 +159,9 @@ async fn sourcing_a_file_applies_its_commands() { server.source_file(&config).await.expect("file is sourced"); assert_eq!( - server - .get_option("@sourced") - .await - .expect("read") - .expect("the sourced option is set") - .as_bytes(), - b"yes", + server.typed_option("@sourced").await.expect("read"), + Some(libtmux::OptionValue::from("yes")), + "the sourced option is set", ); guard.shutdown().await.expect("tmux fixture shuts down"); @@ -785,13 +781,12 @@ async fn a_global_window_option_is_read_globally() { .expect("the window takes a value of its own"); let read = server - .get_global_window_option("main-pane-width") + .typed_global_window_option("main-pane-width") .await - .expect("the option is readable") - .expect("the option is set"); + .expect("the option is readable"); assert_eq!( - read.as_str().expect("the width is text"), - "123", + read, + Some(libtmux::OptionValue::from("123")), "the global read is not answered by the window's own value" ); @@ -1306,7 +1301,7 @@ async fn a_chooser_opens_in_a_pane_and_a_popup_needs_a_client() { /// tmux has two vocabularies for it. `cmd-find.c` resolves a target and says /// "can't find pane"; `options.c` resolves its own and says "no such pane". /// Matching only the first meant `is_object_gone` answered `true` from -/// `capture` and `false` from `get_option` about the same dead pane. +/// `capture` and `false` from `typed_option` about the same dead pane. /// /// The `@` branch made it worse than inconsistent. A user option that is not /// set is unknown to tmux, so that failure is the answer `None` -- but the @@ -1342,14 +1337,8 @@ async fn a_dead_pane_says_so_however_the_question_is_asked() { .await .expect("the user option is set"); assert_eq!( - doomed - .get_option("@marker") - .await - .expect("readable") - .expect("set") - .as_str() - .expect("text"), - "here" + doomed.typed_option("@marker").await.expect("readable"), + Some(libtmux::OptionValue::from("here")), ); // `kill` consumes the handle, so the questions afterwards are asked @@ -1372,7 +1361,7 @@ async fn a_dead_pane_says_so_however_the_question_is_asked() { ); let by_option = doomed - .get_option("remain-on-exit") + .typed_option("remain-on-exit") .await .expect_err("the pane is gone"); assert!( @@ -1381,7 +1370,7 @@ async fn a_dead_pane_says_so_however_the_question_is_asked() { ); let by_user_option = doomed - .get_option("@marker") + .typed_option("@marker") .await .expect_err("the pane is gone, not the option unset"); assert!( diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index 2e67027d..a8d4e137 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -559,12 +559,8 @@ async fn a_command_holding_spaces_survives_the_text_protocol() { 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", + server.typed_option("@spaced").await.expect("read"), + Some(libtmux::OptionValue::from("a b c")), ); control.shutdown().await.expect("control mode shuts down"); diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index 232aa232..53d289b1 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -94,13 +94,9 @@ async fn creation_options_reach_tmux() { // 3.6 uses the default. Asserting the rendered size would be asserting // tmux's behaviour rather than the crate's. assert_eq!( - session - .get_option("default-size") - .await - .expect("read") - .expect("new-session -x -y sets it") - .as_bytes(), - b"120x40", + session.typed_option("default-size").await.expect("read"), + Some(libtmux::OptionValue::from("120x40")), + "new-session -x -y sets it", ); let panes = session.panes().await.expect("panes list"); diff --git a/crates/libtmux/tests/options.rs b/crates/libtmux/tests/options.rs index b2635849..da298fee 100644 --- a/crates/libtmux/tests/options.rs +++ b/crates/libtmux/tests/options.rs @@ -9,8 +9,12 @@ use libtmux::test::TestServer; use libtmux::{EnvironmentEntry, OptionValue}; use libtmux::{NewWindowOptions, TmuxText}; -fn bytes(value: Option) -> Vec { - value.expect("tmux reports a value").as_bytes().to_vec() +fn bytes(value: Option>) -> Vec { + value + .expect("tmux reports a value") + .into() + .as_bytes() + .to_vec() } #[tokio::test] @@ -35,7 +39,7 @@ async fn option_values_survive_bytes_that_tmux_would_quote_for_display() { .await .expect("option is set"); assert_eq!( - bytes(server.get_option("@probe").await.expect("option is read")), + bytes(server.typed_option("@probe").await.expect("option is read")), value.as_bytes(), "{value:?} round-trips exactly", ); @@ -53,7 +57,7 @@ async fn an_unknown_option_is_an_error_while_an_unset_one_is_absent() { // rather than an error, even though tmux itself calls it unknown. assert!( server - .get_option("@absent") + .typed_option("@absent") .await .expect("an unset user option is absent") .is_none(), @@ -61,7 +65,7 @@ async fn an_unknown_option_is_an_error_while_an_unset_one_is_absent() { // A built-in name tmux does not have is a caller mistake. let error = server - .get_option("no-such-built-in") + .typed_option("no-such-built-in") .await .expect_err("an unknown built-in name is refused"); assert!(matches!( @@ -75,7 +79,7 @@ async fn an_unknown_option_is_an_error_while_an_unset_one_is_absent() { // A known option with no value at this scope reports absence instead. assert!( server - .get_global_option("after-kill-pane[0]") + .typed_global_option("after-kill-pane[0]") .await .expect("a known hook name is accepted") .is_none(), @@ -108,31 +112,31 @@ async fn options_are_scoped_to_the_object_that_set_them() { pane.set_option("@where", "pane").await.expect("set"); assert_eq!( - bytes(server.get_option("@where").await.expect("read")), + bytes(server.typed_option("@where").await.expect("read")), b"server" ); assert_eq!( - bytes(session.get_option("@where").await.expect("read")), + bytes(session.typed_option("@where").await.expect("read")), b"session" ); assert_eq!( - bytes(window.get_option("@where").await.expect("read")), + bytes(window.typed_option("@where").await.expect("read")), b"window" ); assert_eq!( - bytes(pane.get_option("@where").await.expect("read")), + bytes(pane.typed_option("@where").await.expect("read")), b"pane" ); // Unsetting one scope leaves the others alone. window.unset_option("@where").await.expect("unset"); - assert!(window.get_option("@where").await.expect("read").is_none()); + assert!(window.typed_option("@where").await.expect("read").is_none()); assert_eq!( - bytes(session.get_option("@where").await.expect("read")), + bytes(session.typed_option("@where").await.expect("read")), b"session" ); assert_eq!( - bytes(pane.get_option("@where").await.expect("read")), + bytes(pane.typed_option("@where").await.expect("read")), b"pane" ); @@ -155,7 +159,7 @@ async fn appending_extends_a_value_rather_than_replacing_it() { .expect("append"); assert_eq!( - bytes(session.get_option("@parts").await.expect("read")), + bytes(session.typed_option("@parts").await.expect("read")), b"one-two", ); @@ -197,7 +201,7 @@ async fn a_hook_is_stored_as_an_indexed_option() { assert_eq!( bytes( server - .get_global_option("after-new-window[0]") + .typed_global_option("after-new-window[0]") .await .expect("hook is read"), ), @@ -210,7 +214,7 @@ async fn a_hook_is_stored_as_an_indexed_option() { .expect("hook is removed"); assert!( server - .get_global_option("after-new-window[0]") + .typed_global_option("after-new-window[0]") .await .expect("hook is read") .is_none(), @@ -227,30 +231,37 @@ async fn option_values_decode_into_flags_and_numbers() { // resolves it against the current one. server.new_session("typed").await.expect("session"); - // tmux's own flag options read as flags. + // The bytes behind a typed value still read as a flag, whatever variant + // carried them: `status` is a choice and arrives as text. let status = server - .get_global_option("status") + .typed_global_option("status") .await .expect("read") .expect("status is set"); - assert_eq!(status.as_flag(), Some(true)); + assert_eq!(TmuxText::from(status).as_flag(), Some(true)); // Numeric options parse without the caller checking UTF-8 first. // history-limit lives in the session table, not the server one. let limit = server - .get_global_option("history-limit") + .typed_global_option("history-limit") .await .expect("read") .expect("history-limit is set"); - assert!(limit.parse::().is_some_and(|value| value > 0)); + assert!( + TmuxText::from(limit) + .parse::() + .is_some_and(|value| value > 0) + ); // A value that is neither is reported as neither, rather than guessed at. server.set_option("@prose", "sometimes").await.expect("set"); - let prose = server - .get_option("@prose") - .await - .expect("read") - .expect("the option is set"); + let prose = TmuxText::from( + server + .typed_option("@prose") + .await + .expect("read") + .expect("the option is set"), + ); assert_eq!(prose.as_flag(), None); assert_eq!(prose.parse::(), None); @@ -998,7 +1009,12 @@ async fn a_name_shaped_like_a_flag_is_refused_not_obeyed() { .expect_err("a name that is a flag is refused"); assert_eq!( - bytes(session.get_option("@kept").await.expect("the option reads")), + bytes( + session + .typed_option("@kept") + .await + .expect("the option reads") + ), b"original".to_vec(), "the option a flag name pointed at survived", ); @@ -1302,3 +1318,216 @@ async fn a_late_pane_scope_is_granted_to_whichever_tmux_actually_has_it() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// Each kind is written as the variant `typed_option` reads back, and reads +/// back equal, on every handle that has a typed write. +#[tokio::test] +async fn a_typed_write_reads_back_as_the_value_written() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server.new_session("typed").await.expect("a session"); + let window = session + .active_window() + .await + .expect("active window") + .expect("a session has a window"); + + let cases = [ + ("mouse", OptionValue::Flag(true)), + ("history-limit", OptionValue::Number(4321)), + ("status-keys", OptionValue::from("vi")), + ("status-left", OptionValue::from("typed #[fg=red]")), + ]; + for (name, value) in cases { + session + .set_typed_option(name, value.clone()) + .await + .unwrap_or_else(|error| panic!("{name} takes {value:?}: {error}")); + assert_eq!( + session.typed_option(name).await.expect("read"), + Some(value), + "{name} reads back as written", + ); + } + + window + .set_typed_option("synchronize-panes", true) + .await + .expect("a window flag"); + assert_eq!( + window + .typed_option("synchronize-panes") + .await + .expect("read"), + Some(OptionValue::Flag(true)), + ); + + server + .set_typed_option("escape-time", 15) + .await + .expect("a server number"); + assert_eq!( + server.typed_option("escape-time").await.expect("read"), + Some(OptionValue::Number(15)), + ); + server + .set_typed_global_window_option("mode-keys", "vi") + .await + .expect("a global window choice"); + assert_eq!( + server + .typed_global_window_option("mode-keys") + .await + .expect("read"), + Some(OptionValue::from("vi")), + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A value the table refuses fails before dispatch and leaves the option as +/// it was. +/// +/// The first case is the one that proves nothing was sent: tmux itself takes +/// `1` for a flag, so had the write reached it `mouse` would now be on. The +/// other two are values tmux would also refuse, so what shows they were +/// stopped here is the error: tmux's refusal is `OptionRejected`. +#[tokio::test] +async fn a_value_the_table_refuses_never_reaches_tmux() { + use libtmux::{Error, ErrorKind, OptionKind, OptionValueRefusal}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("refused") + .await + .expect("a session"); + + session + .set_typed_option("mouse", false) + .await + .expect("mouse starts off"); + session + .set_typed_option("status-keys", "emacs") + .await + .expect("status-keys starts at emacs"); + session + .set_typed_option("history-limit", 1234) + .await + .expect("history-limit starts at 1234"); + + let cases = [ + ( + "mouse", + OptionValue::Number(1), + OptionValueRefusal::WrongKind { + expected: OptionKind::Flag, + }, + OptionValue::Flag(false), + ), + ( + "status-keys", + OptionValue::from("dvorak"), + OptionValueRefusal::NotAChoice { + choices: &["emacs", "vi"], + }, + OptionValue::from("emacs"), + ), + ( + "history-limit", + OptionValue::Number(-1), + OptionValueRefusal::OutOfRange { + range: 0..=i64::from(i32::MAX), + }, + OptionValue::Number(1234), + ), + ]; + for (name, value, expected, unchanged) in cases { + let result = session.set_typed_option(name, value).await; + assert_eq!( + session.typed_option(name).await.expect("read"), + Some(unchanged), + "{name} is as it was", + ); + + let error = result.expect_err("the table refuses the value"); + assert!( + matches!( + &error, + Error::OptionValueRefused { option, reason } + if *option == name && *reason == expected + ), + "{name}: refused before dispatch, for the declared reason: {error:?}", + ); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert!(!error.is_transient(), "the same value is refused again"); + let reported = format!("{error} {error:?}"); + assert!( + !reported.contains("dvorak"), + "the value stays out of the error: {reported}", + ); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A refusal is decided without tmux: after the server is gone, a valid write +/// fails for want of it and an invalid one is still refused by the table. +#[tokio::test] +async fn a_typed_refusal_needs_no_server() { + use libtmux::Error; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server().clone(); + guard.shutdown().await.expect("tmux fixture shuts down"); + + let valid = server + .set_typed_global_option("history-limit", 10) + .await + .expect_err("nothing is there to take the write"); + assert!( + !matches!(valid, Error::OptionValueRefused { .. }), + "a valid value reaches dispatch, and dispatch fails: {valid:?}", + ); + + let invalid = server + .set_typed_global_option("history-limit", -1) + .await + .expect_err("the table refuses the value"); + assert!( + matches!(invalid, Error::OptionValueRefused { .. }), + "refused before any dispatch was attempted: {invalid:?}", + ); +} + +/// A user option has no declared kind: a typed write stores any variant as +/// the text a read shows for it, and reads back as text. +#[tokio::test] +async fn a_typed_user_option_is_stored_as_text() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard.server().new_session("user").await.expect("a session"); + + let cases = [ + ("@flag", OptionValue::Flag(true), "on"), + ("@number", OptionValue::Number(-3), "-3"), + ("@text", OptionValue::from("dvorak"), "dvorak"), + ]; + for (name, value, stored) in cases { + session + .set_typed_option(name, value) + .await + .unwrap_or_else(|error| panic!("{name} takes any variant: {error}")); + let read = session.typed_option(name).await.expect("read"); + assert!( + matches!(read, Some(OptionValue::Text(_))), + "{name} reads back as text: {read:?}", + ); + assert_eq!( + String::from_utf8_lossy(&bytes(read)), + stored, + "{name} is stored as the text a read shows", + ); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 672a964c..b3757932 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -738,12 +738,12 @@ impl TmuxTools { if let Some(name) = session.as_deref() { self.find_session(name) .await? - .set_option("mouse", value) + .set_typed_option("mouse", enabled) .await .map_err(|error| tmux_error(&error))?; } else { self.server - .set_global_option("mouse", value) + .set_typed_global_option("mouse", enabled) .await .map_err(|error| tmux_error(&error))?; } @@ -768,12 +768,12 @@ impl TmuxTools { if let Some(name) = session.as_deref() { self.find_session(name) .await? - .set_option("history-limit", limit.to_string()) + .set_typed_option("history-limit", limit) .await .map_err(|error| tmux_error(&error))?; } else { self.server - .set_global_option("history-limit", limit.to_string()) + .set_typed_global_option("history-limit", limit) .await .map_err(|error| tmux_error(&error))?; } @@ -924,7 +924,7 @@ impl TmuxTools { let value = if enabled { "on" } else { "off" }; self.find_window(&window) .await? - .set_option("synchronize-panes", value) + .set_typed_option("synchronize-panes", enabled) .await .map_err(|error| tmux_error(&error))?; Ok(Json(SettingChanged { diff --git a/crates/tmux-mcp/src/tools/inspect.rs b/crates/tmux-mcp/src/tools/inspect.rs index 6de5b414..b822f13b 100644 --- a/crates/tmux-mcp/src/tools/inspect.rs +++ b/crates/tmux-mcp/src/tools/inspect.rs @@ -1,6 +1,6 @@ use std::time::{Duration, Instant}; -use libtmux::{CaptureOptions, Command}; +use libtmux::{CaptureOptions, Command, TmuxText}; use rmcp::handler::server::wrapper::{Json, Parameters}; use rmcp::{tool, tool_router}; @@ -488,12 +488,12 @@ impl TmuxTools { .option_scope(scope.as_deref(), target.as_deref()) .await?; let value = match scope { - OptionScope::Server => self.server.get_option(&name).await, - OptionScope::GlobalSession => self.server.get_global_option(&name).await, - OptionScope::GlobalWindow => self.server.get_global_window_option(&name).await, - OptionScope::Session(session) => session.get_option(&name).await, - OptionScope::Window(window) => window.get_option(&name).await, - OptionScope::Pane(pane) => pane.get_option(&name).await, + OptionScope::Server => self.server.typed_option(&name).await, + OptionScope::GlobalSession => self.server.typed_global_option(&name).await, + OptionScope::GlobalWindow => self.server.typed_global_window_option(&name).await, + OptionScope::Session(session) => session.typed_option(&name).await, + OptionScope::Window(window) => window.typed_option(&name).await, + OptionScope::Pane(pane) => pane.typed_option(&name).await, } .map_err(|e| tmux_error(&e))?; @@ -501,7 +501,7 @@ impl TmuxTools { name, // Absent and empty are different answers: tmux reports no value // for an option that has never been set at that scope. - value: value.as_ref().map(lossy), + value: value.map(TmuxText::from).as_ref().map(lossy), })) } diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index 89941e1e..7dcd6f7b 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -2102,7 +2102,7 @@ async fn run_refuses_initial_input_before_watcher_setup() { assert_eq!( guard .server() - .get_global_option("@mcp-initial-watcher") + .typed_global_option("@mcp-initial-watcher") .await .expect("hook record is read"), None, @@ -2296,7 +2296,7 @@ async fn assert_terminal_control_route_is_preflight_failure( assert_eq!(client_count(&bootstrap).await, baseline_clients); assert_eq!( bootstrap - .get_global_option("@mcp-route-watcher") + .typed_global_option("@mcp-route-watcher") .await .expect("watcher record is read"), None, @@ -2304,7 +2304,7 @@ async fn assert_terminal_control_route_is_preflight_failure( ); assert_eq!( bootstrap - .get_global_option("@mcp-route-display") + .typed_global_option("@mcp-route-display") .await .expect("display record is read"), None, diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index edebc763..5b816f0c 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -497,13 +497,8 @@ windows: Some(libtmux::EnvironmentEntry::Set(value)) if value.as_bytes() == b"applied", )); assert_eq!( - session - .get_option("base-index") - .await - .expect("read") - .expect("the option is set") - .as_bytes(), - b"3", + session.typed_option("base-index").await.expect("read"), + Some(libtmux::OptionValue::Number(3)), ); let window = session @@ -514,13 +509,8 @@ windows: .next() .expect("one window"); assert_eq!( - window - .get_option("main-pane-width") - .await - .expect("read") - .expect("the option is set") - .as_bytes(), - b"42", + window.typed_option("main-pane-width").await.expect("read"), + Some(libtmux::OptionValue::from("42")), ); guard.shutdown().await.expect("tmux fixture shuts down"); From 14afc7966d8acdb7b24585c9b4efa09f58dabb10 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 22:38:54 -0500 Subject: [PATCH 065/117] Control(fix[spawn]): Call a missing tmux missing on the attach path too With no tmux binary, every call reported `Error::ExecutableNotFound` (`ErrorKind::Unreachable`) except `ControlMode::attach`, which reported `ControlMode { Transport, NotFound }`. `attach` tolerates a failed version probe and spawns anyway, so it can be the first call to meet the missing binary -- and `Transport` reads as a failure worth retrying. The control spawn now applies the subprocess path's own test (`NotFound` with a usable working directory, or the bare name absent from the captured `PATH`). Any other spawn failure is still a control-mode error. Found building the record/replay double, whose replay forces exactly this path. The test failed on the old code with the variant above. --- crates/libtmux/src/internal/process.rs | 19 ++++++++++++++++- crates/libtmux/tests/control.rs | 29 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/libtmux/src/internal/process.rs b/crates/libtmux/src/internal/process.rs index f94370e6..cdca7047 100644 --- a/crates/libtmux/src/internal/process.rs +++ b/crates/libtmux/src/internal/process.rs @@ -448,7 +448,24 @@ impl PersistentChild { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); - let child = command.spawn().map_err(Error::control_mode)?; + let child = command.spawn().map_err(|source| { + // The same test the subprocess path applies, so a missing tmux is + // `ExecutableNotFound` whichever path meets it first. Anything else + // stays a control-mode failure. + let executable_not_found = (source.kind() == io::ErrorKind::NotFound + && launch.current_dir().is_none_or(Path::is_dir)) + || launch.executable_missing_from_path(); + if executable_not_found { + Error::spawn( + request.request_id().get(), + request.summary().clone(), + source, + true, + ) + } else { + Error::control_mode(source) + } + })?; let process_group = ProcessGroupGuard::new(child.id()); // `child.id()` is `None` only once the child has already been // reaped, which cannot be true immediately after `spawn` returns. diff --git a/crates/libtmux/tests/control.rs b/crates/libtmux/tests/control.rs index a8d4e137..ebb67768 100644 --- a/crates/libtmux/tests/control.rs +++ b/crates/libtmux/tests/control.rs @@ -2021,3 +2021,32 @@ async fn a_clients_listing_tells_our_own_connection_apart() { mode.shutdown().await.expect("the connection closes"); guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// A missing tmux is the same failure whichever path finds it. `attach` +/// tolerates a failed version probe and spawns anyway, so it was the path +/// that found it first -- and classified it as a control-mode transport +/// failure, which reads as worth retrying, where every other call says the +/// executable is not there. +#[tokio::test] +async fn attach_reports_a_missing_tmux_as_the_process_path_does() { + let server = libtmux::Server::builder() + .socket_path("/tmp/libtmux-rs-dev/never-bound.sock") + .tmux_executable("/nonexistent/libtmux-rs/tmux") + .build() + .expect("the configuration is valid"); + let session: libtmux::SessionId = "$0".parse().expect("a session id"); + + let Err(attach) = ControlMode::attach(&server, &session).await else { + panic!("attach succeeded with no tmux"); + }; + let listing = server + .sessions() + .await + .expect_err("a listing fails with no tmux"); + + assert!( + matches!(attach, libtmux::Error::ExecutableNotFound { .. }), + "attach: {attach:?}", + ); + assert_eq!(attach.kind(), listing.kind()); +} From 6a456905949d75f15379a14c1222de7743171dbe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 22:53:41 -0500 Subject: [PATCH 066/117] Workspace(fix[errors]): Name the line and column to fix why: A syntax error printed only "workspace configuration is not valid YAML"; the position lived in its source, which most reporters never print. A semantic error named a key path and no position, because `YamlLoader` keeps none. A file is fixed in an editor, so an error that does not name a line leaves the reader to search for it. what: - `ConfigError::Yaml` carries `line`, `column` and `reason` from the scanner's marker, and prints them - `ConfigError::Invalid` carries `path`, `line` and `column`; the position comes from a second, event-level pass over the source that runs only when there is an error to place - Place a node the parser implied (`-` or `key:` with nothing after) at its key or its `-`, not at the token the parser peeked past, and a block mapping at its first key rather than that key's `:` - Every helper names the key path it reports, so `environment.A` or `shell_command[1]` is located rather than the whole document --- crates/tmux-workspace/src/config.rs | 164 ++++++++++------ crates/tmux-workspace/src/config/locate.rs | 214 +++++++++++++++++++++ crates/tmux-workspace/tests/build.rs | 56 +++++- 3 files changed, 378 insertions(+), 56 deletions(-) create mode 100644 crates/tmux-workspace/src/config/locate.rs diff --git a/crates/tmux-workspace/src/config.rs b/crates/tmux-workspace/src/config.rs index 7f1d3bdc..da929b2a 100644 --- a/crates/tmux-workspace/src/config.rs +++ b/crates/tmux-workspace/src/config.rs @@ -1,5 +1,7 @@ //! Parsing tmuxp-style workspace YAML. +mod locate; + use std::fmt::Write as _; use std::path::PathBuf; @@ -10,8 +12,15 @@ use yaml_rust2::{Yaml, YamlLoader}; #[non_exhaustive] pub enum ConfigError { /// The document was not valid YAML. - #[error("workspace configuration is not valid YAML")] - Yaml(#[from] yaml_rust2::ScanError), + #[error("workspace configuration is not valid YAML at line {line}, column {column}: {reason}")] + Yaml { + /// The line the parser stopped on, counting from 1. + line: usize, + /// The column the parser stopped on, counting from 1. + column: usize, + /// What the parser expected and did not find. + reason: String, + }, /// The document was empty, or held more than one workspace. #[error("expected exactly one workspace document, found {found}")] @@ -21,19 +30,57 @@ pub enum ConfigError { }, /// A required key was absent or the wrong shape. - #[error("workspace configuration is invalid: {reason}")] + #[error("workspace configuration is invalid at line {line}, column {column}: {path} {reason}")] Invalid { + /// Where, as a key path such as `windows[0].panes[1]`. + path: String, + /// The line of the offending value, counting from 1. A missing key + /// is placed at the mapping that should have held it. + line: usize, + /// The column of the offending value, counting from 1. + column: usize, /// What was wrong, in terms of the configuration's own vocabulary. reason: String, }, } -impl ConfigError { - fn invalid(reason: impl Into) -> Self { - Self::Invalid { +impl From for ConfigError { + fn from(error: yaml_rust2::ScanError) -> Self { + let mark = error.marker(); + Self::Yaml { + line: mark.line(), + // `Marker::col` counts from zero; `ScanError`'s own `Display` adds one. + column: mark.col() + 1, + reason: error.info().to_owned(), + } + } +} + +/// A key path and what is wrong there, before the source is scanned for +/// where that path sits. +#[derive(Debug)] +struct Problem { + path: String, + reason: String, +} + +impl Problem { + fn new(path: impl Into, reason: impl Into) -> Self { + Self { + path: path.into(), reason: reason.into(), } } + + fn locate(self, source: &str) -> ConfigError { + let (line, column) = locate::locate(source, &self.path); + ConfigError::Invalid { + path: self.path, + line, + column, + reason: self.reason, + } + } } /// One workspace: a session and the windows it should contain. @@ -127,7 +174,8 @@ impl Workspace { /// # Errors /// /// Returns an error when the document is not valid YAML, does not hold - /// exactly one workspace, or is missing `session_name`. + /// exactly one workspace, or is missing `session_name`. Every error + /// except the document count names the line and column to look at. /// /// # Examples /// @@ -157,10 +205,13 @@ impl Workspace { found: documents.len(), }); }; + Self::from_document(document).map_err(|problem| problem.locate(source)) + } + fn from_document(document: &Yaml) -> Result { let session_name = document["session_name"] .as_str() - .ok_or_else(|| ConfigError::invalid("session_name must be a string"))? + .ok_or_else(|| Problem::new("session_name", "must be a string"))? .to_owned(); let windows = match &document["windows"] { @@ -170,16 +221,19 @@ impl Workspace { .enumerate() .map(|(index, window)| WindowConfig::from_yaml(window, index)) .collect::, _>>()?, - _ => return Err(ConfigError::invalid("windows must be a list")), + _ => return Err(Problem::new("windows", "must be a list")), }; Ok(Self { session_name, start_directory: optional_path(&document["start_directory"], "start_directory")?, - environment: pairs(&document["environment"])?, - options: pairs(&document["options"])?, - global_options: pairs(&document["global_options"])?, - shell_command_before: commands(&document["shell_command_before"])?, + environment: pairs(&document["environment"], "environment")?, + options: pairs(&document["options"], "options")?, + global_options: pairs(&document["global_options"], "global_options")?, + shell_command_before: commands( + &document["shell_command_before"], + "shell_command_before", + )?, suppress_history: is_true(&document["suppress_history"], "suppress_history")?, windows, unsupported_keys: unsupported(document, SESSION_KEYS), @@ -188,10 +242,10 @@ impl Workspace { } impl WindowConfig { - fn from_yaml(value: &Yaml, index: usize) -> Result { + fn from_yaml(value: &Yaml, index: usize) -> Result { let at = format!("windows[{index}]"); if !matches!(value, Yaml::Hash(_)) { - return Err(ConfigError::invalid(format!("{at} must be a mapping"))); + return Err(Problem::new(at, "must be a mapping")); } let panes = match &value["panes"] { // A window with no panes still has the one tmux creates with it. @@ -201,22 +255,25 @@ impl WindowConfig { .enumerate() .map(|(pane, entry)| PaneConfig::from_yaml(entry, &at, pane)) .collect::, _>>()?, - _ => return Err(ConfigError::invalid(format!("{at}.panes must be a list"))), + _ => return Err(Problem::new(format!("{at}.panes"), "must be a list")), }; Ok(Self { window_name: value["window_name"].as_str().map(ToOwned::to_owned), - window_index: optional_index(&value["window_index"])?, + window_index: optional_index(&value["window_index"], &format!("{at}.window_index"))?, window_shell: value["window_shell"].as_str().map(ToOwned::to_owned), - environment: pairs(&value["environment"])?, + environment: pairs(&value["environment"], &format!("{at}.environment"))?, layout: optional_text(&value["layout"], &format!("{at}.layout"))?, start_directory: optional_path( &value["start_directory"], &format!("{at}.start_directory"), )?, focus: is_true(&value["focus"], &format!("{at}.focus"))?, - options: pairs(&value["options"])?, - shell_command_before: commands(&value["shell_command_before"])?, + options: pairs(&value["options"], &format!("{at}.options"))?, + shell_command_before: commands( + &value["shell_command_before"], + &format!("{at}.shell_command_before"), + )?, suppress_history: optional_bool( &value["suppress_history"], &format!("{at}.suppress_history"), @@ -232,7 +289,7 @@ impl WindowConfig { } impl PaneConfig { - fn from_yaml(value: &Yaml, window: &str, index: usize) -> Result { + fn from_yaml(value: &Yaml, window: &str, index: usize) -> Result { let at = format!("{window}.panes[{index}]"); // tmuxp lets a pane be a bare command string. if let Some(command) = value.as_str() { @@ -243,14 +300,12 @@ impl PaneConfig { } if !matches!(value, Yaml::Hash(_)) { - return Err(ConfigError::invalid(format!( - "{at} must be a command string or a mapping" - ))); + return Err(Problem::new(at, "must be a command string or a mapping")); } Ok(Self { - shell_commands: commands(&value["shell_command"])?, - environment: pairs(&value["environment"])?, + shell_commands: commands(&value["shell_command"], &format!("{at}.shell_command"))?, + environment: pairs(&value["environment"], &format!("{at}.environment"))?, start_directory: optional_path( &value["start_directory"], &format!("{at}.start_directory"), @@ -319,7 +374,7 @@ fn unsupported(document: &Yaml, known: &[&str]) -> Vec { } /// Read a mapping of names to values, as `environment` and `options` use. -fn pairs(value: &Yaml) -> Result, ConfigError> { +fn pairs(value: &Yaml, path: &str) -> Result, Problem> { match value { Yaml::BadValue | Yaml::Null => Ok(Vec::new()), Yaml::Hash(entries) => entries @@ -327,7 +382,7 @@ fn pairs(value: &Yaml) -> Result, ConfigError> { .map(|(key, value)| { let key = key .as_str() - .ok_or_else(|| ConfigError::invalid("names must be strings"))?; + .ok_or_else(|| Problem::new(path, "names must be strings"))?; // tmuxp writes option values as strings, numbers, or bools. let value = value.as_str().map(ToOwned::to_owned).or_else(|| { value.as_i64().map(|number| number.to_string()).or_else(|| { @@ -341,49 +396,49 @@ fn pairs(value: &Yaml) -> Result, ConfigError> { }) }); - value - .map(|value| (key.to_owned(), value)) - .ok_or_else(|| ConfigError::invalid("values must be scalars")) + value.map(|value| (key.to_owned(), value)).ok_or_else(|| { + Problem::new( + format!("{path}.{key}"), + "must be a string, a number, or a boolean", + ) + }) }) .collect(), - _ => Err(ConfigError::invalid( - "expected a mapping of names to values", - )), + _ => Err(Problem::new(path, "must be a mapping of names to values")), } } /// Read a value tmuxp allows as a string or a list of strings. -fn commands(value: &Yaml) -> Result, ConfigError> { +fn commands(value: &Yaml, path: &str) -> Result, Problem> { match value { Yaml::BadValue | Yaml::Null => Ok(Vec::new()), Yaml::String(command) => Ok(vec![command.clone()]), Yaml::Array(entries) => entries .iter() - .map(|entry| { + .enumerate() + .map(|(index, entry)| { entry .as_str() .map(ToOwned::to_owned) - .ok_or_else(|| ConfigError::invalid("shell_command entries must be strings")) + .ok_or_else(|| Problem::new(format!("{path}[{index}]"), "must be a string")) }) .collect(), - _ => Err(ConfigError::invalid( - "shell_command must be a string or a list of strings", - )), + _ => Err(Problem::new(path, "must be a string or a list of strings")), } } /// Read a window index, which tmuxp writes as an integer or a string. -fn optional_index(value: &Yaml) -> Result, ConfigError> { +fn optional_index(value: &Yaml, path: &str) -> Result, Problem> { match value { Yaml::BadValue | Yaml::Null => Ok(None), Yaml::Integer(index) => i32::try_from(*index) .map(Some) - .map_err(|_| ConfigError::invalid("window_index is out of range")), + .map_err(|_| Problem::new(path, "is out of range")), Yaml::String(index) => index .parse() .map(Some) - .map_err(|_| ConfigError::invalid("window_index must be a number")), - _ => Err(ConfigError::invalid("window_index must be a number")), + .map_err(|_| Problem::new(path, "must be a number")), + _ => Err(Problem::new(path, "must be a number")), } } @@ -392,20 +447,20 @@ fn optional_index(value: &Yaml) -> Result, ConfigError> { /// Absence defaults; a wrong shape does not. `start_directory: 123` used to /// read as "no start directory", which builds a workspace that is valid and /// not the one the file describes. -fn optional_path(value: &Yaml, path: &str) -> Result, ConfigError> { +fn optional_path(value: &Yaml, path: &str) -> Result, Problem> { match value { Yaml::BadValue | Yaml::Null => Ok(None), Yaml::String(text) => Ok(Some(PathBuf::from(text))), - _ => Err(ConfigError::invalid(format!("{path} must be a string"))), + _ => Err(Problem::new(path, "must be a string")), } } /// Read an optional string, refusing a value that is present and not one. -fn optional_text(value: &Yaml, path: &str) -> Result, ConfigError> { +fn optional_text(value: &Yaml, path: &str) -> Result, Problem> { match value { Yaml::BadValue | Yaml::Null => Ok(None), Yaml::String(text) => Ok(Some(text.clone())), - _ => Err(ConfigError::invalid(format!("{path} must be a string"))), + _ => Err(Problem::new(path, "must be a string")), } } @@ -413,23 +468,24 @@ fn optional_text(value: &Yaml, path: &str) -> Result, ConfigError /// /// Both spellings are accepted; a third thing is refused. `focus: "tru"` used /// to read as `false`, which is a different workspace rather than an error. -fn optional_bool(value: &Yaml, path: &str) -> Result, ConfigError> { +fn optional_bool(value: &Yaml, path: &str) -> Result, Problem> { match value { Yaml::BadValue | Yaml::Null => Ok(None), Yaml::Boolean(flag) => Ok(Some(*flag)), Yaml::String(text) => match text.as_str() { "true" | "yes" | "on" => Ok(Some(true)), "false" | "no" | "off" => Ok(Some(false)), - _ => Err(ConfigError::invalid(format!( - "{path} must be a boolean, found {text:?}" - ))), + _ => Err(Problem::new( + path, + format!("must be a boolean, found {text:?}"), + )), }, - _ => Err(ConfigError::invalid(format!("{path} must be a boolean"))), + _ => Err(Problem::new(path, "must be a boolean")), } } /// Read a boolean that defaults to false when absent, and fails when wrong. -fn is_true(value: &Yaml, path: &str) -> Result { +fn is_true(value: &Yaml, path: &str) -> Result { Ok(optional_bool(value, path)?.unwrap_or(false)) } diff --git a/crates/tmux-workspace/src/config/locate.rs b/crates/tmux-workspace/src/config/locate.rs new file mode 100644 index 00000000..915b6d03 --- /dev/null +++ b/crates/tmux-workspace/src/config/locate.rs @@ -0,0 +1,214 @@ +//! Finding where in a document a key path sits. +//! +//! `YamlLoader` keeps no positions, so a problem found in the loaded tree is +//! placed by scanning the source a second time, and only when there is one. + +use std::collections::HashMap; + +use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser}; +use yaml_rust2::scanner::{Marker, TScalarStyle}; + +/// The 1-based line and column of the node at `path`, or of the nearest +/// ancestor the document has: a missing key is reported where its mapping is. +pub(super) fn locate(source: &str, path: &str) -> (usize, usize) { + let mut recorder = Recorder { + lines: source.lines().collect(), + frames: Vec::new(), + positions: HashMap::new(), + }; + // The same source already loaded without error, so this pass cannot fail. + let _ = Parser::new_from_str(source).load(&mut recorder, true); + + let mut path = path; + loop { + if let Some(&position) = recorder.positions.get(path) { + return position; + } + if path.is_empty() { + return (1, 1); + } + path = &path[..path.rfind(['.', '[']).unwrap_or(0)]; + } +} + +/// `Marker::col` counts from zero; its own `Display` adds one too. +fn position(mark: Marker) -> (usize, usize) { + (mark.line(), mark.col() + 1) +} + +/// Records the position of every value node under the key path the parsing +/// code names it by: `windows[0].panes[1].shell_command`. +#[derive(Debug)] +struct Recorder<'source> { + lines: Vec<&'source str>, + frames: Vec, + positions: HashMap, +} + +#[derive(Debug)] +enum Frame { + Mapping { + path: String, + key: Option, + key_mark: Option, + awaiting_key: bool, + }, + Sequence { + path: String, + mark: Marker, + next: usize, + /// The line the previous entry began on. + previous: Option, + }, +} + +/// Stands in for the path of a container used as a mapping key, so nothing +/// inside one can shadow a real key. +const KEY_NODE: &str = "\0key"; + +impl Recorder<'_> { + /// The path of a node starting now, or `None` when it is a mapping key. + fn child_path(&self) -> Option { + match self.frames.last() { + None => Some(String::new()), + Some(Frame::Mapping { + awaiting_key: true, .. + }) => None, + Some(Frame::Mapping { path, key, .. }) => { + let key = key.as_deref().unwrap_or(KEY_NODE); + Some(if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + }) + } + Some(Frame::Sequence { path, next, .. }) => Some(format!("{path}[{next}]")), + } + } + + /// Where a node the parser implied (`-` or `key:` with nothing after) is. + /// + /// The parser marks such a node at a token it peeked past, often on a + /// later line, so it is placed at its key or at its entry's `-` instead. + fn implied(&self, mark: Marker) -> (usize, usize) { + match self.frames.last() { + Some(Frame::Mapping { + key_mark: Some(key), + .. + }) => position(*key), + Some(Frame::Sequence { + mark: first, + previous: None, + .. + }) => position(*first), + Some(Frame::Sequence { + previous: Some(previous), + .. + }) => self + .lines + .iter() + .enumerate() + .skip(*previous) + .find_map(|(index, line)| { + let indent = line.len() - line.trim_start().len(); + line.trim_start() + .starts_with('-') + .then_some((index + 1, indent + 1)) + }) + .unwrap_or_else(|| position(mark)), + _ => position(mark), + } + } + + fn record(&mut self, mark: Marker, implied: bool) -> Option { + let path = self.child_path(); + let at = if implied && path.is_some() { + self.implied(mark) + } else { + position(mark) + }; + if let Some(path) = &path { + self.positions.entry(path.clone()).or_insert(at); + } + match self.frames.last_mut() { + Some(Frame::Mapping { + path: mapping, + key_mark, + awaiting_key: true, + .. + }) => { + // A block mapping is marked at its first key's `:`, so it + // takes its first key's position instead. + if key_mark.is_none() { + if let Some(at) = self.positions.get_mut(mapping.as_str()) { + *at = position(mark); + } + } + *key_mark = Some(mark); + } + Some(Frame::Sequence { previous, .. }) => *previous = Some(at.0), + _ => {} + } + path + } + + /// Step the enclosing container past a node that has ended. + fn finish(&mut self, scalar: Option) { + match self.frames.last_mut() { + Some(Frame::Mapping { + key, awaiting_key, .. + }) => { + if *awaiting_key { + *key = scalar; + } + *awaiting_key = !*awaiting_key; + } + Some(Frame::Sequence { next, .. }) => *next += 1, + None => {} + } + } +} + +impl MarkedEventReceiver for Recorder<'_> { + fn on_event(&mut self, event: Event, mark: Marker) { + match event { + Event::Scalar(text, style, ..) => { + // A plain scalar cannot be empty in the source, so an empty + // one is a node the parser implied. + self.record(mark, text.is_empty() && style == TScalarStyle::Plain); + self.finish(Some(text)); + } + Event::Alias(_) => { + self.record(mark, false); + self.finish(None); + } + Event::MappingStart(..) => { + let path = self + .record(mark, false) + .unwrap_or_else(|| KEY_NODE.to_owned()); + self.frames.push(Frame::Mapping { + path, + key: None, + key_mark: None, + awaiting_key: true, + }); + } + Event::SequenceStart(..) => { + let path = self + .record(mark, false) + .unwrap_or_else(|| KEY_NODE.to_owned()); + self.frames.push(Frame::Sequence { + path, + mark, + next: 0, + previous: None, + }); + } + Event::MappingEnd | Event::SequenceEnd => { + self.frames.pop(); + self.finish(None); + } + _ => {} + } + } +} diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index 5b816f0c..19ed175e 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -7,7 +7,7 @@ use libtmux::TmuxText; use libtmux::plan::Planner; use libtmux::test::TestServer; -use tmux_workspace::{BuildError, Workspace, WorkspaceBuilder}; +use tmux_workspace::{BuildError, ConfigError, Workspace, WorkspaceBuilder}; fn text(value: &TmuxText) -> String { String::from_utf8(value.as_bytes().to_vec()).expect("fixture values are UTF-8") @@ -63,7 +63,7 @@ windows: #[test] fn a_missing_session_name_is_rejected() { let error = Workspace::from_yaml("windows: []").expect_err("session_name is required"); - assert!(matches!(error, tmux_workspace::ConfigError::Invalid { .. },)); + assert!(matches!(error, ConfigError::Invalid { .. },)); } #[tokio::test] @@ -786,3 +786,55 @@ windows: guard.shutdown().await.expect("tmux fixture shuts down"); drop(session); } + +/// A file is fixed in an editor, so an error names the line to go to. +#[test] +fn an_error_names_the_line_and_column_to_fix() { + let syntax = Workspace::from_yaml( + "session_name: s\nwindows:\n - window_name: a\n panes:\n - vim\n - htop\n", + ) + .expect_err("a misindented entry is not YAML"); + assert!( + matches!( + syntax, + ConfigError::Yaml { + line: 6, + column: 6, + .. + } + ), + "{syntax:?}", + ); + assert_eq!( + syntax.to_string(), + "workspace configuration is not valid YAML at line 6, column 6: \ + while parsing a block mapping, did not find expected key", + ); + + for (source, expected) in [ + ( + "session_name: s\nwindows:\n - window_name: a\n focus: tru\n", + "at line 4, column 12: windows[0].focus must be a boolean, found \"tru\"", + ), + ( + "session_name: s\nwindows:\n - {panes: [a, 5]}\n", + "at line 3, column 17: windows[0].panes[1] must be", + ), + // A missing key is reported at the mapping that should hold it. + ("windows: []\n", "at line 1, column 1: session_name must be"), + // The parser marks an entry with nothing after its `-` at whatever + // token follows, two lines down here, rather than at the `-`. + ( + "session_name: s\nwindows:\n - window_name: a\n -\n -\n - window_name: b\n", + "at line 4, column 3: windows[1] must be a mapping", + ), + ] { + let message = Workspace::from_yaml(source) + .expect_err("the value is refused") + .to_string(); + assert!( + message.contains(expected), + "expected {expected:?} in {message:?}" + ); + } +} From 38831b93e0ee9a3af423836ef8a1bf76ffa16ff6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 23:05:56 -0500 Subject: [PATCH 067/117] Workspace(fix[history]): Suppress history unless a file says not to why: tmuxp types every command with a leading space unless the pane, then the window, then the session sets `suppress_history: false`. Here an absent key read as `false`, so a tmuxp file that never named it put every command it ran into the user's shell history. what: - Read an absent session-level `suppress_history` as `true`; window and pane still inherit it - `to_yaml` writes the key only when it is `false`, and `freeze` produces the default - Vendor tmuxp's MIT-licensed examples under tests/fixtures/tmuxp with its licence beside them, starting with suppress-history.yaml; ship them in the package and require them in the package check - Prove it on a real pane: `default-command` set to `cat` shows each pane's typed text exactly, leading space included --- crates/tmux-workspace/Cargo.toml | 1 + crates/tmux-workspace/src/config.rs | 10 ++- crates/tmux-workspace/src/freeze.rs | 2 +- crates/tmux-workspace/tests/build.rs | 74 +++++++++++++++++++ .../tests/fixtures/tmuxp/LICENSE | 21 ++++++ .../fixtures/tmuxp/suppress-history.yaml | 29 ++++++++ scripts/check-package-contents.sh | 1 + 7 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/LICENSE create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/suppress-history.yaml diff --git a/crates/tmux-workspace/Cargo.toml b/crates/tmux-workspace/Cargo.toml index a1ff6c7f..cd5adb5c 100644 --- a/crates/tmux-workspace/Cargo.toml +++ b/crates/tmux-workspace/Cargo.toml @@ -19,6 +19,7 @@ include = [ "/libtmux-macros-README.md", "/src/**/*.rs", "/tests/**/*.rs", + "/tests/fixtures/tmuxp/*", ] [dependencies] diff --git a/crates/tmux-workspace/src/config.rs b/crates/tmux-workspace/src/config.rs index da929b2a..de7e4feb 100644 --- a/crates/tmux-workspace/src/config.rs +++ b/crates/tmux-workspace/src/config.rs @@ -99,6 +99,8 @@ pub struct Workspace { /// Commands run in every pane before its own, in order. pub shell_command_before: Vec, /// Whether to keep pane commands out of the shell's history. + /// + /// A file that does not say reads as `true`, as in tmuxp. pub suppress_history: bool, /// The windows to create, in order. pub windows: Vec, @@ -234,7 +236,9 @@ impl Workspace { &document["shell_command_before"], "shell_command_before", )?, - suppress_history: is_true(&document["suppress_history"], "suppress_history")?, + // tmuxp suppresses unless a file says otherwise. + suppress_history: optional_bool(&document["suppress_history"], "suppress_history")? + .unwrap_or(true), windows, unsupported_keys: unsupported(document, SESSION_KEYS), }) @@ -521,8 +525,8 @@ impl Workspace { if let Some(directory) = &self.start_directory { let _ = writeln!(out, "start_directory: {}", path(directory)); } - if self.suppress_history { - out.push_str("suppress_history: true\n"); + if !self.suppress_history { + out.push_str("suppress_history: false\n"); } write_pairs(&mut out, Some("environment"), &self.environment, 2); write_pairs(&mut out, Some("options"), &self.options, 2); diff --git a/crates/tmux-workspace/src/freeze.rs b/crates/tmux-workspace/src/freeze.rs index 1f837ffe..d306e462 100644 --- a/crates/tmux-workspace/src/freeze.rs +++ b/crates/tmux-workspace/src/freeze.rs @@ -107,7 +107,7 @@ pub async fn freeze(session: &Session) -> Result { options: Vec::new(), global_options: Vec::new(), shell_command_before: Vec::new(), - suppress_history: false, + suppress_history: true, windows, unsupported_keys: Vec::new(), }) diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index 19ed175e..870b79c1 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -787,6 +787,80 @@ windows: drop(session); } +/// The line some pane of `session` shows holding exactly `text`, once one does. +/// +/// Callers point `default-command` at `cat`, so a pane shows exactly what was +/// typed into it: a command kept out of history is the line that starts with +/// a space. +async fn typed_line(session: &libtmux::Session, text: &str) -> String { + let mut seen = None; + let settled = libtmux::test::retry_until(std::time::Duration::from_secs(30), async || { + for pane in session.panes().await.unwrap_or_default() { + seen = pane.capture().await.ok().and_then(|lines| { + lines + .iter() + .map(|line| line.to_string_lossy().trim_end().to_owned()) + .find(|line| line.trim_start() == text) + }); + if seen.is_some() { + return true; + } + } + false + }) + .await; + assert!(settled.is_ok(), "{text:?} reached a pane"); + seen.expect("the line was seen") +} + +/// tmuxp keeps a file's commands out of shell history unless it says not to, +/// by typing each with a leading space. +#[tokio::test] +async fn commands_stay_out_of_history_unless_the_file_says_otherwise() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + + let mut silent = Workspace::from_yaml( + "session_name: silent\nwindows:\n - panes:\n - typed by default\n", + ) + .expect("configuration parses"); + // tmuxp's own example: the session records, one window and one pane opt + // back out. + let mut example = Workspace::from_yaml(include_str!("fixtures/tmuxp/suppress-history.yaml")) + .expect("tmuxp's example parses"); + for workspace in [&mut silent, &mut example] { + workspace + .global_options + .push(("default-command".to_owned(), "exec cat".to_owned())); + } + + let silent = WorkspaceBuilder::new(server) + .build(&silent) + .await + .expect("workspace builds"); + assert_eq!( + typed_line(&silent, "typed by default").await, + " typed by default" + ); + + let example = WorkspaceBuilder::new(server) + .build(&example) + .await + .expect("tmuxp's example builds"); + for (command, suppressed) in [ + (r#"echo "window in the history!""#, false), + (r#"echo "window not in the history!""#, true), + (r#"echo "session in the history!""#, false), + (r#"echo "command in the history!""#, false), + (r#"echo "command not in the history!""#, true), + ] { + let line = typed_line(&example, command).await; + assert_eq!(line.starts_with(' '), suppressed, "{line:?}"); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A file is fixed in an editor, so an error names the line to go to. #[test] fn an_error_names_the_line_and_column_to_fix() { diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/LICENSE b/crates/tmux-workspace/tests/fixtures/tmuxp/LICENSE new file mode 100644 index 00000000..203c5d28 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013- tmuxp contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/suppress-history.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/suppress-history.yaml new file mode 100644 index 00000000..6e12823f --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/suppress-history.yaml @@ -0,0 +1,29 @@ +session_name: suppress +suppress_history: false +windows: + - window_name: appended + focus: true + suppress_history: false + panes: + - echo "window in the history!" + + - window_name: suppressed + suppress_history: true + panes: + - echo "window not in the history!" + + - window_name: default + panes: + - echo "session in the history!" + + - window_name: mixed + suppress_history: false + panes: + - shell_command: + - echo "command in the history!" + suppress_history: false + - shell_command: + - echo "command not in the history!" + suppress_history: true + - shell_command: + - echo "window in the history!" diff --git a/scripts/check-package-contents.sh b/scripts/check-package-contents.sh index a97626b5..42de690b 100755 --- a/scripts/check-package-contents.sh +++ b/scripts/check-package-contents.sh @@ -81,6 +81,7 @@ workspace_crate_files=( "$workspace_crate_root"/LICENSE-MIT "$workspace_crate_root"/README.md "$workspace_crate_root"/libtmux-macros-README.md + "$workspace_crate_root"/tests/fixtures/tmuxp/* ) # nullglob is on, so an empty list would make every check below vacuous. From 2cbcf73cdfccb9968ee8df2562a26214ec4282d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 23:35:47 -0500 Subject: [PATCH 068/117] Workspace(fix[panes]): Read tmuxp's pane and command shorthands why: tmuxp's own examples/minimal.yaml, a window with one empty `-`, failed to parse, and `- pane` or `- blank` typed a command of that name. The per-command form tmuxp's builder documents, `cmd` with its own `enter` and sleeps, was a parse error. And every bare-string pane, tmuxp's commonest form, was typed without Enter: it took `..PaneConfig::default()`, whose derived `enter` is false. what: - A pane may be null, a command, a list of commands, or a mapping; a lone null, `pane` or `blank` is no command, as in tmuxp. Among other commands the words are typed and a null is refused, where tmuxp raises a TypeError - Add `ShellCommand` (`cmd`, `enter`, `sleep_before`, `sleep_after`) for `shell_command` and `shell_command_before`, and pane-level sleeps - Follow tmuxp's loop: a command's `enter` holds for the ones after it, and the before-commands lead the pane's list under its `enter` - Keep sleeps and write them back, but do not wait: a plan runs start to finish and cannot pause between steps - `PaneConfig::default()` presses Enter - Vendor every tmuxp YAML example and require that each parses --- crates/tmux-workspace/src/config.rs | 263 +++++++++++++++--- crates/tmux-workspace/src/freeze.rs | 9 +- crates/tmux-workspace/src/lib.rs | 18 +- crates/tmux-workspace/tests/build.rs | 262 ++++++++++++++++- .../fixtures/tmuxp/2-pane-synchronized.yaml | 8 + .../tests/fixtures/tmuxp/2-pane-vertical.yaml | 6 + .../tests/fixtures/tmuxp/3-pane.yaml | 12 + .../tests/fixtures/tmuxp/4-pane.yaml | 13 + .../tests/fixtures/tmuxp/blank-panes.yaml | 27 ++ .../tests/fixtures/tmuxp/env-variables.yaml | 17 ++ .../tmuxp/focus-window-and-panes.yaml | 20 ++ .../tmuxp/main-pane-height-percentage.yaml | 15 + .../fixtures/tmuxp/main-pane-height.yaml | 15 + .../tests/fixtures/tmuxp/minimal.yaml | 4 + .../tests/fixtures/tmuxp/options.yaml | 19 ++ .../tests/fixtures/tmuxp/pane-shell.yaml | 22 ++ .../tests/fixtures/tmuxp/plugin-system.yaml | 13 + .../fixtures/tmuxp/session-environment.yaml | 19 ++ .../tests/fixtures/tmuxp/shorthands.yaml | 9 + .../fixtures/tmuxp/skip-send-pane-level.yaml | 9 + .../tests/fixtures/tmuxp/skip-send.yaml | 9 + .../fixtures/tmuxp/sleep-pane-level.yaml | 11 + .../fixtures/tmuxp/sleep-virtualenv.yaml | 11 + .../tests/fixtures/tmuxp/sleep.yaml | 16 ++ .../tests/fixtures/tmuxp/start-directory.yaml | 41 +++ .../tests/fixtures/tmuxp/window-index.yaml | 12 + 26 files changed, 822 insertions(+), 58 deletions(-) create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-synchronized.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-vertical.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/3-pane.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/4-pane.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/blank-panes.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/env-variables.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/focus-window-and-panes.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height-percentage.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/minimal.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/options.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/pane-shell.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/plugin-system.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/session-environment.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/shorthands.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/skip-send-pane-level.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/skip-send.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/sleep-pane-level.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/sleep-virtualenv.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/sleep.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/start-directory.yaml create mode 100644 crates/tmux-workspace/tests/fixtures/tmuxp/window-index.yaml diff --git a/crates/tmux-workspace/src/config.rs b/crates/tmux-workspace/src/config.rs index de7e4feb..3a95853f 100644 --- a/crates/tmux-workspace/src/config.rs +++ b/crates/tmux-workspace/src/config.rs @@ -4,6 +4,7 @@ mod locate; use std::fmt::Write as _; use std::path::PathBuf; +use std::time::Duration; use yaml_rust2::{Yaml, YamlLoader}; @@ -97,7 +98,7 @@ pub struct Workspace { /// Global options to apply once the session exists. pub global_options: Vec<(String, String)>, /// Commands run in every pane before its own, in order. - pub shell_command_before: Vec, + pub shell_command_before: Vec, /// Whether to keep pane commands out of the shell's history. /// /// A file that does not say reads as `true`, as in tmuxp. @@ -131,7 +132,7 @@ pub struct WindowConfig { /// Window options to apply once the window exists. pub options: Vec<(String, String)>, /// Commands run in this window's panes before their own, in order. - pub shell_command_before: Vec, + pub shell_command_before: Vec, /// Whether this window's commands stay out of the shell's history. /// /// `None` inherits the workspace setting. @@ -143,21 +144,35 @@ pub struct WindowConfig { } /// One pane. -#[derive(Clone, Debug, Default, Eq, PartialEq)] +/// +/// The default is a pane that runs nothing and would press Enter after +/// anything it were given, which is what tmuxp's `- pane`, `- blank` and an +/// empty `-` all mean. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct PaneConfig { /// Commands to run in the pane once it exists. - pub shell_commands: Vec, + pub shell_commands: Vec, /// Environment variables set for the process this pane starts. pub environment: Vec<(String, String)>, /// The pane's working directory. pub start_directory: Option, /// Whether this pane should end up selected. pub focus: bool, - /// Whether to press Enter after each command. + /// Whether to press Enter after each command, until a command sets its + /// own [`ShellCommand::enter`]. /// /// tmuxp's `enter: false` types a command without running it, which is - /// how a file leaves something ready for the user to review. + /// how a file leaves something ready for the user to review. It covers + /// the `shell_command_before` commands typed into this pane too. pub enter: bool, + /// How long to wait before each command, until a command sets its own. + /// + /// Read and kept, not acted on: see [`ShellCommand::sleep_before`]. + pub sleep_before: Option, + /// How long to wait after each command, until a command sets its own. + /// + /// Read and kept, not acted on: see [`ShellCommand::sleep_before`]. + pub sleep_after: Option, /// Whether this pane's commands stay out of the shell's history. /// /// `None` inherits the window, then the workspace. @@ -166,6 +181,86 @@ pub struct PaneConfig { pub unsupported_keys: Vec, } +impl Default for PaneConfig { + fn default() -> Self { + Self { + shell_commands: Vec::new(), + environment: Vec::new(), + start_directory: None, + focus: false, + enter: true, + sleep_before: None, + sleep_after: None, + suppress_history: None, + unsupported_keys: Vec::new(), + } + } +} + +/// One command typed into a pane, with tmuxp's per-command settings. +/// +/// A file writes it as a string, or as a mapping with `cmd` and any of +/// `enter`, `sleep_before` and `sleep_after`. A setting given here holds for +/// the commands after it in the same pane until one of them sets its own, +/// because that is what tmuxp does: `enter: false` on one command leaves the +/// next one unentered too. +/// +/// # Examples +/// +/// ``` +/// use tmux_workspace::{ShellCommand, Workspace}; +/// +/// let workspace = Workspace::from_yaml( +/// " +/// session_name: demo +/// windows: +/// - panes: +/// - shell_command: +/// - cd src +/// - cmd: cargo test +/// enter: false +/// ", +/// )?; +/// +/// let commands = &workspace.windows[0].panes[0].shell_commands; +/// assert_eq!(commands[0], ShellCommand::new("cd src")); +/// assert_eq!(commands[1].cmd, "cargo test"); +/// assert_eq!(commands[1].enter, Some(false)); +/// # Ok::<(), tmux_workspace::ConfigError>(()) +/// ``` +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ShellCommand { + /// The text typed into the pane. + pub cmd: String, + /// Whether to press Enter after it. `None` keeps whatever is in force. + pub enter: Option, + /// How long tmuxp waits before typing it. + /// + /// Read and kept so the file round-trips, and not acted on: a + /// [`libtmux::plan::Plan`] runs start to finish with no way to pause + /// between steps. tmux buffers typed input until the pane reads it, so a + /// sleep that only waited for a shell to start is not needed. + pub sleep_before: Option, + /// How long tmuxp waits after typing it. Read and kept, not acted on. + pub sleep_after: Option, +} + +impl ShellCommand { + /// A command with no settings of its own. + #[must_use] + pub fn new(cmd: impl Into) -> Self { + Self { + cmd: cmd.into(), + ..Self::default() + } + } + + /// Whether this is the bare string form, with no settings of its own. + const fn is_plain(&self) -> bool { + self.enter.is_none() && self.sleep_before.is_none() && self.sleep_after.is_none() + } +} + impl Workspace { /// Parse one workspace from tmuxp-style YAML. /// @@ -295,16 +390,22 @@ impl WindowConfig { impl PaneConfig { fn from_yaml(value: &Yaml, window: &str, index: usize) -> Result { let at = format!("{window}.panes[{index}]"); - // tmuxp lets a pane be a bare command string. - if let Some(command) = value.as_str() { - return Ok(Self { - shell_commands: vec![command.to_owned()], - ..Self::default() - }); - } - - if !matches!(value, Yaml::Hash(_)) { - return Err(Problem::new(at, "must be a command string or a mapping")); + match value { + // tmuxp lets a pane be its commands alone, or nothing at all. + Yaml::Null | Yaml::String(_) | Yaml::Array(_) => { + return Ok(Self { + shell_commands: commands(value, &at)?, + ..Self::default() + }); + } + Yaml::Hash(_) => {} + _ => { + return Err(Problem::new( + at, + "must be a command, a list of commands, or a mapping; \ + quote a command YAML would read as a number or a boolean", + )); + } } Ok(Self { @@ -317,6 +418,8 @@ impl PaneConfig { focus: is_true(&value["focus"], &format!("{at}.focus"))?, // tmuxp presses Enter unless a file says otherwise. enter: optional_bool(&value["enter"], &format!("{at}.enter"))?.unwrap_or(true), + sleep_before: optional_seconds(&value["sleep_before"], &format!("{at}.sleep_before"))?, + sleep_after: optional_seconds(&value["sleep_after"], &format!("{at}.sleep_after"))?, suppress_history: optional_bool( &value["suppress_history"], &format!("{at}.suppress_history"), @@ -348,6 +451,8 @@ const PANE_KEYS: &[&str] = &[ "start_directory", "focus", "enter", + "sleep_before", + "sleep_after", "suppress_history", ]; @@ -412,22 +517,76 @@ fn pairs(value: &Yaml, path: &str) -> Result, Problem> { } } -/// Read a value tmuxp allows as a string or a list of strings. -fn commands(value: &Yaml, path: &str) -> Result, Problem> { +/// Read `shell_command` or `shell_command_before`: one command, a list of +/// them, or nothing. +fn commands(value: &Yaml, path: &str) -> Result, Problem> { + let (entries, single) = match value { + Yaml::BadValue | Yaml::Null => return Ok(Vec::new()), + Yaml::Array(entries) => (entries.as_slice(), false), + _ => (std::slice::from_ref(value), true), + }; + // tmuxp reads a lone null, `pane` or `blank` as a pane with no command. + // Among other commands the words are typed, and a null is refused. + if let [only] = entries { + if matches!(only, Yaml::Null) || matches!(only.as_str(), Some("pane" | "blank")) { + return Ok(Vec::new()); + } + } + entries + .iter() + .enumerate() + .map(|(index, entry)| { + let at = if single { + path.to_owned() + } else { + format!("{path}[{index}]") + }; + command(entry, &at) + }) + .collect() +} + +fn command(value: &Yaml, path: &str) -> Result { match value { - Yaml::BadValue | Yaml::Null => Ok(Vec::new()), - Yaml::String(command) => Ok(vec![command.clone()]), - Yaml::Array(entries) => entries - .iter() - .enumerate() - .map(|(index, entry)| { - entry - .as_str() - .map(ToOwned::to_owned) - .ok_or_else(|| Problem::new(format!("{path}[{index}]"), "must be a string")) - }) - .collect(), - _ => Err(Problem::new(path, "must be a string or a list of strings")), + Yaml::String(text) => Ok(ShellCommand::new(text.as_str())), + Yaml::Hash(_) => Ok(ShellCommand { + cmd: value["cmd"] + .as_str() + .ok_or_else(|| Problem::new(format!("{path}.cmd"), "must be a string"))? + .to_owned(), + enter: optional_bool(&value["enter"], &format!("{path}.enter"))?, + sleep_before: optional_seconds( + &value["sleep_before"], + &format!("{path}.sleep_before"), + )?, + sleep_after: optional_seconds(&value["sleep_after"], &format!("{path}.sleep_after"))?, + }), + Yaml::Null => Err(Problem::new( + path, + "is empty among other commands; remove it, or write \"\" to press Enter", + )), + _ => Err(Problem::new( + path, + "must be a command or a mapping with `cmd`; \ + quote a command YAML would read as a number or a boolean", + )), + } +} + +/// Read a number of seconds, which tmuxp passes to `time.sleep`. +fn optional_seconds(value: &Yaml, path: &str) -> Result, Problem> { + let refused = || Problem::new(path, "must be a number of seconds, zero or more"); + match value { + Yaml::BadValue | Yaml::Null => Ok(None), + Yaml::Integer(seconds) => u64::try_from(*seconds) + .map(|seconds| Some(Duration::from_secs(seconds))) + .map_err(|_| refused()), + Yaml::Real(_) => value + .as_f64() + .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) + .map(Some) + .ok_or_else(refused), + _ => Err(refused()), } } @@ -531,7 +690,7 @@ impl Workspace { write_pairs(&mut out, Some("environment"), &self.environment, 2); write_pairs(&mut out, Some("options"), &self.options, 2); write_pairs(&mut out, Some("global_options"), &self.global_options, 2); - write_list( + write_commands( &mut out, Some("shell_command_before"), &self.shell_command_before, @@ -610,7 +769,7 @@ impl WindowConfig { } if !self.shell_command_before.is_empty() { entry.key(out, "shell_command_before:"); - write_list(out, None, &self.shell_command_before, 6); + write_commands(out, None, &self.shell_command_before, 6); } // Always written, even when empty: an entry with no keys at all is @@ -628,10 +787,10 @@ impl PaneConfig { let mut entry = Entry::new(" - ", " "); if let [only] = self.shell_commands.as_slice() { - entry.key(out, &format!("shell_command: {}", quoted(only))); + entry.key(out, &format!("shell_command: {}", command_yaml(only))); } else if !self.shell_commands.is_empty() { entry.key(out, "shell_command:"); - write_list(out, None, &self.shell_commands, 10); + write_commands(out, None, &self.shell_commands, 10); } if let Some(directory) = &self.start_directory { entry.key(out, &format!("start_directory: {}", path(directory))); @@ -642,6 +801,12 @@ impl PaneConfig { if !self.enter { entry.key(out, "enter: false"); } + if let Some(sleep) = self.sleep_before { + entry.key(out, &format!("sleep_before: {}", sleep.as_secs_f64())); + } + if let Some(sleep) = self.sleep_after { + entry.key(out, &format!("sleep_after: {}", sleep.as_secs_f64())); + } if let Some(suppress) = self.suppress_history { entry.key(out, &format!("suppress_history: {suppress}")); } @@ -670,17 +835,35 @@ fn write_pairs(out: &mut String, name: Option<&str>, values: &[(String, String)] } } -/// Write a sequence of strings, indented. -fn write_list(out: &mut String, name: Option<&str>, values: &[String], indent: usize) { - if values.is_empty() { +/// Write a sequence of commands, indented. +fn write_commands(out: &mut String, name: Option<&str>, commands: &[ShellCommand], indent: usize) { + if commands.is_empty() { return; } if let Some(name) = name { let _ = writeln!(out, "{name}:"); } - for value in values { - let _ = writeln!(out, "{:indent$}- {}", "", quoted(value)); + for command in commands { + let _ = writeln!(out, "{:indent$}- {}", "", command_yaml(command)); + } +} + +/// A command as a string, or as a flow mapping when it has settings of its own. +fn command_yaml(command: &ShellCommand) -> String { + if command.is_plain() { + return quoted(&command.cmd); + } + let mut fields = vec![format!("cmd: {}", quoted(&command.cmd))]; + if let Some(enter) = command.enter { + fields.push(format!("enter: {enter}")); + } + if let Some(sleep) = command.sleep_before { + fields.push(format!("sleep_before: {}", sleep.as_secs_f64())); + } + if let Some(sleep) = command.sleep_after { + fields.push(format!("sleep_after: {}", sleep.as_secs_f64())); } + format!("{{{}}}", fields.join(", ")) } /// Quote a path the way a scalar is quoted. diff --git a/crates/tmux-workspace/src/freeze.rs b/crates/tmux-workspace/src/freeze.rs index d306e462..d7671241 100644 --- a/crates/tmux-workspace/src/freeze.rs +++ b/crates/tmux-workspace/src/freeze.rs @@ -13,7 +13,7 @@ use libtmux::{Error, Session}; -use crate::config::{PaneConfig, WindowConfig, Workspace}; +use crate::config::{PaneConfig, ShellCommand, WindowConfig, Workspace}; /// Describe a live session as a workspace. /// @@ -69,16 +69,13 @@ pub async fn freeze(session: &Session) -> Result { panes.push(PaneConfig { shell_commands: pane .current_command() - .map(|command| vec![command.to_string_lossy().into_owned()]) + .map(|command| vec![ShellCommand::new(command.to_string_lossy())]) .unwrap_or_default(), - environment: Vec::new(), start_directory: pane .current_path() .map(|path| path.to_string_lossy().into_owned().into()), focus: pane.is_active(), - enter: true, - suppress_history: None, - unsupported_keys: Vec::new(), + ..PaneConfig::default() }); } diff --git a/crates/tmux-workspace/src/lib.rs b/crates/tmux-workspace/src/lib.rs index 3d0aaf24..4e81f89d 100644 --- a/crates/tmux-workspace/src/lib.rs +++ b/crates/tmux-workspace/src/lib.rs @@ -33,7 +33,7 @@ mod config; mod freeze; -pub use config::{ConfigError, PaneConfig, WindowConfig, Workspace}; +pub use config::{ConfigError, PaneConfig, ShellCommand, WindowConfig, Workspace}; pub use freeze::freeze; use std::path::Path; @@ -201,15 +201,17 @@ impl<'server> WorkspaceBuilder<'server> { .or(config.suppress_history) .unwrap_or(workspace.suppress_history); - let before = workspace + // As in tmuxp, the `shell_command_before` commands lead the + // pane's own list, and a command's `enter` holds for the rest. + let mut enter = pane_config.enter; + let commands = workspace .shell_command_before .iter() - .chain(&config.shell_command_before); - for command in before { - plan.add(Self::typing(*pane, command, suppress, true)); - } - for command in &pane_config.shell_commands { - plan.add(Self::typing(*pane, command, suppress, pane_config.enter)); + .chain(&config.shell_command_before) + .chain(&pane_config.shell_commands); + for command in commands { + enter = command.enter.unwrap_or(enter); + plan.add(Self::typing(*pane, &command.cmd, suppress, enter)); } if pane_config.focus { focus_pane = Some(*pane); diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index 870b79c1..9ce2f80e 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -7,7 +7,9 @@ use libtmux::TmuxText; use libtmux::plan::Planner; use libtmux::test::TestServer; -use tmux_workspace::{BuildError, ConfigError, Workspace, WorkspaceBuilder}; +use tmux_workspace::{ + BuildError, ConfigError, PaneConfig, ShellCommand, Workspace, WorkspaceBuilder, +}; fn text(value: &TmuxText) -> String { String::from_utf8(value.as_bytes().to_vec()).expect("fixture values are UTF-8") @@ -38,9 +40,15 @@ windows: let panes = &workspace.windows[0].panes; assert_eq!(panes.len(), 3); - assert_eq!(panes[0].shell_commands, ["echo bare"]); - assert_eq!(panes[1].shell_commands, ["echo single"]); - assert_eq!(panes[2].shell_commands, ["echo first", "echo second"]); + assert_eq!(panes[0].shell_commands, [ShellCommand::new("echo bare")]); + assert_eq!(panes[1].shell_commands, [ShellCommand::new("echo single")]); + assert_eq!( + panes[2].shell_commands, + [ + ShellCommand::new("echo first"), + ShellCommand::new("echo second") + ] + ); assert!(panes[2].focus); assert!(!panes[0].focus); } @@ -861,6 +869,252 @@ async fn commands_stay_out_of_history_unless_the_file_says_otherwise() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Whether some pane of `session` shows `text`, waiting for it to. +async fn shows(session: &libtmux::Session, text: &str) -> bool { + libtmux::test::retry_until(std::time::Duration::from_secs(30), async || { + for pane in session.panes().await.unwrap_or_default() { + if pane.capture().await.is_ok_and(|lines| { + lines + .iter() + .any(|line| line.to_string_lossy().contains(text)) + }) { + return true; + } + } + false + }) + .await + .is_ok() +} + +/// `- vim` is tmuxp's commonest pane, and it runs `vim`. +#[tokio::test] +async fn a_bare_command_pane_runs_its_command() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let workspace = Workspace::from_yaml( + "session_name: bare\nwindows:\n - panes:\n - echo ran-$((20+22))\n", + ) + .expect("configuration parses"); + + let session = WorkspaceBuilder::new(guard.server()) + .build(&workspace) + .await + .expect("workspace builds"); + // Only the shell's arithmetic prints `ran-42`; the typed text does not. + assert!(shows(&session, "ran-42").await, "the command ran"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// Every example tmuxp ships reads here. +#[test] +fn every_tmuxp_example_parses() { + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tmuxp"); + let mut read = 0; + for entry in std::fs::read_dir(&directory).expect("the fixtures are present") { + let path = entry.expect("a directory entry").path(); + if path.extension().is_none_or(|extension| extension != "yaml") { + continue; + } + let source = std::fs::read_to_string(&path).expect("the fixture reads"); + if let Err(error) = Workspace::from_yaml(&source) { + panic!("{}: {error}", path.display()); + } + read += 1; + } + assert!(read >= 20, "only {read} examples were found to read"); +} + +/// tmuxp reads an empty entry, `pane` and `blank` as a pane with no command. +#[test] +fn a_blank_pane_is_a_pane_without_a_command() { + let minimal = Workspace::from_yaml(include_str!("fixtures/tmuxp/minimal.yaml")) + .expect("tmuxp's minimal example parses"); + assert_eq!(minimal.windows[0].panes, [PaneConfig::default()]); + + let blank = Workspace::from_yaml(include_str!("fixtures/tmuxp/blank-panes.yaml")) + .expect("tmuxp's blank-pane example parses"); + let shapes: Vec>> = blank + .windows + .iter() + .map(|window| { + window + .panes + .iter() + .map(|pane| pane.shell_commands.clone()) + .collect() + }) + .collect(); + let enter = || vec![ShellCommand::new("")]; + assert_eq!( + shapes, + [ + vec![vec![], vec![], vec![]], + vec![vec![], vec![], vec![]], + // An empty string is a command: it presses Enter. + vec![enter(), enter(), enter()], + vec![vec![], vec![]], + ], + ); + + let focus = Workspace::from_yaml(include_str!("fixtures/tmuxp/focus-window-and-panes.yaml")) + .expect("tmuxp's focus example parses"); + assert!(focus.windows[1].panes[0].shell_commands.is_empty()); + + // Only a lone blank is blank: among other commands the word is typed. + let listed = + Workspace::from_yaml("session_name: s\nwindows:\n - panes:\n - [ls, pane]\n") + .expect("a pane may be a list of commands"); + assert_eq!( + listed.windows[0].panes[0].shell_commands, + [ShellCommand::new("ls"), ShellCommand::new("pane")], + ); + let message = Workspace::from_yaml( + "session_name: s\nwindows:\n - panes:\n - shell_command: [ls, null]\n", + ) + .expect_err("tmuxp fails on a null among commands") + .to_string(); + assert!( + message.contains("windows[0].panes[0].shell_command[1] is empty among other commands"), + "{message}", + ); +} + +#[tokio::test] +async fn tmuxp_blank_pane_examples_build() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let builder = WorkspaceBuilder::new(guard.server()); + + for (source, panes) in [ + (include_str!("fixtures/tmuxp/minimal.yaml"), vec![1]), + ( + include_str!("fixtures/tmuxp/blank-panes.yaml"), + vec![3, 3, 3, 2], + ), + ] { + let workspace = Workspace::from_yaml(source).expect("tmuxp's example parses"); + let session = builder.build(&workspace).await.expect("the example builds"); + let mut counts = Vec::new(); + for window in session.windows().await.expect("windows list") { + counts.push(window.pane_count()); + } + assert_eq!(counts, panes, "{}", workspace.session_name); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// tmuxp's per-command form: `cmd` with its own `enter` and sleeps. +#[test] +fn a_command_may_carry_its_own_settings() { + let seconds = |seconds| Some(std::time::Duration::from_secs(seconds)); + + let skip = Workspace::from_yaml(include_str!("fixtures/tmuxp/skip-send.yaml")) + .expect("tmuxp's skip-send example parses"); + assert_eq!( + skip.windows[0].panes[0].shell_commands[1], + ShellCommand { + cmd: r#"echo "___$((1 + 3))___""#.to_owned(), + enter: Some(false), + ..ShellCommand::default() + }, + ); + let pane_level = Workspace::from_yaml(include_str!("fixtures/tmuxp/skip-send-pane-level.yaml")) + .expect("tmuxp's pane-level skip-send example parses"); + assert!(pane_level.windows[0].panes.iter().all(|pane| !pane.enter)); + + let sleep = Workspace::from_yaml(include_str!("fixtures/tmuxp/sleep.yaml")) + .expect("tmuxp's sleep example parses"); + let commands = &sleep.windows[0].panes[0].shell_commands; + assert_eq!(commands[1].sleep_before, seconds(2)); + assert_eq!(commands[3].sleep_after, seconds(2)); + let sleep_pane = Workspace::from_yaml(include_str!("fixtures/tmuxp/sleep-pane-level.yaml")) + .expect("tmuxp's pane-level sleep example parses"); + assert_eq!(sleep_pane.windows[0].panes[0].sleep_before, seconds(2)); + let venv = Workspace::from_yaml(include_str!("fixtures/tmuxp/sleep-virtualenv.yaml")) + .expect("tmuxp's virtualenv example parses"); + assert_eq!( + venv.shell_command_before, + [ShellCommand { + cmd: "source .venv/bin/activate".to_owned(), + sleep_before: seconds(1), + sleep_after: seconds(1), + ..ShellCommand::default() + }], + ); + + for workspace in [skip, pane_level, sleep, sleep_pane, venv] { + assert_eq!( + Workspace::from_yaml(&workspace.to_yaml()).expect("the rendered YAML parses"), + workspace, + ); + } + + let fractional = Workspace::from_yaml( + "session_name: s\nwindows:\n - panes:\n - shell_command: {cmd: ls, sleep_after: 0.25}\n", + ) + .expect("a fraction of a second is a sleep"); + assert_eq!( + fractional.windows[0].panes[0].shell_commands[0].sleep_after, + Some(std::time::Duration::from_millis(250)), + ); + let message = Workspace::from_yaml( + "session_name: s\nwindows:\n - panes:\n - shell_command: [{cmd: ls, sleep_before: -1}]\n", + ) + .expect_err("a negative sleep is refused") + .to_string(); + assert!( + message.contains("shell_command[0].sleep_before must be a number of seconds"), + "{message}", + ); +} + +/// `enter: false` types a command and leaves it, and holds for the commands +/// after it until one says otherwise, the `shell_command_before` ones +/// included: that is what tmuxp does. +#[tokio::test] +async fn enter_false_types_without_running_until_a_command_says_otherwise() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let mut workspace = Workspace::from_yaml( + " +session_name: typed +windows: + - shell_command_before: [before] + panes: + - shell_command: + - cmd: first + enter: false + - second + - cmd: third + enter: true + - enter: false + shell_command: + - cmd: last + enter: true +", + ) + .expect("configuration parses"); + workspace + .global_options + .push(("default-command".to_owned(), "exec cat".to_owned())); + + let session = WorkspaceBuilder::new(guard.server()) + .build(&workspace) + .await + .expect("workspace builds"); + + // Each command is typed after a space that keeps it out of history, so + // commands sent without Enter share a line, separated by those spaces. + assert_eq!(typed_line(&session, "before").await, " before"); + assert_eq!( + typed_line(&session, "first second third").await, + " first second third" + ); + assert_eq!(typed_line(&session, "before last").await, " before last"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A file is fixed in an editor, so an error names the line to go to. #[test] fn an_error_names_the_line_and_column_to_fix() { diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-synchronized.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-synchronized.yaml new file mode 100644 index 00000000..50f77473 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-synchronized.yaml @@ -0,0 +1,8 @@ +session_name: 2-pane-synchronized +windows: + - window_name: Two synchronized panes + panes: + - ssh server1 + - ssh server2 + options_after: + synchronize-panes: on diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-vertical.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-vertical.yaml new file mode 100644 index 00000000..a40e0568 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/2-pane-vertical.yaml @@ -0,0 +1,6 @@ +session_name: 2-pane-vertical +windows: + - window_name: my test window + panes: + - echo hello + - echo hello diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/3-pane.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/3-pane.yaml new file mode 100644 index 00000000..76e61103 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/3-pane.yaml @@ -0,0 +1,12 @@ +session_name: 3-panes +windows: + - window_name: dev window + layout: main-vertical + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/4-pane.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/4-pane.yaml new file mode 100644 index 00000000..d42b8995 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/4-pane.yaml @@ -0,0 +1,13 @@ +session_name: 4-pane-split +windows: + - window_name: dev window + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/blank-panes.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/blank-panes.yaml new file mode 100644 index 00000000..9828ac18 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/blank-panes.yaml @@ -0,0 +1,27 @@ +session_name: Blank pane test +windows: + # Emptiness will simply open a blank pane, if no shell_command_before. + # All these are equivalent + - window_name: Blank pane test + panes: + - + - pane + - blank + - window_name: More blank panes + panes: + - null + - shell_command: + - shell_command: + - + # an empty string will be treated as a carriage return + - window_name: Empty string (return) + panes: + - "" + - shell_command: "" + - shell_command: + - "" + # a pane can have other options but still be blank + - window_name: Blank with options + panes: + - focus: true + - start_directory: /tmp diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/env-variables.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/env-variables.yaml new file mode 100644 index 00000000..e448f5a0 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/env-variables.yaml @@ -0,0 +1,17 @@ +start_directory: "${PWD}/test" +shell_command_before: "echo ${PWD}" +before_script: "${MY_ENV_VAR}/test3.sh" +session_name: session - ${USER} (${MY_ENV_VAR}) +windows: + - window_name: editor + panes: + - shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging for ${USER} + options: + automatic-rename: true + panes: + - shell_command: + - htop + - ls $PWD diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/focus-window-and-panes.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/focus-window-and-panes.yaml new file mode 100644 index 00000000..de480566 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/focus-window-and-panes.yaml @@ -0,0 +1,20 @@ +session_name: focus +windows: + - window_name: attached window + focus: true + panes: + - shell_command: + - echo hello + - echo 'this pane should be selected on load' + focus: true + - shell_command: + - cd /var/log + - echo hello + - window_name: second window + shell_command_before: cd /var/log + panes: + - pane + - shell_command: + - echo 'this pane should be focused, when window switched to first time' + focus: true + - pane diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height-percentage.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height-percentage.yaml new file mode 100644 index 00000000..061aad4a --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height-percentage.yaml @@ -0,0 +1,15 @@ +session_name: main-pane-height +start_directory: "~" +windows: + - layout: main-horizontal + options: + main-pane-height: 67% + panes: + - shell_command: + - top + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" + window_name: my window name diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height.yaml new file mode 100644 index 00000000..fe9a23f0 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/main-pane-height.yaml @@ -0,0 +1,15 @@ +session_name: main-pane-height +start_directory: "~" +windows: + - layout: main-horizontal + options: + main-pane-height: 30 + panes: + - shell_command: + - top + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" + window_name: my window name diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/minimal.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/minimal.yaml new file mode 100644 index 00000000..e4f86e47 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/minimal.yaml @@ -0,0 +1,4 @@ +session_name: My tmux session +windows: + - panes: + - diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/options.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/options.yaml new file mode 100644 index 00000000..b6618982 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/options.yaml @@ -0,0 +1,19 @@ +session_name: test window options +start_directory: "~" +global_options: + default-shell: /bin/sh + default-command: /bin/sh +options: + main-pane-height: ${MAIN_PANE_HEIGHT} # works with env variables +windows: + - layout: main-horizontal + options: + automatic-rename: on + panes: + - shell_command: + - man echo + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/pane-shell.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/pane-shell.yaml new file mode 100644 index 00000000..0fee4fde --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/pane-shell.yaml @@ -0,0 +1,22 @@ +session_name: Pane shell example +windows: + - window_name: first + window_shell: /usr/bin/python2 + layout: even-vertical + suppress_history: false + options: + remain-on-exit: true + panes: + - shell: /usr/bin/python3 + shell_command: + - print('This is python 3') + - shell: /usr/bin/vim -u none + shell_command: + - iAll panes have the `remain-on-exit` setting on. + - When you exit out of the shell or application, the panes will remain. + - Use tmux command `:kill-pane` to remove the pane. + - Use tmux command `:respawn-pane` to restart the shell in the pane. + - Use and then `:q!` to get out of this vim window. :-) + - shell_command: + - print('Hello World 2') + - shell: /usr/bin/top diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/plugin-system.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/plugin-system.yaml new file mode 100644 index 00000000..15581de7 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/plugin-system.yaml @@ -0,0 +1,13 @@ +session_name: plugin-system +plugins: + - "tmuxp_plugin_extended_build.plugin.PluginExtendedBuild" +windows: + - window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep *.log + - echo "hello world" diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/session-environment.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/session-environment.yaml new file mode 100644 index 00000000..a1091e7a --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/session-environment.yaml @@ -0,0 +1,19 @@ +session_name: Environment variables test +environment: + EDITOR: /usr/bin/vim + DJANGO_SETTINGS_MODULE: my_app.settings.local + SERVER_PORT: "8009" +windows: + - window_name: Django project + panes: + - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - window_name: Another Django project + environment: + DJANGO_SETTINGS_MODULE: my_app.settings.local + SERVER_PORT: "8010" + panes: + - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - environment: + DJANGO_SETTINGS_MODULE: my_app.settings.local-testing + SERVER_PORT: "8011" + shell_command: ./manage.py runserver 0.0.0.0:${SERVER_PORT} diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/shorthands.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/shorthands.yaml new file mode 100644 index 00000000..cd4afaf3 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/shorthands.yaml @@ -0,0 +1,9 @@ +session_name: shorthands +windows: + - window_name: long form + panes: + - shell_command: + - echo 'did you know' + - echo 'you can inline' + - shell_command: echo 'single commands' + - echo 'for panes' diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/skip-send-pane-level.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/skip-send-pane-level.yaml new file mode 100644 index 00000000..2e988e89 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/skip-send-pane-level.yaml @@ -0,0 +1,9 @@ +session_name: Skip command execution (pane-level) +windows: + - panes: + - shell_command: echo "___$((1 + 3))___" + enter: false + - shell_command: + - echo "___$((1 + 3))___"\; + - echo "___$((1 + 3))___" + enter: false diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/skip-send.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/skip-send.yaml new file mode 100644 index 00000000..150aa64f --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/skip-send.yaml @@ -0,0 +1,9 @@ +session_name: Skip command execution (command-level) +windows: + - panes: + - shell_command: + # You can see this + - echo "___$((11 + 1))___" + # This is skipped + - cmd: echo "___$((1 + 3))___" + enter: false diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/sleep-pane-level.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/sleep-pane-level.yaml new file mode 100644 index 00000000..00a1ef21 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/sleep-pane-level.yaml @@ -0,0 +1,11 @@ +session_name: Pause / skip command execution (pane-level) +windows: + - panes: + - # Wait 2 seconds before sending all commands in this pane + sleep_before: 2 + shell_command: + - echo "___$((11 + 1))___" + - cmd: echo "___$((1 + 3))___" + - cmd: echo "___$((1 + 3))___" + - cmd: echo "Stuff rendering here!" + - cmd: echo "2 seconds later" diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/sleep-virtualenv.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/sleep-virtualenv.yaml new file mode 100644 index 00000000..e226b6e3 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/sleep-virtualenv.yaml @@ -0,0 +1,11 @@ +session_name: virtualenv +shell_command_before: + # - cmd: source $(poetry env info --path)/bin/activate + # - cmd: source `pipenv --venv`/bin/activate + - cmd: source .venv/bin/activate + sleep_before: 1 + sleep_after: 1 +windows: + - panes: + - shell_command: + - ./manage.py runserver diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/sleep.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/sleep.yaml new file mode 100644 index 00000000..cf1e7f7e --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/sleep.yaml @@ -0,0 +1,16 @@ +session_name: Pause / skip command execution (command-level) +windows: + - panes: + - shell_command: + # Executes immediately + - echo "___$((11 + 1))___" + # Delays before sending 2 seconds + - cmd: echo "___$((1 + 3))___" + sleep_before: 2 + # Executes immediately + - cmd: echo "___$((1 + 3))___" + # Pauses 2 seconds after + - cmd: echo "Stuff rendering here!" + sleep_after: 2 + # Executes after earlier commands (after 2 sec) + - cmd: echo "2 seconds later" diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/start-directory.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/start-directory.yaml new file mode 100644 index 00000000..dcecf67b --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/start-directory.yaml @@ -0,0 +1,41 @@ +session_name: start directory +start_directory: /var/ +windows: + - window_name: should be /var/ + panes: + - shell_command: + - echo "\033c + - it trickles down from session-level" + - echo hello + - window_name: should be /var/log + start_directory: log + panes: + - shell_command: + - echo '\033c + - window start_directory concatenates to session start_directory + - if it is not absolute' + - echo hello + - window_name: should be ~ + start_directory: "~" + panes: + - shell_command: + - 'echo \\033c ~ has precedence. note: remember to quote ~ in YAML' + - echo hello + - window_name: should be /bin + start_directory: /bin + panes: + - echo '\033c absolute paths also have precedence.' + - echo hello + - window_name: should be workspace file's dir + + start_directory: ./ + panes: + - shell_command: + - echo '\033c + - ./ is relative to workspace file location + - ../ will be parent of workspace file + - ./test will be \"test\" dir inside dir of workspace file' + - shell_command: + - echo '\033c + - This way you can load up workspaces from projects and maintain + - relative paths.' diff --git a/crates/tmux-workspace/tests/fixtures/tmuxp/window-index.yaml b/crates/tmux-workspace/tests/fixtures/tmuxp/window-index.yaml new file mode 100644 index 00000000..36da4f07 --- /dev/null +++ b/crates/tmux-workspace/tests/fixtures/tmuxp/window-index.yaml @@ -0,0 +1,12 @@ +session_name: Window index example +windows: + - window_name: zero + panes: + - echo "this window's index will be zero" + - window_name: five + panes: + - echo "this window's index will be five" + window_index: 5 + - window_name: one + panes: + - echo "this window's index will be one" From 9272adf7a49b7351e4f3d737b6091e77b741b785 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 23:50:58 -0500 Subject: [PATCH 069/117] Workspace(fix[directories]): Resolve start directories as tmuxp does why: `start_directory` reached tmux as written. `~/x` and `$VAR/x` were relative paths tmux resolved against the loading process's directory, and a directory that does not exist makes tmux fall back to `$HOME`, so a wrong one looked right for `~`. `./` could not mean the file's directory, because `from_yaml` takes no path. A window's relative directory replaced the session's instead of joining it. what: - Expand `~` and `$NAME`/`${NAME}` the way tmuxp's `expandshell` does (Python's `expanduser`, then `expandvars`), in start directories, names, and `environment` and option values; leave commands to the pane's shell, where tmuxp pre-expands and so re-reads a variable's value as shell code - Add `Workspace::from_file`, which resolves `.` paths from the file's directory, and `ConfigError::Read` - Join a window's relative directory onto the session's; start a `.` path from the directory it inherits, where tmuxp raises a KeyError when there is none; resolve the rest from the current directory, as tmux would - Refuse `~name`: there is no user database lookup without libc - Build tmuxp's start-directory.yaml and check every window lands where tmuxp's loader puts it --- crates/tmux-workspace/src/config.rs | 327 +++++++++++++++++++++++---- crates/tmux-workspace/tests/build.rs | 177 +++++++++++++++ 2 files changed, 459 insertions(+), 45 deletions(-) diff --git a/crates/tmux-workspace/src/config.rs b/crates/tmux-workspace/src/config.rs index 3a95853f..cdef6380 100644 --- a/crates/tmux-workspace/src/config.rs +++ b/crates/tmux-workspace/src/config.rs @@ -2,8 +2,9 @@ mod locate; +use std::ffi::OsString; use std::fmt::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use yaml_rust2::{Yaml, YamlLoader}; @@ -12,6 +13,15 @@ use yaml_rust2::{Yaml, YamlLoader}; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ConfigError { + /// The workspace file could not be read. + #[error("cannot read workspace file {}", path.display())] + Read { + /// The file that was asked for. + path: PathBuf, + /// Why it could not be read. + source: std::io::Error, + }, + /// The document was not valid YAML. #[error("workspace configuration is not valid YAML at line {line}, column {column}: {reason}")] Yaml { @@ -268,6 +278,14 @@ impl Workspace { /// deliberately not a full tmuxp implementation: unknown keys are ignored /// rather than rejected, so a richer tmuxp file still loads. /// + /// As tmuxp does, it expands `~` and `$NAME` or `${NAME}` from this + /// process's environment in names, start directories, and `environment` + /// and option values, leaving an unset variable as written; commands are + /// typed as written, for the pane's shell to expand. A start directory + /// that begins with `.` is relative to the one it inherits, or to the + /// current directory at the top: [`Self::from_file`] uses the file's + /// directory instead. + /// /// # Errors /// /// Returns an error when the document is not valid YAML, does not hold @@ -296,34 +314,83 @@ impl Workspace { /// # Ok::<(), tmux_workspace::ConfigError>(()) /// ``` pub fn from_yaml(source: &str) -> Result { + Self::parse(source, None) + } + + /// Read and parse one workspace file, as `tmuxp load` does. + /// + /// Everything [`Self::from_yaml`] says holds, except that a start + /// directory beginning with `.` and inheriting none is relative to the + /// file's directory. JSON is read too, as the YAML it is a subset of. + /// + /// # Errors + /// + /// Returns [`ConfigError::Read`] when the file cannot be read, and + /// otherwise what [`Self::from_yaml`] returns. + /// + /// # Examples + /// + /// ``` + /// use tmux_workspace::Workspace; + /// + /// # fn main() -> Result<(), Box> { + /// let project = tempfile::tempdir()?; + /// let file = project.path().join(".tmuxp.yaml"); + /// std::fs::write(&file, "session_name: project\nstart_directory: ./\n")?; + /// + /// let workspace = Workspace::from_file(&file)?; + /// assert_eq!(workspace.start_directory.as_deref(), Some(project.path())); + /// # Ok(()) + /// # } + /// ``` + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let read = |source| ConfigError::Read { + path: path.to_owned(), + source, + }; + let source = std::fs::read_to_string(path).map_err(read)?; + let directory = std::path::absolute(path) + .map_err(read)? + .parent() + .map(Path::to_path_buf); + Self::parse(&source, directory.as_deref()) + } + + fn parse(source: &str, base: Option<&Path>) -> Result { let documents = YamlLoader::load_from_str(source)?; let [document] = documents.as_slice() else { return Err(ConfigError::DocumentCount { found: documents.len(), }); }; - Self::from_document(document).map_err(|problem| problem.locate(source)) + Self::from_document(document, &Directories { base }) + .map_err(|problem| problem.locate(source)) } - fn from_document(document: &Yaml) -> Result { + fn from_document(document: &Yaml, directories: &Directories<'_>) -> Result { let session_name = document["session_name"] .as_str() - .ok_or_else(|| Problem::new("session_name", "must be a string"))? - .to_owned(); + .ok_or_else(|| Problem::new("session_name", "must be a string"))?; + let session_name = expand(session_name, "session_name")?; + let start_directory = + directories.resolve(&document["start_directory"], "start_directory", None, false)?; let windows = match &document["windows"] { Yaml::BadValue | Yaml::Null => Vec::new(), Yaml::Array(entries) => entries .iter() .enumerate() - .map(|(index, window)| WindowConfig::from_yaml(window, index)) + .map(|(index, window)| { + WindowConfig::from_yaml(window, index, directories, start_directory.as_deref()) + }) .collect::, _>>()?, _ => return Err(Problem::new("windows", "must be a list")), }; Ok(Self { session_name, - start_directory: optional_path(&document["start_directory"], "start_directory")?, + start_directory, environment: pairs(&document["environment"], "environment")?, options: pairs(&document["options"], "options")?, global_options: pairs(&document["global_options"], "global_options")?, @@ -341,32 +408,48 @@ impl Workspace { } impl WindowConfig { - fn from_yaml(value: &Yaml, index: usize) -> Result { + fn from_yaml( + value: &Yaml, + index: usize, + directories: &Directories<'_>, + session: Option<&Path>, + ) -> Result { let at = format!("windows[{index}]"); if !matches!(value, Yaml::Hash(_)) { return Err(Problem::new(at, "must be a mapping")); } + // tmuxp joins a window's relative directory onto the session's. + let start_directory = directories.resolve( + &value["start_directory"], + &format!("{at}.start_directory"), + session, + true, + )?; + let inherited = start_directory.as_deref().or(session); let panes = match &value["panes"] { // A window with no panes still has the one tmux creates with it. Yaml::BadValue | Yaml::Null => vec![PaneConfig::default()], Yaml::Array(entries) => entries .iter() .enumerate() - .map(|(pane, entry)| PaneConfig::from_yaml(entry, &at, pane)) + .map(|(pane, entry)| { + PaneConfig::from_yaml(entry, &at, pane, directories, inherited) + }) .collect::, _>>()?, _ => return Err(Problem::new(format!("{at}.panes"), "must be a list")), }; + let window_name = value["window_name"] + .as_str() + .map(|name| expand(name, &format!("{at}.window_name"))) + .transpose()?; Ok(Self { - window_name: value["window_name"].as_str().map(ToOwned::to_owned), + window_name, window_index: optional_index(&value["window_index"], &format!("{at}.window_index"))?, window_shell: value["window_shell"].as_str().map(ToOwned::to_owned), environment: pairs(&value["environment"], &format!("{at}.environment"))?, layout: optional_text(&value["layout"], &format!("{at}.layout"))?, - start_directory: optional_path( - &value["start_directory"], - &format!("{at}.start_directory"), - )?, + start_directory, focus: is_true(&value["focus"], &format!("{at}.focus"))?, options: pairs(&value["options"], &format!("{at}.options"))?, shell_command_before: commands( @@ -388,7 +471,13 @@ impl WindowConfig { } impl PaneConfig { - fn from_yaml(value: &Yaml, window: &str, index: usize) -> Result { + fn from_yaml( + value: &Yaml, + window: &str, + index: usize, + directories: &Directories<'_>, + inherited: Option<&Path>, + ) -> Result { let at = format!("{window}.panes[{index}]"); match value { // tmuxp lets a pane be its commands alone, or nothing at all. @@ -411,9 +500,13 @@ impl PaneConfig { Ok(Self { shell_commands: commands(&value["shell_command"], &format!("{at}.shell_command"))?, environment: pairs(&value["environment"], &format!("{at}.environment"))?, - start_directory: optional_path( + // tmuxp does not join a pane's relative directory onto the + // window's; only a `.` path starts from it. + start_directory: directories.resolve( &value["start_directory"], &format!("{at}.start_directory"), + inherited, + false, )?, focus: is_true(&value["focus"], &format!("{at}.focus"))?, // tmuxp presses Enter unless a file says otherwise. @@ -492,25 +585,19 @@ fn pairs(value: &Yaml, path: &str) -> Result, Problem> { let key = key .as_str() .ok_or_else(|| Problem::new(path, "names must be strings"))?; - // tmuxp writes option values as strings, numbers, or bools. - let value = value.as_str().map(ToOwned::to_owned).or_else(|| { - value.as_i64().map(|number| number.to_string()).or_else(|| { - value.as_bool().map(|flag| { - if flag { - "on".to_owned() - } else { - "off".to_owned() - } - }) - }) - }); - - value.map(|value| (key.to_owned(), value)).ok_or_else(|| { - Problem::new( - format!("{path}.{key}"), - "must be a string, a number, or a boolean", - ) - }) + let at = format!("{path}.{key}"); + // tmuxp writes option values as strings, numbers, or bools, + // and expands only the strings. + let value = match value { + Yaml::String(text) => expand(text, &at)?, + Yaml::Integer(number) => number.to_string(), + Yaml::Boolean(true) => "on".to_owned(), + Yaml::Boolean(false) => "off".to_owned(), + _ => { + return Err(Problem::new(at, "must be a string, a number, or a boolean")); + } + }; + Ok((key.to_owned(), value)) }) .collect(), _ => Err(Problem::new(path, "must be a mapping of names to values")), @@ -605,17 +692,129 @@ fn optional_index(value: &Yaml, path: &str) -> Result, Problem> { } } -/// Read an optional path, refusing a value that is present and not one. +/// Where a workspace's relative start directories are resolved from. +struct Directories<'a> { + /// The workspace file's directory, or `None` for the current directory. + base: Option<&'a Path>, +} + +impl Directories<'_> { + /// Read a `start_directory` and resolve it the way tmuxp's loader does. + /// + /// `~` and variables expand first. An absolute result stands. A result + /// starting with `.` is relative to `parent`, else to the base. Any other + /// relative result joins `parent` when `join` is set, which tmuxp does for + /// a window under its session, and is otherwise relative to the current + /// directory, where tmux would resolve it. + /// + /// Absence defaults; a wrong shape does not. `start_directory: 123` used + /// to read as "no start directory", which builds a workspace that is valid + /// and not the one the file describes. + fn resolve( + &self, + value: &Yaml, + path: &str, + parent: Option<&Path>, + join: bool, + ) -> Result, Problem> { + let text = match value { + Yaml::BadValue | Yaml::Null => return Ok(None), + Yaml::String(text) => text, + _ => return Err(Problem::new(path, "must be a string")), + }; + if text.starts_with('~') && !(text == "~" || text.starts_with("~/")) { + return Err(Problem::new( + path, + "starts with `~name`, which is not expanded here; write the directory out", + )); + } + let expanded = PathBuf::from(expand(text, path)?); + if expanded.is_absolute() { + return Ok(Some(tidy(&expanded))); + } + let anchor = if text.starts_with('.') { + parent.or(self.base) + } else if join { + parent + } else { + None + }; + let anchor = match anchor { + Some(anchor) => anchor.to_owned(), + None => std::env::current_dir().map_err(|error| { + Problem::new( + path, + format!("is relative, and the current directory cannot be read: {error}"), + ) + })?, + }; + Ok(Some(tidy(&anchor.join(expanded)))) + } +} + +/// Drop `.` components and doubled separators. `..` is kept for the kernel +/// to resolve, since a lexical `..` is wrong across a symbolic link. +fn tidy(path: &Path) -> PathBuf { + path.components().collect() +} + +/// Expand `text` against this process's environment, as tmuxp's +/// `expandshell` does. +fn expand(text: &str, path: &str) -> Result { + expand_with(text, |name| std::env::var_os(name)).map_err(|reason| Problem::new(path, reason)) +} + +/// Python's `os.path.expanduser` then `os.path.expandvars`, which is what +/// tmuxp applies. /// -/// Absence defaults; a wrong shape does not. `start_directory: 123` used to -/// read as "no start directory", which builds a workspace that is valid and -/// not the one the file describes. -fn optional_path(value: &Yaml, path: &str) -> Result, Problem> { - match value { - Yaml::BadValue | Yaml::Null => Ok(None), - Yaml::String(text) => Ok(Some(PathBuf::from(text))), - _ => Err(Problem::new(path, "must be a string")), +/// A leading `~` or `~/` becomes `$HOME`. `$NAME` (ASCII letters, digits and +/// `_`) and `${NAME}` become the variable's value; an unset variable, `~name` +/// and a lone `$` stay as written. There is no escape, in tmuxp or here. +fn expand_with(text: &str, variable: impl Fn(&str) -> Option) -> Result { + let text_of = |name: &str, value: OsString| { + value + .into_string() + .map_err(|_| format!("names ${name}, whose value is not UTF-8")) + }; + let mut expanded = String::with_capacity(text.len()); + let mut rest = text; + if let Some(tail) = text.strip_prefix('~') { + if tail.is_empty() || tail.starts_with('/') { + let home = variable("HOME").ok_or("starts with `~`, and HOME is not set")?; + expanded.push_str(text_of("HOME", home)?.trim_end_matches('/')); + if expanded.is_empty() && tail.is_empty() { + expanded.push('/'); + } + rest = tail; + } + } + while let Some(at) = rest.find('$') { + expanded.push_str(&rest[..at]); + let after = &rest[at + 1..]; + let (name, length) = if let Some(braced) = after.strip_prefix('{') { + braced + .find('}') + .map_or(("", 0), |end| (&braced[..end], end + 2)) + } else { + let end = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(after.len()); + (&after[..end], end) + }; + // A name no environment variable can have is never looked up. + let value = if name.is_empty() || name.contains(['=', '\0']) { + None + } else { + variable(name) + }; + match value { + Some(value) => expanded.push_str(&text_of(name, value)?), + None => expanded.push_str(&rest[at..=at + length]), + } + rest = &after[length..]; } + expanded.push_str(rest); + Ok(expanded) } /// Read an optional string, refusing a value that is present and not one. @@ -867,7 +1066,7 @@ fn command_yaml(command: &ShellCommand) -> String { } /// Quote a path the way a scalar is quoted. -fn path(value: &std::path::Path) -> String { +fn path(value: &Path) -> String { quoted(&value.display().to_string()) } @@ -893,3 +1092,41 @@ fn quoted(value: &str) -> String { escaped.push('"'); escaped } + +#[cfg(test)] +mod tests { + use super::expand_with; + + /// Each expected value is what Python's `os.path.expandvars( + /// os.path.expanduser(text))` returns with the same two variables set. + #[test] + fn expansion_is_pythons_expanduser_then_expandvars() { + let expand = |text| { + expand_with(text, |name| match name { + "HOME" => Some("/home/me/".into()), + "PROJECT" => Some("tmux".into()), + _ => None, + }) + }; + for (text, expected) in [ + ("~", "/home/me"), + ("~/src", "/home/me/src"), + ("~nosuchuser/src", "~nosuchuser/src"), + ("a~", "a~"), + ("$PROJECT/x", "tmux/x"), + ("${PROJECT}x", "tmuxx"), + ("$PROJECTx", "$PROJECTx"), + ("$UNSET and ${UNSET}", "$UNSET and ${UNSET}"), + ("$ ${ ${} $-", "$ ${ ${} $-"), + ("~/$PROJECT", "/home/me/tmux"), + ("price: $5", "price: $5"), + ] { + assert_eq!(expand(text).as_deref(), Ok(expected), "{text}"); + } + + let root = |text| expand_with(text, |_| Some("/".into())); + assert_eq!(root("~").as_deref(), Ok("/")); + assert_eq!(root("~/x").as_deref(), Ok("/x")); + assert!(expand_with("~", |_| None).is_err(), "no HOME, no `~`"); + } +} diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index 9ce2f80e..a96e3157 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -1115,6 +1115,183 @@ windows: guard.shutdown().await.expect("tmux fixture shuts down"); } +/// tmuxp expands `~` and variables in names, directories and values, joins a +/// window's relative directory onto the session's, and starts a `.` path +/// from the directory it inherits. +#[test] +fn start_directories_and_names_expand_as_tmuxp_does() { + let home = std::env::var("HOME").expect("tests run with HOME set"); + let home_path = std::path::PathBuf::from(&home); + let current = std::env::current_dir().expect("a current directory"); + let workspace = Workspace::from_yaml( + " +session_name: dirs-${HOME} +start_directory: ~/code +environment: + WHERE: $HOME/x +windows: + - window_name: w $TMUX_WORKSPACE_UNSET_VARIABLE + start_directory: ${HOME} + - start_directory: src + panes: + - start_directory: ./tests + - start_directory: tests + - echo $HOME +", + ) + .expect("configuration parses"); + + assert_eq!(workspace.session_name, format!("dirs-{home}")); + assert_eq!(workspace.start_directory, Some(home_path.join("code"))); + assert_eq!( + workspace.environment, + [("WHERE".to_owned(), format!("{home}/x"))] + ); + let windows = &workspace.windows; + assert_eq!( + windows[0].window_name.as_deref(), + Some("w $TMUX_WORKSPACE_UNSET_VARIABLE"), + "an unset variable stays as written", + ); + assert_eq!(windows[0].start_directory, Some(home_path.clone())); + assert_eq!( + windows[1].start_directory, + Some(home_path.join("code/src")), + "a window's relative directory joins the session's", + ); + let panes = &windows[1].panes; + assert_eq!( + panes[0].start_directory, + Some(home_path.join("code/src/tests")), + "a `.` path starts from the directory it inherits", + ); + assert_eq!( + panes[1].start_directory, + Some(current.join("tests")), + "tmuxp leaves a pane's other relative path to tmux, which starts from here", + ); + assert_eq!( + panes[2].shell_commands, + [ShellCommand::new("echo $HOME")], + "a command is the pane shell's to expand", + ); + + let top = Workspace::from_yaml("session_name: s\nstart_directory: ./\n") + .expect("configuration parses"); + assert_eq!(top.start_directory, Some(current)); + + let message = Workspace::from_yaml("session_name: s\nstart_directory: ~root/x\n") + .expect_err("`~name` is refused") + .to_string(); + assert!( + message.contains("start_directory starts with `~name`"), + "{message}" + ); +} + +/// The first pane of each window starts where the file says. +async fn first_pane_directories(session: &libtmux::Session) -> Vec { + let mut directories = Vec::new(); + for window in session.windows().await.expect("windows list") { + let panes = window.panes().await.expect("panes list"); + directories.push(text_optional(panes[0].current_path())); + } + directories +} + +fn canonical(path: impl AsRef) -> String { + path.as_ref() + .canonicalize() + .expect("the directory exists") + .display() + .to_string() +} + +#[tokio::test] +async fn a_dot_start_directory_is_relative_to_the_file_it_is_in() { + let root = tempfile::tempdir().expect("temporary directory"); + std::fs::create_dir_all(root.path().join("nested/deeper")).expect("nested directories"); + let file = root.path().join("workspace.yaml"); + std::fs::write( + &file, + " +session_name: relative +start_directory: ./ +windows: + - window_name: file + - window_name: joined + start_directory: nested + - window_name: dotted + start_directory: ./nested + panes: + - start_directory: ./deeper +", + ) + .expect("the file is written"); + + // The tests run from the crate's directory, which has no `nested`, so a + // path resolved from there lands tmux in its fallback directory. + let workspace = Workspace::from_file(&file).expect("the file parses"); + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = WorkspaceBuilder::new(guard.server()) + .build(&workspace) + .await + .expect("workspace builds"); + + assert_eq!( + first_pane_directories(&session).await, + [ + canonical(root.path()), + canonical(root.path().join("nested")), + canonical(root.path().join("nested/deeper")), + ], + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// tmuxp's own start-directory example, which its window names describe. +/// +/// The last window is named for the file's directory, and tmuxp's loader +/// puts it in the session's: a `.` path starts from the directory it would +/// otherwise inherit. This follows the loader. +#[tokio::test] +async fn tmuxp_start_directory_example_builds_where_tmuxp_does() { + let file = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tmuxp/start-directory.yaml"); + let workspace = Workspace::from_file(&file).expect("tmuxp's example parses"); + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = WorkspaceBuilder::new(guard.server()) + .build(&workspace) + .await + .expect("tmuxp's example builds"); + + let home = std::env::var("HOME").expect("tests run with HOME set"); + assert_eq!( + first_pane_directories(&session).await, + [ + canonical("/var"), + canonical("/var/log"), + canonical(home), + canonical("/bin"), + canonical("/var"), + ], + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[test] +fn a_missing_file_is_named() { + let error = Workspace::from_file("/nonexistent/tmux-workspace.yaml") + .expect_err("there is no such file"); + assert!(matches!(error, ConfigError::Read { .. }), "{error:?}"); + assert_eq!( + error.to_string(), + "cannot read workspace file /nonexistent/tmux-workspace.yaml", + ); +} + /// A file is fixed in an editor, so an error names the line to go to. #[test] fn an_error_names_the_line_and_column_to_fix() { From 6718bf025b486b5704dbbd4e5e940fca610b98f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 18 Sep 2026 23:58:52 -0500 Subject: [PATCH 070/117] Workspace(fix[freeze]): Freeze a pane at its prompt as no command why: `freeze` recorded `#{pane_current_command}` for every pane, and a pane at its prompt reports its shell, so building the frozen file typed `zsh` into a new zsh. tmuxp means to drop shells; its check looks for a leading `-`, which tmux strips before reporting the name, so it drops none on a current tmux. The round-trip test used only `sleep` panes and could not see it. what: - Read the session's effective `default-shell` through a format, and treat a pane running it, or a common interactive shell by name, as a pane with no command. This is the logic the workspace-cli branch carries, so the two meet as one - The round-trip test holds an idle pane and waits for its `sleep` panes to run before freezing --- crates/tmux-workspace/src/freeze.rs | 36 +++++++++++++++++++++------- crates/tmux-workspace/tests/build.rs | 27 ++++++++++++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/tmux-workspace/src/freeze.rs b/crates/tmux-workspace/src/freeze.rs index d7671241..732b7e2b 100644 --- a/crates/tmux-workspace/src/freeze.rs +++ b/crates/tmux-workspace/src/freeze.rs @@ -21,10 +21,11 @@ use crate::config::{PaneConfig, ShellCommand, WindowConfig, Workspace}; /// [`Workspace::to_yaml`] or handed straight back to /// [`crate::WorkspaceBuilder`] to make another like it. /// -/// A pane is described by the command tmux says it is running. For a pane -/// sitting at a shell that is the shell itself, which would restart as an -/// empty pane -- correct, and rarely what was meant. Panes running something -/// are the ones worth freezing. +/// A pane is described by the command tmux says it is running, by name and +/// without its arguments. A pane at its shell's prompt freezes to a pane +/// with no command: tmux reports the shell itself, and recording it would +/// start a shell inside a shell when the file is built. A shell is the +/// session's `default-shell`, or a common interactive shell by name. /// /// # Errors /// @@ -61,16 +62,24 @@ use crate::config::{PaneConfig, ShellCommand, WindowConfig, Workspace}; /// # } /// ``` pub async fn freeze(session: &Session) -> Result { + // A format, not `Session::get_option`: that answers only an override set + // on this session, and `default-shell` is rarely set there. + let default_shell = session.format("#{default-shell}").await?; + let default_shell = basename(&default_shell.to_string_lossy()).to_owned(); let mut windows = Vec::new(); for window in session.windows().await? { let mut panes = Vec::new(); for pane in window.panes().await? { + let command = pane + .current_command() + .map(|command| command.to_string_lossy().into_owned()) + .filter(|command| { + let name = basename(command); + name != default_shell && !SHELLS.contains(&name) + }); panes.push(PaneConfig { - shell_commands: pane - .current_command() - .map(|command| vec![ShellCommand::new(command.to_string_lossy())]) - .unwrap_or_default(), + shell_commands: command.map(ShellCommand::new).into_iter().collect(), start_directory: pane .current_path() .map(|path| path.to_string_lossy().into_owned().into()), @@ -109,3 +118,14 @@ pub async fn freeze(session: &Session) -> Result { unsupported_keys: Vec::new(), }) } + +/// The last path component, which is how tmux reports a pane's command. +fn basename(text: &str) -> &str { + text.rsplit('/').next().unwrap_or(text) +} + +/// Shells common enough that a pane running one is at its prompt, whatever +/// `default-shell` is: on macOS `/bin/sh` runs as `bash`. +const SHELLS: &[&str] = &[ + "sh", "bash", "zsh", "dash", "ash", "ksh", "mksh", "fish", "csh", "tcsh", +]; diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index a96e3157..58fa67e3 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -657,7 +657,7 @@ session_name: original windows: - window_name: editor panes: - - sleep 400 + - blank - sleep 401 - window_name: logs panes: @@ -671,10 +671,35 @@ windows: .await .expect("the workspace builds"); + // Typed commands start once each shell reads them. + let settled = libtmux::test::retry_until(std::time::Duration::from_secs(30), async || { + let panes = built.panes().await.unwrap_or_default(); + panes.len() == 3 + && panes.iter().skip(1).all(|pane| { + pane.current_command() + .is_some_and(|command| command.to_string_lossy() == "sleep") + }) + }) + .await; + assert!(settled.is_ok(), "the typed commands are running"); + let frozen = tmux_workspace::freeze(&built) .await .expect("the session freezes"); + // A pane at its prompt freezes to no command: recording the shell would + // start a shell inside it on the way back. A pane running something + // freezes to that command's name. + assert!( + frozen.windows[0].panes[0].shell_commands.is_empty(), + "{:?}", + frozen.windows[0].panes[0].shell_commands, + ); + assert_eq!( + frozen.windows[0].panes[1].shell_commands, + [ShellCommand::new("sleep")] + ); + assert_eq!(frozen.session_name, "original"); assert_eq!(frozen.windows.len(), 2); assert_eq!( From 67493f28b017f9e86176b3c9606ffe90392f6815 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 00:06:05 -0500 Subject: [PATCH 071/117] Workspace(docs[readme]): Say it is a library and where it meets tmuxp why: The README opened on "Build tmux workspaces from tmuxp-style YAML", and that there is no command to run came 138 lines in, under why the crate exists. A tmuxp user reading it could not tell which of tmuxp's behaviours this follows, which it declines, or which keys it ignores. what: - Open with: a library, no command; five tmuxp keys ignored - A "Reading a tmuxp file" section: the surprising tmuxp behaviours followed, the ones declined and why, and the unsupported keys - The build example points at `Workspace::from_file` The command itself is on the workspace-cli branch; this adds none. --- crates/tmux-workspace/README.md | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/tmux-workspace/README.md b/crates/tmux-workspace/README.md index 2d782727..81865ffa 100644 --- a/crates/tmux-workspace/README.md +++ b/crates/tmux-workspace/README.md @@ -3,6 +3,11 @@ Build tmux workspaces from [tmuxp](https://tmuxp.git-pull.com/)-style YAML, using [libtmux](https://docs.rs/libtmux). +It is a library, with no command to run: a program reads the file and builds +it. It reads tmuxp's own example files, and ignores five of tmuxp's keys; +[Reading a tmuxp file](#reading-a-tmuxp-file) names them and says where the +two disagree. + > **Alpha.** The API changes between releases, including in ways that will not > be called out as breaking, because nothing here is stable yet. Cargo will not > resolve a prerelease unless the requirement names one, so a plain `0.1` @@ -59,8 +64,9 @@ use tmux_workspace::{Workspace, WorkspaceBuilder}; #[tokio::main] async fn main() -> Result<(), Box> { // Runs for real, against an isolated tmux under `/tmp/libtmux-rs-test/`. - // Your own code reads the file and uses `libtmux::Server::new()?`: - // let source = std::fs::read_to_string("dev.yaml")?; + // Your own code reads the file, so a `./` start directory is the file's, + // and uses `libtmux::Server::new()?`: + // let workspace = Workspace::from_file("dev.yaml")?; let source = " session_name: dev windows: @@ -119,6 +125,49 @@ windows: } ``` +## Reading a tmuxp file + +A file written for tmuxp builds the session tmuxp would build, including +where tmuxp's behaviour is surprising: + +- A lone `pane`, `blank` or empty `-` is a pane with no command. Among other + commands the word is typed. +- Commands are typed after a space, which keeps them out of history in a + shell set to ignore such lines, unless `suppress_history: false`. The space + reaches whatever the pane runs, a program as much as a shell. +- `enter: false` on a command holds for the commands after it in that pane, + so the next one is typed onto the same line. A pane's `enter: false` covers + its `shell_command_before` commands, which are typed first. +- `~` and `$NAME` or `${NAME}` expand from the loading process's environment + in names, start directories, and `environment` and option values. An unset + variable stays as written, and there is no escape: a frozen name holding + `$HOME` reads back as the home directory. +- A window's relative `start_directory` joins the session's. One starting + with `.` starts from the directory it would inherit, else from the file's. + tmuxp's `start-directory.yaml` names a window for the file's directory that + tmuxp's loader, and this crate, put in the session's. A pane's other + relative path is not joined to its window's: it starts from the current + directory, as tmux would start it. + +It differs where following tmuxp would be unsafe or impossible: + +- Commands are typed as written, for the pane's shell to expand. tmuxp + expands variables in them first, which reads a variable's value as shell + code. +- `sleep_before` and `sleep_after` are read and kept, and not waited for: a + `libtmux` plan cannot pause. tmux holds typed input until the pane reads it. +- `~name` in a start directory is refused, not looked up; elsewhere it stays + as written. +- A `.` path with nothing to inherit, and a null among commands, crash + tmuxp. Here the first starts from the file's directory and the second is + an error naming its line. +- `window_shell` starts the window's first pane only, and a pane's + `environment` adds to its window's rather than replacing it. + +Five of tmuxp's keys are not acted on, and are listed in `unsupported_keys` +along with any key tmuxp does not have: `before_script`, `plugins`, +`options_after`, and a pane's `shell` and `shell_command_before`. + ## Install ```console From 70840308dfc0a10741bbc0ddf623e6d6e8df3e29 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 00:21:09 -0500 Subject: [PATCH 072/117] Docs(fix[cancel]): Say what a dropped call leaves why: Cancellation prose was scattered through rustdoc in no fixed place, and three of its claims were wrong when measured through libtmux on tmux 3.7d. A wait_for_channel that is dropped or times out leaves tmux a waiter with no client, which eats the channel's next signal. A with_channel_lock scope dropped while queued does not wedge the channel, because its lock runs in a task of its own; a lock still queued at the dispatch deadline does, for it and for lock_channel. set_hooks sends up to two invocations, not one, so a drop between them can leave a hook cleared. run_shell's shell command keeps running after the call is dropped. The README's model now says the signal reaches the client and not the server, so a dropped single mutation may or may not have happened. That covers every one-dispatch method, including mutate-then-refresh pairs whose second half only reads, so they carry no section. what: - Move each cancellation paragraph under a # Cancel safety heading that names which of the three outcomes it is - Add sections where a drop leaves something to know: partial effects (set_hooks, Plan::run, watch_only), work that outlives the call (run_shell), held tmux state (wait_for_channel), and the stream and wait reads a caller races in select! - Correct lock_channel, with_channel_lock and the wait_for_channel source comment to match what was measured - Add tests/cancel_safety.rs: a named list of methods that must carry the section, failing on a lost section, a missing method, or a section on a method the list does not name --- crates/libtmux/README.md | 6 +- crates/libtmux/src/control.rs | 62 +++++++-- crates/libtmux/src/pane/observe.rs | 19 +++ crates/libtmux/src/plan/run.rs | 11 ++ crates/libtmux/src/server.rs | 21 ++- crates/libtmux/src/server/channels.rs | 42 +++--- crates/libtmux/src/server/settings.rs | 15 +- crates/libtmux/src/session.rs | 15 +- crates/libtmux/src/session/settings.rs | 15 +- crates/libtmux/src/test.rs | 23 ++-- crates/libtmux/src/window.rs | 15 +- crates/libtmux/src/window/settings.rs | 15 +- crates/libtmux/tests/cancel_safety.rs | 181 +++++++++++++++++++++++++ 13 files changed, 363 insertions(+), 77 deletions(-) create mode 100644 crates/libtmux/tests/cancel_safety.rs diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 880afc4e..097c1410 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -562,6 +562,9 @@ deadline, 30 seconds by default and configurable through for dispatch capacity. Dropping the command future, reaching its timeout, or shutting the server down signals the group and waits for the direct child while the runtime is alive. +The signal reaches the tmux client, not the server: a command the server has +already received still runs, so a dropped mutation may or may not have +happened, and a retry can repeat it. A control-mode connection has its own isolated process group. Its attach handshake and each command response use the same default timeout; a response's @@ -581,7 +584,8 @@ async fn main() -> Result<(), Box> { let server = guard.server(); // Ends at 400ms rather than the server's 30-second default, and the - // command's process group is signalled and reaped on the way out. + // client's process group is signalled and reaped on the way out. The + // `sleep` runs under the tmux server, so it finishes regardless. let bounded = tokio::time::timeout( Duration::from_millis(400), server.run_shell("sleep 3"), diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index fe132535..e745290b 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -477,6 +477,11 @@ impl ControlMode { /// Returns an error when tmux cannot be started, does not give the crate /// the pipes it asked for, exits before attaching, or does not finish its /// opening block before the server deadline. + /// + /// # Cancel safety + /// + /// Nothing is left held: a dropped attach kills and reaps the tmux client + /// it started. pub async fn attach(server: &Server, session: &SessionId) -> Result { Self::attach_with_limits(server, session, ControlLimits::default()).await } @@ -492,6 +497,10 @@ impl ControlMode { /// Returns an error when the connection cannot be opened, as /// [`Self::attach`] does. [`Server::shutdown`] cancels an attach in /// progress and refuses later attempts. + /// + /// # Cancel safety + /// + /// Nothing is left held, as for [`Self::attach`]. pub async fn attach_with_limits( server: &Server, session: &SessionId, @@ -563,15 +572,23 @@ impl ControlMode { /// /// Returns an error when the command cannot be written as a control-mode /// line, the connection has closed, or its deadline elapses while queued, - /// being written, or awaiting a response. Cancellation has the same write - /// boundary as [`ControlSender::send`]. + /// being written, or awaiting a response. + /// + /// # Cancel safety + /// + /// As for [`ControlSender::send`]: a command dropped while queued is never + /// written, and one already committed may run. pub async fn send(&self, command: Command) -> Result { self.sender.send(command).await } /// Return the next notification or terminal error, then `None`. /// - /// See [`ControlEvents::next_event`] for termination and cancellation. + /// See [`ControlEvents::next_event`] for termination. + /// + /// # Cancel safety + /// + /// Nothing happened: a dropped call consumes no event. pub async fn next_event(&mut self) -> Option> { self.events.next_event().await } @@ -701,15 +718,17 @@ impl ControlSender { /// [`crate::Server::cmd`], where the wait costs one process rather than /// the connection everything else on it is sharing. /// - /// Dropping this future while it is queued prevents the command from being - /// written. Once the connection commits it for writing, tmux may execute - /// it; its reply position stays reserved so later replies remain aligned. - /// /// # Errors /// /// Returns an error when the command cannot be written as a control-mode /// line, the connection has closed, or its deadline elapses while queued, /// being written, or awaiting a response. + /// + /// # Cancel safety + /// + /// A command dropped while queued is never written. Once the connection has + /// committed it, tmux may run it, and its reply is still read and + /// discarded, so later replies stay aligned. pub async fn send(&self, command: Command) -> Result { self.send_ordered(command, None).await } @@ -1042,6 +1061,12 @@ impl ControlSender { /// Returns an error when the connection has closed, tmux would not list a /// pane, returned an unreadable pane ID, or a later mute fails. A failure /// after an accepted mute is [`Error::AfterEffect`]. + /// + /// # Cancel safety + /// + /// The effect can be partial: panes are muted one at a time, so a dropped + /// call can leave some muted and others not. Muting is idempotent, so + /// calling again finishes the job. pub async fn watch_only(&self, panes: &[PaneId]) -> Result<(), Error> { let listed = self .send( @@ -1172,9 +1197,8 @@ impl ControlEvents { /// Return the next notification or terminal error, then `None`. /// - /// Cancelling a pending call consumes neither an event nor a terminal - /// diagnostic. Once `None` is returned, subsequent calls also return - /// `None`. See [`ControlEvents`] for the EOF and cleanup contract. + /// Once `None` is returned, subsequent calls also return `None`. See + /// [`ControlEvents`] for the EOF and cleanup contract. /// /// # Errors /// @@ -1182,6 +1206,11 @@ impl ControlEvents { /// frame budget or command deadline being exceeded, executor shutdown, /// or failed connection cleanup. A panic in the connection task resumes /// here instead, since nothing else would report it. + /// + /// # Cancel safety + /// + /// Nothing happened: a dropped call consumes neither an event nor the + /// terminal error. pub async fn next_event(&mut self) -> Option> { poll_fn(|context| Pin::new(&mut *self).poll_next(context)).await } @@ -1377,9 +1406,7 @@ impl PaneOutput { /// retain those chunks. It runs synchronously and should return promptly. /// /// Each chunk passed to `on_output` is consumed from this stream and is - /// not repeated by [`Self::next_chunk`]. That remains true when this - /// future is cancelled or returns an error: caller-owned storage keeps - /// the prefix it already accepted. + /// not repeated by [`Self::next_chunk`], even when this returns an error. /// /// The visible screen and preceding output may overlap: the screen is /// tmux's rendered grid, while the callback receives the raw terminal @@ -1404,6 +1431,11 @@ impl PaneOutput { /// /// Returns an error when the connection closes, the command deadline /// elapses, or tmux refuses the capture, including when the pane vanished. + /// + /// # Cancel safety + /// + /// Partly happened: chunks already passed to `on_output` stay consumed, so + /// what the callback kept is the only copy. The stream stays usable. pub async fn snapshot( &mut self, mut on_output: impl FnMut(&[u8]), @@ -1470,6 +1502,10 @@ impl PaneOutput { /// /// A chunk is what tmux chose to report at once, which is not a line and /// not a fixed size. Callers wanting lines should buffer. + /// + /// # Cancel safety + /// + /// Nothing happened: a dropped call consumes no chunk. pub async fn next_chunk(&mut self) -> Option> { poll_fn(|context| Pin::new(&mut *self).poll_next(context)).await } diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index 3e4d2e87..2be928e9 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -39,6 +39,11 @@ impl Pane { /// error when tmux could not be read, or an error when the control-mode /// connection cannot be opened. /// + /// # Cancel safety + /// + /// Nothing is left held: a dropped call closes the connection it opened, + /// and the mutes it set end with that connection. + /// /// # Examples /// /// ```no_run @@ -68,6 +73,10 @@ impl Pane { /// # Errors /// /// Returns the same errors as [`Self::stream_output`]. + /// + /// # Cancel safety + /// + /// Nothing is left held, as for [`Self::stream_output`]. #[cfg(feature = "control-mode")] pub async fn stream_output_with_limits( &self, @@ -261,6 +270,11 @@ impl Pane { /// Returns an error when tmux cannot be reached or refuses a capture. /// Running out of time is [`PaneWait::TimedOut`], not an error. /// + /// # Cancel safety + /// + /// Nothing happened. A look only reads, so output a dropped wait missed + /// stays in the scrollback, up to `history-limit`, for the next wait. + /// /// # Examples /// /// ``` @@ -307,6 +321,11 @@ impl Pane { /// Returns an error when tmux cannot be reached or refuses a capture. /// Running out of time is [`PaneWait::TimedOut`], not an error. /// + /// # Cancel safety + /// + /// Nothing happened: a look only reads. A retry measures quiet afresh, + /// from its own first look. + /// /// # Examples /// /// ``` diff --git a/crates/libtmux/src/plan/run.rs b/crates/libtmux/src/plan/run.rs index 1dbd5bc5..54789537 100644 --- a/crates/libtmux/src/plan/run.rs +++ b/crates/libtmux/src/plan/run.rs @@ -296,6 +296,12 @@ impl Plan { /// not return valid IDs. Validation happens before the first command. A /// command tmux *refuses* is reported through the returned [`PlanResult`], /// not as an error, because a plan may expect one. + /// + /// # Cancel safety + /// + /// The effect can be partial: steps already dispatched stay done, and the + /// [`PlanResult`] naming them, with the ids of what they created, is lost + /// with the future. A retry runs the whole plan again. pub async fn run(&self, server: &Server, planner: Planner) -> Result { self.validate() .map_err(|source| Error::InvalidPlan { source })?; @@ -727,6 +733,11 @@ impl Plan { /// `select-layout` guard: that check needs a version probe, and this /// connection carries no [`Server`] to run one against. A layout value /// this cannot parse still reaches tmux directly. + /// + /// # Cancel safety + /// + /// The effect can be partial, as for [`Self::run`]: steps already sent + /// stay done, and the result naming them is lost with the future. pub async fn run_over_control_mode( &self, sender: &crate::control::ControlSender, diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 7cca3c70..04fc73ef 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1412,21 +1412,22 @@ impl Server { /// Create a session, run an operation with it, then kill it. /// - /// Once this future is polled, the scope owns creation and cleanup. - /// Cancellation or unwinding can let an in-flight creation finish, but a - /// session whose creation yields a handle is killed while the Tokio - /// runtime remains active. Ordinary handle `Drop` remains non-destructive. - /// /// [`crate::ScopeError`] retains creation, operation and cleanup failures /// separately. If operation and cleanup both fail, both original errors /// are returned. Cleanup errors carry [`Error::AfterEffect`] because - /// creation succeeded. A canceled caller cannot receive a cleanup error; - /// the `tracing` feature records that failure while the runtime is active. + /// creation succeeded. /// /// # Errors /// /// Returns [`crate::ScopeError`] when creation, the operation, or cleanup /// fails. The operation's error needs no conversion into [`Error`]. + /// + /// # Cancel safety + /// + /// Nothing is left behind. Once polled, creation and cleanup run in tasks + /// of their own, so the session is killed even if this future is dropped + /// or the operation panics, while the Tokio runtime is alive. A cleanup + /// failure then has no caller to reach; the `tracing` feature records it. pub async fn with_session( &self, options: impl Into, @@ -1458,6 +1459,12 @@ impl Server { /// zero, so the command appears to have produced nothing. Reporting an /// empty listing there would be a wrong answer the caller could not /// detect. 3.2a and 3.5 onwards are unaffected. + /// + /// # Cancel safety + /// + /// The effect may have happened, and may still be happening. The shell + /// command runs under the tmux server, not this dispatch, so a dropped + /// call stops waiting for it without stopping it, and its output is lost. pub async fn run_shell(&self, command: impl Into) -> Result, Error> { self.refuse_if_defective( "run-shell output", diff --git a/crates/libtmux/src/server/channels.rs b/crates/libtmux/src/server/channels.rs index 6369c1ca..59821952 100644 --- a/crates/libtmux/src/server/channels.rs +++ b/crates/libtmux/src/server/channels.rs @@ -41,18 +41,19 @@ impl Server { /// locker that returns early, fails, or panics still releases the /// channel: a lock left held wedges every later locker on the server. /// - /// This does not cover the other way a channel wedges. Dropping a - /// *pending* lock while it is queued behind another locker leaves tmux - /// with a queue entry it will hand the lock to and nobody to take it; - /// that is a tmux defect (`cmd-wait-for.c`) and no scope can reach it. - /// See [`Self::lock_channel`]. - /// /// # Errors /// /// [`crate::ScopeError::Creation`] when tmux refuses the lock, /// `Operation` when the body fails, and `Cleanup` when the unlock fails /// after the body succeeded. /// + /// # Cancel safety + /// + /// Nothing is left held by a drop. The lock is taken and released in tasks + /// of their own, so a scope dropped while its lock is still queued unlocks + /// once tmux grants it. A lock still queued when the server's default + /// timeout elapses wedges the channel as [`Self::lock_channel`] describes. + /// /// # Examples /// /// ``` @@ -108,16 +109,11 @@ impl Server { /// /// # Cancel safety /// - /// Not cancel safe: dropping this future can leave something held, and - /// no one can release it. While the call is queued behind another locker, - /// tmux holds a queue entry for it. If that locker's process ends without - /// calling [`Self::unlock_channel`], `cmd_wait_for_unlock` hands the - /// released lock to the next queued entry with no way to skip one whose - /// client has gone, and every later call here for the same channel blocks - /// forever. That is a tmux defect (`cmd-wait-for.c`), measured on 3.2a, - /// 3.7c and master, and no scope in this crate reaches it. The - /// non-locking [`Self::wait_for_channel`] is safe to drop: it is a latch - /// check, not a queue. + /// Something is left held, and no one can release it. Dropped, or timed + /// out, while queued behind another locker, this leaves tmux a queue + /// entry with no client behind it; `cmd_wait_for_unlock` hands the lock + /// to that entry, and every later lock on the channel blocks forever. A + /// tmux defect (`cmd-wait-for.c`), measured on 3.2a, 3.7c and master. pub async fn lock_channel(&self, channel: &str) -> Result<(), Error> { listing::mutate( &self.core, @@ -184,6 +180,14 @@ impl Server { /// an error, so "nothing signalled it" stays distinct from "the command /// did not get through" -- the caller retries only one of those. /// + /// # Cancel safety + /// + /// Something is left held. A wait that is dropped, or that returns + /// [`ChannelWait::TimedOut`], leaves tmux a waiter with no client behind + /// it, and the channel's next signal releases that waiter instead of + /// latching, so a wait begun after the signal misses it. Measured on 3.7d; + /// see `cmd_wait_for_signal` in `cmd-wait-for.c`. + /// /// # Examples /// /// ``` @@ -236,10 +240,8 @@ impl Server { Ok(ChannelWait::TimedOut) } Ok(Err(error)) => Err(error), - // Dropping the dispatch kills the waiting tmux client; the - // channel stays usable, measured by killing a waiter and - // signalling it after. True only for this idempotent-latch - // form -- `Self::lock_channel`'s queue has no such guarantee. + // Dropping the dispatch kills the waiting client but leaves its + // entry in tmux's waiter list: see the cancel-safety section. Err(_elapsed) => Ok(ChannelWait::TimedOut), } } diff --git a/crates/libtmux/src/server/settings.rs b/crates/libtmux/src/server/settings.rs index aa2d9222..5b08946c 100644 --- a/crates/libtmux/src/server/settings.rs +++ b/crates/libtmux/src/server/settings.rs @@ -483,15 +483,22 @@ impl Server { /// written remains; [`ReplaceMode::Merge`] leaves entries at indices the /// write does not name. /// - /// Sent as one tmux invocation rather than one per index. That costs one - /// process instead of several, but it is not atomic: tmux applies a - /// shared invocation in order and stops at the first refusal, so a - /// rejected entry leaves the ones before it written. + /// Sent as at most two tmux invocations rather than one per index: the + /// first command alone, then the rest together. That is not atomic: tmux + /// applies a shared invocation in order and stops at the first refusal, + /// so a rejected entry leaves the ones before it written. /// /// # Errors /// /// Returns an error when tmux rejects the name or any command. /// + /// # Cancel safety + /// + /// The effect can be partial: dropped between the two invocations, the + /// hook is left cleared under [`ReplaceMode::Replace`], or with only the + /// first of its new entries written under [`ReplaceMode::Merge`]. Calling + /// again with the same arguments finishes it. + /// /// # Examples /// /// ``` diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index b4c2b13d..c9be290c 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -746,21 +746,22 @@ impl Session { /// Create a window, run an operation with it, then kill it. /// - /// Once this future is polled, the scope owns creation and cleanup. - /// Cancellation or unwinding can let an in-flight creation finish, but a - /// window whose creation yields a handle is killed while the Tokio runtime - /// remains active. Ordinary handle `Drop` remains non-destructive. - /// /// [`crate::ScopeError`] retains creation, operation and cleanup failures /// separately. If operation and cleanup both fail, both original errors /// are returned. Cleanup errors carry [`Error::AfterEffect`] because - /// creation succeeded. A canceled caller cannot receive a cleanup error; - /// the `tracing` feature records that failure while the runtime is active. + /// creation succeeded. /// /// # Errors /// /// Returns [`crate::ScopeError`] when creation, the operation, or cleanup /// fails. The operation's error needs no conversion into [`Error`]. + /// + /// # Cancel safety + /// + /// Nothing is left behind. Once polled, creation and cleanup run in tasks + /// of their own, so the window is killed even if this future is dropped + /// or the operation panics, while the Tokio runtime is alive. A cleanup + /// failure then has no caller to reach; the `tracing` feature records it. pub async fn with_window( &self, options: impl Into, diff --git a/crates/libtmux/src/session/settings.rs b/crates/libtmux/src/session/settings.rs index 99ebda5a..bad50bd1 100644 --- a/crates/libtmux/src/session/settings.rs +++ b/crates/libtmux/src/session/settings.rs @@ -220,15 +220,22 @@ impl Session { /// written remains; [`ReplaceMode::Merge`] leaves entries at indices the /// write does not name. /// - /// Sent as one tmux invocation rather than one per index. That costs one - /// process instead of several, but it is not atomic: tmux applies a - /// shared invocation in order and stops at the first refusal, so a - /// rejected entry leaves the ones before it written. + /// Sent as at most two tmux invocations rather than one per index: the + /// first command alone, then the rest together. That is not atomic: tmux + /// applies a shared invocation in order and stops at the first refusal, + /// so a rejected entry leaves the ones before it written. /// /// # Errors /// /// Returns an error when tmux rejects the name or any command. /// + /// # Cancel safety + /// + /// The effect can be partial: dropped between the two invocations, the + /// hook is left cleared under [`ReplaceMode::Replace`], or with only the + /// first of its new entries written under [`ReplaceMode::Merge`]. Calling + /// again with the same arguments finishes it. + /// /// # Examples /// /// ``` diff --git a/crates/libtmux/src/test.rs b/crates/libtmux/src/test.rs index bfc69a49..3471d249 100644 --- a/crates/libtmux/src/test.rs +++ b/crates/libtmux/src/test.rs @@ -423,10 +423,7 @@ impl TestServerBuilder { /// or rollback cleanup cannot be completed safely. /// /// Startup creates a private explicit socket and owned empty config without - /// creating an initial session. Cancelling this future before ownership - /// transfers to the returned guard triggers synchronous forced best-effort - /// rollback, whose cleanup failures cannot be reported to the cancelled - /// caller. + /// creating an initial session. /// /// ``` /// # fn main() -> Result<(), Box> { @@ -438,6 +435,12 @@ impl TestServerBuilder { /// # }) /// # } /// ``` + /// + /// # Cancel safety + /// + /// Nothing is left behind, on a best-effort basis: a start dropped before + /// it returns the guard forces its daemon down and removes its files at + /// once, and a failure doing so goes unreported. pub async fn start(self) -> Result { self.start_with_leader_observer(leader_exited_unreaped, platform_fallback_grace_ceiling()) .await @@ -854,12 +857,6 @@ impl TestServer { /// terminates the retained foreground daemon. Escaped [`Server`] clones /// cannot issue later commands. /// - /// Cancelling this future before lifecycle ownership transfers leaves the - /// guard responsible for synchronous forced best-effort cleanup. Once this - /// future transfers ownership to its blocking waiter, that waiter completes - /// cleanup even if this future is cancelled; later cleanup failures are - /// unobservable to the cancelled caller. - /// /// ``` /// # fn main() -> Result<(), Box> { /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; @@ -870,6 +867,12 @@ impl TestServer { /// # }) /// # } /// ``` + /// + /// # Cancel safety + /// + /// Nothing is left behind, on a best-effort basis: dropped early, the + /// guard's own forced cleanup runs; dropped once cleanup has started, a + /// blocking task finishes it. Either way a failure goes unreported. pub async fn shutdown(mut self) -> Result<(), TestServerError> { let executor_failed = self.server.shutdown().await.is_err(); let Some(mut lifecycle) = self.lifecycle.take() else { diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 4431ffba..e2f98e0f 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -836,21 +836,22 @@ impl Window { /// Create a pane, run an operation with it, then kill it. /// - /// Once this future is polled, the scope owns creation and cleanup. - /// Cancellation or unwinding can let an in-flight creation finish, but a - /// pane whose creation yields a handle is killed while the Tokio runtime - /// remains active. Ordinary handle `Drop` remains non-destructive. - /// /// [`crate::ScopeError`] retains creation, operation and cleanup failures /// separately. If operation and cleanup both fail, both original errors /// are returned. Cleanup errors carry [`Error::AfterEffect`] because - /// creation succeeded. A canceled caller cannot receive a cleanup error; - /// the `tracing` feature records that failure while the runtime is active. + /// creation succeeded. /// /// # Errors /// /// Returns [`crate::ScopeError`] when creation, the operation, or cleanup /// fails. The operation's error needs no conversion into [`Error`]. + /// + /// # Cancel safety + /// + /// Nothing is left behind. Once polled, creation and cleanup run in tasks + /// of their own, so the pane is killed even if this future is dropped or + /// the operation panics, while the Tokio runtime is alive. A cleanup + /// failure then has no caller to reach; the `tracing` feature records it. pub async fn with_pane( &self, options: impl Into, diff --git a/crates/libtmux/src/window/settings.rs b/crates/libtmux/src/window/settings.rs index 41fd9d7c..b6b15e3f 100644 --- a/crates/libtmux/src/window/settings.rs +++ b/crates/libtmux/src/window/settings.rs @@ -200,10 +200,10 @@ impl Window { /// written remains; [`ReplaceMode::Merge`] leaves entries at indices the /// write does not name. /// - /// Sent as one tmux invocation rather than one per index. That costs one - /// process instead of several, but it is not atomic: tmux applies a - /// shared invocation in order and stops at the first refusal, so a - /// rejected entry leaves the ones before it written. + /// Sent as at most two tmux invocations rather than one per index: the + /// first command alone, then the rest together. That is not atomic: tmux + /// applies a shared invocation in order and stops at the first refusal, + /// so a rejected entry leaves the ones before it written. /// /// # Errors /// @@ -212,6 +212,13 @@ impl Window { /// Returns [`crate::Error::OptionScopeMismatch`] when tmux keeps the /// option in another of its tables. /// + /// # Cancel safety + /// + /// The effect can be partial: dropped between the two invocations, the + /// hook is left cleared under [`ReplaceMode::Replace`], or with only the + /// first of its new entries written under [`ReplaceMode::Merge`]. Calling + /// again with the same arguments finishes it. + /// /// # Examples /// /// ``` diff --git a/crates/libtmux/tests/cancel_safety.rs b/crates/libtmux/tests/cancel_safety.rs new file mode 100644 index 00000000..e269c9de --- /dev/null +++ b/crates/libtmux/tests/cancel_safety.rs @@ -0,0 +1,181 @@ +//! Methods whose dropped future leaves something to know keep saying so. + +// Helpers outside a test function are not covered by clippy.toml's in-test +// exemptions, and this file has them. +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Every public `async fn` that owes a `# Cancel safety` section, as +/// (file, impl type, method). +/// +/// Each one holds something, can half-happen, keeps running after a drop, or +/// is a wait or stream read a caller races in `select!`. `.github/WRITING.md` +/// says when a method belongs here. +const MUST_CARRY: &[(&str, &str, &str)] = &[ + ("src/control.rs", "ControlEvents", "next_event"), + ("src/control.rs", "ControlMode", "attach"), + ("src/control.rs", "ControlMode", "attach_with_limits"), + ("src/control.rs", "ControlMode", "next_event"), + ("src/control.rs", "ControlMode", "send"), + ("src/control.rs", "ControlSender", "send"), + ("src/control.rs", "ControlSender", "watch_only"), + ("src/control.rs", "PaneOutput", "next_chunk"), + ("src/control.rs", "PaneOutput", "snapshot"), + ("src/pane.rs", "Pane", "send_line"), + ("src/pane/observe.rs", "Pane", "stream_output"), + ("src/pane/observe.rs", "Pane", "stream_output_with_limits"), + ("src/pane/observe.rs", "Pane", "wait_for_quiet"), + ("src/pane/observe.rs", "Pane", "wait_for_text"), + ("src/pane/observe.rs", "Pane", "wait_until"), + ("src/plan/run.rs", "Plan", "run"), + ("src/plan/run.rs", "Plan", "run_over_control_mode"), + ("src/server.rs", "Server", "run_shell"), + ("src/server.rs", "Server", "with_session"), + ("src/server/channels.rs", "Server", "lock_channel"), + ("src/server/channels.rs", "Server", "wait_for_channel"), + ("src/server/channels.rs", "Server", "with_channel_lock"), + ("src/server/settings.rs", "Server", "set_hooks"), + ("src/session.rs", "Session", "with_window"), + ("src/session/settings.rs", "Session", "set_hooks"), + ("src/test.rs", "TestServer", "shutdown"), + ("src/test.rs", "TestServerBuilder", "start"), + ("src/window.rs", "Window", "with_pane"), + ("src/window/settings.rs", "Window", "set_hooks"), +]; + +const HEADING: &str = "/// # Cancel safety"; + +#[test] +fn listed_methods_keep_their_cancel_safety_section() { + let listed: BTreeSet<(String, String, String)> = MUST_CARRY + .iter() + .map(|(file, owner, method)| { + ( + (*file).to_owned(), + (*owner).to_owned(), + (*method).to_owned(), + ) + }) + .collect(); + + let mut found = BTreeSet::new(); + let mut carrying = BTreeSet::new(); + for path in walk(Path::new("src")) { + let file = path.to_string_lossy().replace('\\', "/"); + let source = std::fs::read_to_string(&path).expect("a readable source file"); + for (owner, method, has_section) in public_async_fns(&source) { + let key = (file.clone(), owner, method); + if has_section { + carrying.insert(key.clone()); + } + found.insert(key); + } + } + + let mut problems = Vec::new(); + for key in &listed { + let (file, owner, method) = key; + if !found.contains(key) { + problems.push(format!( + "{file}: `{owner}::{method}` is listed but not found" + )); + } else if !carrying.contains(key) { + problems.push(format!( + "{file}: `{owner}::{method}` lost its `# Cancel safety` section" + )); + } + } + for (file, owner, method) in carrying.difference(&listed) { + problems.push(format!( + "{file}: `{owner}::{method}` has a `# Cancel safety` section but is not in \ + MUST_CARRY, so losing it would go unnoticed" + )); + } + + assert!(problems.is_empty(), "{}", problems.join("\n")); +} + +/// Each `pub async fn` in `source` as (impl type, method, has the heading). +fn public_async_fns(source: &str) -> Vec<(String, String, bool)> { + let mut found = Vec::new(); + let mut owner = String::new(); + let mut in_doc = false; + let mut has_section = false; + let mut in_attribute = false; + + for line in source.lines() { + if line.starts_with("impl ") || line.starts_with("impl<") { + owner = impl_type(line); + } else if line == "}" { + owner.clear(); + } + let code = line.trim_start(); + if in_attribute { + in_attribute = !code.ends_with(']'); + continue; + } + if code.starts_with("///") { + if !in_doc { + has_section = false; + } + in_doc = true; + has_section |= code.trim_end() == HEADING; + continue; + } + if in_doc && code.starts_with("#[") { + in_attribute = !code.ends_with(']'); + continue; + } + if let Some(rest) = code.strip_prefix("pub async fn ") { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + found.push((owner.clone(), name, in_doc && has_section)); + } + in_doc = false; + } + found +} + +/// The type an `impl` line at column zero is for. +fn impl_type(line: &str) -> String { + let mut rest = line.trim_start_matches("impl").trim_start(); + if rest.starts_with('<') { + let mut depth = 0; + let end = rest + .char_indices() + .find(|(_, c)| { + match c { + '<' => depth += 1, + '>' => depth -= 1, + _ => {} + } + depth == 0 + }) + .map_or(rest.len(), |(index, _)| index + 1); + rest = rest[end..].trim_start(); + } + if let Some((_, target)) = rest.split_once(" for ") { + rest = target; + } + rest.chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect() +} + +fn walk(directory: &Path) -> Vec { + let mut found = Vec::new(); + let entries = std::fs::read_dir(directory).expect("a readable directory"); + for entry in entries { + let path = entry.expect("a readable entry").path(); + if path.is_dir() { + found.extend(walk(&path)); + } else if path.extension().is_some_and(|extension| extension == "rs") { + found.push(path); + } + } + found +} From f5b0a361104852304dc98ffc91e84f211cb926e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 00:47:59 -0500 Subject: [PATCH 073/117] tmux-mcp(fix[env]): Withhold environment values why: A tmux server holds the environment of the shell that started it. show_environment, in the default inspect toolset, returned every value in clear -- 110 in one measured call, API keys among them -- and spawned one tmux process per variable. get_tmux_variables returned the same values by name, because tmux expands a name that is neither an option nor a format from the session and server environments. The allowance is the operator's, at startup, not the client's per call: a client that can name a variable can name every secret, so a per-call opt-in would still let a default configuration return one. what: - show_environment reports name, state (set or removed) and withheld; a value only for a name in LIBTMUX_ENVIRONMENT_VALUES - get_tmux_variables refuses a name the consulted environments hold, unless allowed; format and option names are unaffected - Builder::environment_values and parse_environment_values, capped at 32 exact names; a malformed list stops startup - libtmux: environment_all reads show-environment -s in one command. The per-variable fan-out lived there, so the fix does too. Only the -s escaping is undone, so values match environment() by name - README trust boundary, tool descriptions, TOOLS.md, CHANGELOG --- crates/libtmux/docs/design.md | 7 +- crates/libtmux/src/internal/environment.rs | 178 ++++++++++++++++++--- crates/libtmux/src/server/settings.rs | 15 +- crates/libtmux/src/session/settings.rs | 17 +- crates/libtmux/tests/options.rs | 15 ++ crates/tmux-mcp/README.md | 31 +++- crates/tmux-mcp/TOOLS.md | 4 +- crates/tmux-mcp/src/bin/tmux-mcp.rs | 4 +- crates/tmux-mcp/src/lib.rs | 16 +- crates/tmux-mcp/src/manifest.rs | 2 +- crates/tmux-mcp/src/policy.rs | 55 +++++++ crates/tmux-mcp/src/tools/contract.rs | 91 ++++++++++- crates/tmux-mcp/src/tools/inspect.rs | 38 +++-- crates/tmux-mcp/src/views.rs | 18 ++- crates/tmux-mcp/tests/agent.rs | 157 +++++++++++++++++- crates/tmux-mcp/tests/binary.rs | 73 +++++++++ crates/tmux-mcp/tests/protocol.rs | 2 +- 17 files changed, 651 insertions(+), 72 deletions(-) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index f0cc7594..6702647f 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -1288,7 +1288,12 @@ because that is the only place the rules are visible. a `Scope` that is either `-g` or `-t `. The part worth sharing is not the flag but the reading: a value containing a newline occupies more than one line of `show-environment`, and a continuation line holding an `=` cannot be -told from the next variable, so every name is read back on its own. +told from the next variable. `show-environment -s` prints each entry as a +shell statement with every `"`, `\`, `$` and backtick in the value escaped, so +the first unescaped `"` ends a value on every supported release and the whole +environment is one command. Only that escaping is undone: tmux 3.4 and later +also escape bytes for display, `$` among them, in both listings alike, so a +value read whole matches the same value read by name. ### `run-shell` output goes nowhere on tmux 3.3 through 3.4 diff --git a/crates/libtmux/src/internal/environment.rs b/crates/libtmux/src/internal/environment.rs index 05ddbd8f..a6e80fbe 100644 --- a/crates/libtmux/src/internal/environment.rs +++ b/crates/libtmux/src/internal/environment.rs @@ -6,6 +6,8 @@ use std::collections::BTreeMap; use std::ffi::OsString; +use crate::error::ListingDecodeError; +use crate::formats::FormatCodecError; use crate::internal::core::Core; use crate::internal::listing; use crate::{Command, EnvironmentEntry, Error, TmuxText}; @@ -137,19 +139,20 @@ pub(crate) async fn get( )))) } -/// Read the whole environment. +/// Read the whole environment in one command. /// -/// Costs one tmux command per variable. The listing alone cannot be trusted: -/// a value containing a newline occupies more than one line, and a -/// continuation line holding an `=` is indistinguishable from the next -/// variable. Each name is therefore read back on its own, which also discards -/// the continuation lines, because tmux refuses a name it does not hold. +/// The plain listing cannot be framed: a value containing a newline occupies +/// more than one line, and a continuation line holding an `=` reads as the +/// next variable. `-s` prints each entry as a shell statement instead, with +/// every `"`, `\`, `$` and backtick in the value escaped, so the first +/// unescaped `"` ends the value. That escaping is undone and nothing else is, +/// so each value reads exactly as [`get`] reads it. pub(crate) async fn all( core: &Core, scope: Scope<'_>, ) -> Result, Error> { let result = core - .execute(scope.apply(Command::new("show-environment"))) + .execute(scope.apply(Command::new("show-environment")).arg("-s")) .await?; if !result.success() { return Err(Error::from_refused_result( @@ -159,23 +162,156 @@ pub(crate) async fn all( )); } - let candidates: Vec = result - .stdout_lossy() - .lines() - .filter_map(|line| { - line.strip_prefix('-') - .map_or_else(|| line.split_once('=').map(|(name, _)| name), Some) - }) - .filter(|name| !name.is_empty()) - .map(ToOwned::to_owned) - .collect(); + parse_shell_listing(result.stdout()).map_err(|(row, offset)| Error::DecodeListing { + list_command: "show-environment", + detail: ListingDecodeError::new(FormatCodecError::row_mismatch( + row, + None, + None, + Some(offset), + )), + }) +} +/// Parse `show-environment -s`, reporting the entry and byte offset that +/// failed rather than guessing past it. +fn parse_shell_listing( + stdout: &[u8], +) -> Result, (usize, usize)> { let mut environment = BTreeMap::new(); - for name in candidates { - if let Some(entry) = get(core, scope, &name).await? { - environment.insert(name, entry); + let mut at = 0; + let mut row = 0; + while at < stdout.len() { + let rest = &stdout[at..]; + let (name, entry, consumed) = parse_unset(rest) + .or_else(|| parse_exported(rest)) + .ok_or((row, at))?; + environment.insert(name, entry); + at += consumed; + row += 1; + } + Ok(environment) +} + +/// `unset NAME;` and its newline. +/// +/// tmux refuses a name containing `=`, which is how an exported entry whose +/// name merely begins `unset ` is told from this. +fn parse_unset(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> { + let body = rest.strip_prefix(b"unset ")?; + let end = body.windows(2).position(|pair| pair == b";\n")?; + let name = &body[..end]; + if name.is_empty() || name.contains(&b'=') { + return None; + } + Some(( + String::from_utf8_lossy(name).into_owned(), + EnvironmentEntry::Removed, + b"unset ".len() + end + 2, + )) +} + +/// `NAME="VALUE"; export NAME;` and its newline. +fn parse_exported(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> { + // tmux refuses a name containing `=`, so the first one ends the name. + let equals = rest.iter().position(|byte| *byte == b'=')?; + let name = &rest[..equals]; + if name.is_empty() || rest.get(equals + 1) != Some(&b'"') { + return None; + } + let mut at = equals + 2; + let mut value = Vec::new(); + loop { + match *rest.get(at)? { + b'"' => break, + b'\\' => { + let next = *rest.get(at + 1)?; + if matches!(next, b'"' | b'\\' | b'$' | b'`') { + value.push(next); + at += 2; + } else { + // tmux's own rendering of a byte, such as `\033`, which + // the plain listing carries too. + value.push(b'\\'); + at += 1; + } + } + byte => { + value.push(byte); + at += 1; + } } } + at += 1; + let suffix = [b"; export ".as_slice(), name, b";\n"].concat(); + if !rest[at..].starts_with(&suffix) { + return None; + } + Some(( + String::from_utf8_lossy(name).into_owned(), + EnvironmentEntry::Set(TmuxText::from(value)), + at + suffix.len(), + )) +} - Ok(environment) +#[cfg(test)] +mod tests { + use super::parse_shell_listing; + use crate::{EnvironmentEntry, TmuxText}; + + fn set(value: &[u8]) -> EnvironmentEntry { + EnvironmentEntry::Set(TmuxText::from(value.to_vec())) + } + + /// Bytes tmux 3.7d printed. `a$b` arrives as `a\\$b`: `-s` escapes the + /// `$` for a shell, and tmux escapes it again for display, as the plain + /// listing that `get` reads does too. + #[test] + fn shell_listing_undoes_only_the_shell_escaping() { + let listing = b"V_BS=\"a\\\\b\"; export V_BS;\n\ + V_DOLLAR=\"a\\\\$b\"; export V_DOLLAR;\n\ + V_DQ=\"a\\\"b\"; export V_DQ;\n\ + V_ESC=\"a\\033b\"; export V_ESC;\n\ + V_TAB=\"a\tb\"; export V_TAB;\n"; + let parsed = parse_shell_listing(listing).expect("listing parses"); + + assert_eq!(parsed["V_BS"], set(b"a\\b")); + assert_eq!(parsed["V_DOLLAR"], set(b"a\\$b")); + assert_eq!(parsed["V_DQ"], set(b"a\"b")); + assert_eq!(parsed["V_ESC"], set(b"a\\033b")); + assert_eq!(parsed["V_TAB"], set(b"a\tb")); + } + + #[test] + fn a_value_shaped_like_framing_stays_one_value() { + let listing = b"MULTI=\"first\nDECOY=x\n\\\"; export X;\nY=\\\"z\"; export MULTI;\n\ + unset GONE;\n\ + unset A=\"v\"; export unset A;\n"; + let parsed = parse_shell_listing(listing).expect("listing parses"); + + assert_eq!(parsed.len(), 3, "{parsed:?}"); + assert_eq!( + parsed["MULTI"], + set(b"first\nDECOY=x\n\"; export X;\nY=\"z") + ); + assert_eq!(parsed["GONE"], EnvironmentEntry::Removed); + assert_eq!(parsed["unset A"], set(b"v")); + } + + #[test] + fn unframed_output_is_refused_not_guessed() { + for listing in [ + b"PLAIN=value\n".as_slice(), + b"OPEN=\"never closed; export OPEN;\n", + b"MISMATCH=\"v\"; export OTHER;\n", + b"unset ;\n", + b"TRUNCATED=\"v\"; export TRUNCATED;", + ] { + assert!( + parse_shell_listing(listing).is_err(), + "accepted {:?}", + String::from_utf8_lossy(listing) + ); + } + } } diff --git a/crates/libtmux/src/server/settings.rs b/crates/libtmux/src/server/settings.rs index 5b08946c..5d4b4df4 100644 --- a/crates/libtmux/src/server/settings.rs +++ b/crates/libtmux/src/server/settings.rs @@ -271,17 +271,16 @@ impl Server { /// Read the server's whole environment. /// - /// Costs one tmux command per variable, for the reason given on - /// [`Session::environment_all`](crate::Session::environment_all): a value - /// containing a newline occupies - /// more than one line of the listing, and a continuation line holding an - /// `=` cannot be told from the next variable. + /// Costs one tmux command, read the way + /// [`Session::environment_all`](crate::Session::environment_all) + /// describes: each value reads exactly as [`Self::environment`] reads it. /// /// # Errors /// - /// Returns an error when tmux cannot be reached or refuses the listing. - /// An empty map means the server holds nothing, never that the listing - /// failed. + /// Returns an error when tmux cannot be reached or refuses the listing, + /// and [`Error::DecodeListing`] when the listing is not in the shape tmux + /// prints. An empty map means the server holds nothing, never that the + /// listing failed. pub async fn environment_all(&self) -> Result, Error> { environment::all(&self.core, environment::Scope::Global).await } diff --git a/crates/libtmux/src/session/settings.rs b/crates/libtmux/src/session/settings.rs index bad50bd1..05a0c89b 100644 --- a/crates/libtmux/src/session/settings.rs +++ b/crates/libtmux/src/session/settings.rs @@ -432,18 +432,17 @@ impl Session { /// inherit it. Both appear in the listing, and [`EnvironmentEntry`] keeps /// them apart, exactly as [`Self::environment`] does for a single name. /// - /// Costs one tmux command per variable. The listing alone cannot be - /// trusted: a value containing a newline occupies more than one line, and - /// a continuation line holding an `=` is indistinguishable from the next - /// variable. Each name is therefore read back on its own, which also - /// discards the continuation lines, because tmux refuses a name it does - /// not hold. + /// Costs one tmux command. The listing is read in tmux's shell form, + /// which escapes each value, so a value containing a newline or an `=` + /// is not mistaken for the next variable. Each value reads exactly as + /// [`Self::environment`] reads it. /// /// # Errors /// - /// Returns an error when tmux cannot be reached or refuses the listing. - /// An empty map means the session holds nothing, never that the listing - /// failed. + /// Returns an error when tmux cannot be reached or refuses the listing, + /// and [`Error::DecodeListing`] when the listing is not in the shape tmux + /// prints. An empty map means the session holds nothing, never that the + /// listing failed. /// /// # Examples /// diff --git a/crates/libtmux/tests/options.rs b/crates/libtmux/tests/options.rs index da298fee..58958234 100644 --- a/crates/libtmux/tests/options.rs +++ b/crates/libtmux/tests/options.rs @@ -574,9 +574,24 @@ async fn an_environment_value_survives_what_a_line_listing_would_split() { .set_environment("SPACED", "x y") .await .expect("a value with runs of spaces"); + // Shaped like the listing's own framing, and carrying every byte the + // listing escapes, so a value that ends early reads as another variable. + session + .set_environment("FRAMED", "a\"; export FRAMED;\nFORGED=\"b\\$c`d\\\\") + .await + .expect("a value shaped like framing"); let environment = session.environment_all().await.expect("listing"); + for name in ["MULTILINE", "SPACED", "FRAMED"] { + assert_eq!( + environment.get(name), + session.environment(name).await.expect("read").as_ref(), + "{name} reads the same whole as alone", + ); + } + assert_eq!(environment.get("FORGED"), None, "framing inside a value"); + assert!(matches!( environment.get("MULTILINE"), Some(EnvironmentEntry::Set(value)) if value.as_bytes() == b"first\nDECOY=not-a-variable", diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index abcbb93c..69ddeb26 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -362,6 +362,7 @@ now run in the client. Update existing client calls with this mapping: | `clear_pane` | Use teardown tool `clear_pane_scrollback`. | | `set_option` | Use constrained tools such as `set_mouse_enabled`, `set_history_limit`, or `set_synchronize_panes`. | | `set_environment` | No generic caller-controlled environment route remains. | +| `show_environment` values | Values are withheld by default; allow names with `LIBTMUX_ENVIRONMENT_VALUES`. | | `run_plan` | Use `call_read_tools_batch` for inspect-only batches; issue typed state-changing calls separately. | | `kill_server` | Kill selected sessions explicitly or administer the server outside MCP. | | `tmux://server` | Use `get_server_info`; `tmux://capabilities` adds the frozen connection and selection boundary. | @@ -439,11 +440,27 @@ configuration, not a sandbox boundary. Valid inherited Bash and zsh `ERR` and `DEBUG` traps remain visible to the authored command; parent-shell traps and the `errexit`, `xtrace`, and `noglob` options remain unchanged. -Pane output and tmux metadata may contain sensitive or untrusted text. -Environment values may contain secrets. Hooks may contain executable -configuration. Existing aliases, hooks, plugins, status jobs, and pane -processes can add effects to any call. Standard MCP annotations describe the -whole call for client consent; they do not enforce authority. +Pane output and tmux metadata may contain sensitive or untrusted text. Hooks +may contain executable configuration. Existing aliases, hooks, plugins, status +jobs, and pane processes can add effects to any call. Standard MCP annotations +describe the whole call for client consent; they do not enforce authority. + +A tmux server holds the environment of the shell that started it, tokens and +keys included. No tool returns an environment value by default. +`show_environment` lists each name and whether it is set or marked for +removal, and withholds the value. `get_tmux_variables` refuses a name the +server or session environment holds, because tmux expands a name it does not +know as a format from the environment. `LIBTMUX_ENVIRONMENT_VALUES` names, at +startup, the variables whose values both tools may return; a named value +reaches the client in clear. It takes up to 32 comma-separated names, matched +exactly: + +```console +$ LIBTMUX_ENVIRONMENT_VALUES=TERM,LANG tmux-mcp +``` + +This covers what tmux holds, not what a pane's program can print: a command +sent through an execute tool runs with the tmux user's environment. ## What it feels like @@ -518,7 +535,9 @@ is excluded. **Reading tmux variables.** `get_tmux_variables` accepts one to 32 validated variable names and constructs bounded `#{variable}` references itself. The manifest records `validated-variable-name` under `inputLiteralization`. It -does not accept arbitrary or shell-command formats. +does not accept arbitrary or shell-command formats, and refuses a name the +tmux environment holds unless the operator allowed it (see +[Trust boundary](#trust-boundary)). ## Answers are typed diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 04e0dcfa..dc466964 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -201,7 +201,7 @@ Return metadata for one session Inspect tmux metadata; accepts no client-supplie ## `get_tmux_variables` -Read a bounded set of tmux variables against one pane Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. +Read a bounded set of tmux variables against one pane. tmux reads a name it does not know as a format from its environment, so a name the server or session environment holds is refused unless the operator listed it in LIBTMUX_ENVIRONMENT_VALUES at startup. Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration. - Toolset: `inspect` - Process reach: `none` @@ -591,7 +591,7 @@ Set the window default for synchronized pane input. Individual pane overrides st ## `show_environment` -Read the environment tmux hands to processes it starts, for the server or for one session. This is not the environment of anything already running: a pane started before a change keeps what it was given. Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets. +List the variables tmux hands to processes it starts, for the server or for one session, and whether each is set or marked for removal. Values are withheld: a tmux server inherits the environment of the shell that started it, tokens and keys included. A value is returned only for a name the operator listed in LIBTMUX_ENVIRONMENT_VALUES at startup, and is then returned in clear. This is not the environment of anything already running: a pane started before a change keeps what it was given. Read the tmux environment; accepts no client-supplied executable input. Values are withheld unless the operator allowed the name. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/bin/tmux-mcp.rs b/crates/tmux-mcp/src/bin/tmux-mcp.rs index 1578cfdc..9c875a82 100644 --- a/crates/tmux-mcp/src/bin/tmux-mcp.rs +++ b/crates/tmux-mcp/src/bin/tmux-mcp.rs @@ -13,7 +13,7 @@ use rmcp::service::{RxJsonRpcMessage, TxJsonRpcMessage}; use rmcp::transport::{Transport, async_rw::AsyncRwTransport, stdio}; use rmcp::{RoleServer, ServiceExt as _}; use tmux_mcp::cli::{HELP, Options, Stop}; -use tmux_mcp::{Selection, SocketProvenance, TmuxTools}; +use tmux_mcp::{Selection, SocketProvenance, TmuxTools, environment_values_from_env}; const DEFAULT_SOCKET: &str = "libtmux-mcp"; const SOCKET_ENV: &str = "LIBTMUX_SOCKET"; @@ -194,6 +194,7 @@ async fn serve(options: Options) -> Result<(), Box> { TmuxTools::builder(server.clone()) .selection(validation_selection) .try_build()?; + let environment_values = environment_values_from_env()?; let existing_before = match server.check_alive().await { Ok(()) => true, @@ -258,6 +259,7 @@ async fn serve(options: Options) -> Result<(), Box> { let tools = TmuxTools::builder(server.clone()) .selection(selection) .socket_provenance(provenance) + .environment_values(environment_values) .try_build()?; // Log the frozen surface and socket choice once at startup. diff --git a/crates/tmux-mcp/src/lib.rs b/crates/tmux-mcp/src/lib.rs index 6dc24100..63b2fc03 100644 --- a/crates/tmux-mcp/src/lib.rs +++ b/crates/tmux-mcp/src/lib.rs @@ -20,8 +20,9 @@ //! # Trust boundary //! //! Pane input and pane commands run with the tmux user's permissions. Pane -//! output may be sensitive or untrusted; tmux environment values may contain -//! secrets; hooks may contain executable configuration. Configured-process +//! output may be sensitive or untrusted. tmux environment values are withheld +//! unless [`Builder::environment_values`] allows the name; hooks may contain +//! executable configuration. Configured-process //! routes accept neither command nor environment payloads. There is no public //! host-command route. //! @@ -56,8 +57,9 @@ pub use exec::{RunOutcome, RunView, WaitOutcome, WaitView}; pub use manifest::CapabilityReport; pub use model::*; pub use policy::{ - Builder, EXCLUDE_TOOLS_ENV, RETIRED_RUST_SAFETY_ENV, RETIRED_SAFETY_ENV, Reporter, Selection, - SocketProvenance, SurfaceError, TOOLS_ENV, TOOLSETS_ENV, Toolset, + Builder, ENVIRONMENT_VALUES_ENV, EXCLUDE_TOOLS_ENV, RETIRED_RUST_SAFETY_ENV, + RETIRED_SAFETY_ENV, Reporter, Selection, SocketProvenance, SurfaceError, TOOLS_ENV, + TOOLSETS_ENV, Toolset, environment_values_from_env, parse_environment_values, }; pub use tail::Cursor; pub use tools::error::ToolError; @@ -93,6 +95,8 @@ pub struct TmuxTools { tool_router: rmcp::handler::server::router::tool::ToolRouter, /// Aggregate-only child routes, retained without advertising direct calls. nested_tool_router: rmcp::handler::server::router::tool::ToolRouter, + /// The environment names whose values the operator allowed at startup. + environment_values: Arc>, } // The resolved socket path stays out, as `ServerIdentity`'s own `Debug` keeps @@ -116,8 +120,8 @@ const INSTRUCTIONS: &str = concat!( commands; teardown deletes state. The startup-frozen surface is reported at \ tmux://capabilities.", "\n\nTRUST: pane commands and input run with the tmux user's permissions. Pane \ - output may be sensitive or untrusted, environment values may contain secrets, and \ - hooks may contain executable configuration.", + output may be sensitive or untrusted, tmux environment values are withheld unless \ + the operator allowed the name, and hooks may contain executable configuration.", "\n\nWAIT, DO NOT POLL: wait_for_text and capture_since observe live output. \ run_shell_command reports command completion. Inspect state before retrying any call \ that reports partial_effect or an unknown outcome.", diff --git a/crates/tmux-mcp/src/manifest.rs b/crates/tmux-mcp/src/manifest.rs index 3ab5f3f0..e1a1d686 100644 --- a/crates/tmux-mcp/src/manifest.rs +++ b/crates/tmux-mcp/src/manifest.rs @@ -214,7 +214,7 @@ fn controlled_opener( "Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted." } Toolset::Inspect if output_classes.contains(&OutputClass::ProcessEnvironment) => { - "Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets." + "Read the tmux environment; accepts no client-supplied executable input. Values are withheld unless the operator allowed the name." } Toolset::Inspect if output_classes.contains(&OutputClass::ConfiguredCommand) => { "Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration." diff --git a/crates/tmux-mcp/src/policy.rs b/crates/tmux-mcp/src/policy.rs index cc426990..4cf0373e 100644 --- a/crates/tmux-mcp/src/policy.rs +++ b/crates/tmux-mcp/src/policy.rs @@ -28,6 +28,46 @@ pub const EXCLUDE_TOOLS_ENV: &str = "LIBTMUX_EXCLUDE_TOOLS"; /// The retired ordered-safety setting, rejected rather than ignored. pub const RETIRED_SAFETY_ENV: &str = "LIBTMUX_SAFETY"; +/// The tmux environment variables whose values tools may return. +pub const ENVIRONMENT_VALUES_ENV: &str = "LIBTMUX_ENVIRONMENT_VALUES"; + +/// The most names [`ENVIRONMENT_VALUES_ENV`] may allow. +const MAX_ENVIRONMENT_VALUES: usize = 32; + +/// Parse the operator's allowed environment names. +/// +/// Absent or empty allows none. A name is compared exactly, so `PATH` does +/// not allow `path`. +/// +/// # Errors +/// +/// Returns an error for an empty element, a name containing `=` or NUL, or +/// more than 32 names. +pub fn parse_environment_values(value: Option<&str>) -> Result, SurfaceError> { + let names = parse_optional_names(value, ENVIRONMENT_VALUES_ENV)?; + if let Some(name) = names.iter().find(|name| name.contains(['=', '\0'])) { + return Err(SurfaceError::new(format!( + "{ENVIRONMENT_VALUES_ENV} names {name:?}, which is not a variable name" + ))); + } + if names.len() > MAX_ENVIRONMENT_VALUES { + return Err(SurfaceError::new(format!( + "{ENVIRONMENT_VALUES_ENV} allows at most {MAX_ENVIRONMENT_VALUES} names" + ))); + } + Ok(names) +} + +/// Read [`ENVIRONMENT_VALUES_ENV`] before serving MCP. +/// +/// # Errors +/// +/// Returns an error for invalid UTF-8 or any error +/// [`parse_environment_values`] reports. +pub fn environment_values_from_env() -> Result, SurfaceError> { + parse_environment_values(unicode_env(ENVIRONMENT_VALUES_ENV)?.as_deref()) +} + /// One mechanical group in the advertised MCP tool inventory. #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "kebab-case")] @@ -311,9 +351,22 @@ pub struct Builder { caller: Option, selection: Selection, socket_provenance: SocketProvenance, + environment_values: BTreeSet, } impl Builder { + /// Allow tools to return the values of these tmux environment variables. + /// + /// None are allowed by default: `show_environment` reports names and + /// state, and `get_tmux_variables` refuses a name the environment holds. + /// A tmux server inherits the environment of the shell that started it, + /// so its values are the user's tokens and keys. + #[must_use] + pub fn environment_values(mut self, names: BTreeSet) -> Self { + self.environment_values = names; + self + } + /// Say where this process is running, rather than reading the environment. #[must_use] pub fn caller(mut self, caller: Option) -> Self { @@ -377,6 +430,7 @@ impl Builder { tails: Arc::new(Tails::new(identity)), tool_router: router, nested_tool_router: resolved.nested_router, + environment_values: Arc::new(self.environment_values), }) } } @@ -505,6 +559,7 @@ impl TmuxTools { exclude: BTreeSet::new(), }, socket_provenance: SocketProvenance::Unknown, + environment_values: BTreeSet::new(), } } } diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index b3757932..ea33a5b6 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -425,6 +425,78 @@ fn is_variable_name(value: &str) -> bool { && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') } +/// Separates `#{session_id}` from the variable in one `get_tmux_variables` +/// expansion. U+241E, as `snapshot_pane` uses, because a `%` would be read +/// as a time conversion. +const VARIABLE_SEPARATOR: &str = "\u{241e}"; + +fn decode_error(message: String) -> ToolError { + ErrorData::internal_error( + message, + Some(serde_json::json!({ + "kind": "decode", + "retryable": false, + "stale": false, + })), + ) + .into() +} + +impl TmuxTools { + /// Refuse a variable name the environment holds, unless the operator + /// allowed it. + /// + /// tmux expands a name that is neither an option nor a format from the + /// target session's environment and then the server's, so `#{NAME}` reads + /// every value `show_environment` withholds. + async fn withhold_environment_values( + &self, + names: impl Iterator, + sessions: &BTreeSet, + ) -> Result<(), ToolError> { + let guarded: Vec<&String> = names + .filter(|name| !self.environment_values.contains(*name)) + .collect(); + if guarded.is_empty() { + return Ok(()); + } + let mut held: BTreeSet = self + .server + .environment_all() + .await + .map_err(|error| tmux_error(&error))? + .into_keys() + .collect(); + for session in sessions.iter().filter(|session| !session.is_empty()) { + let id: libtmux::SessionId = session + .parse() + .map_err(|_| decode_error(format!("tmux expanded session_id as {session:?}")))?; + let Some(session) = self + .server + .session_by_id(&id) + .await + .map_err(|error| tmux_error(&error))? + else { + return Err(object_gone("session", session)); + }; + held.extend( + session + .environment_all() + .await + .map_err(|error| tmux_error(&error))? + .into_keys(), + ); + } + match guarded.into_iter().find(|name| held.contains(*name)) { + Some(name) => Err(bad_input(format!( + "{name} is a tmux environment variable, and its value is withheld; the \ + operator can allow it with LIBTMUX_ENVIRONMENT_VALUES at startup" + ))), + None => Ok(()), + } + } +} + #[tool_router(router = contract_router, vis = "pub(super)")] impl TmuxTools { #[tool( @@ -525,7 +597,10 @@ impl TmuxTools { } #[tool( - description = "Read a bounded set of tmux variables against one pane", + description = "Read a bounded set of tmux variables against one pane. tmux reads a \ + name it does not know as a format from its environment, so a name the \ + server or session environment holds is refused unless the operator \ + listed it in LIBTMUX_ENVIRONMENT_VALUES at startup.", title = "Get tmux Variables", meta = crate::capability_meta!( Inspect, None, @@ -555,20 +630,30 @@ impl TmuxTools { None => None, }; let mut values = BTreeMap::new(); + let mut sessions = BTreeSet::new(); for name in names { if !is_variable_name(&name) { return Err(bad_input(format!( "{name:?} is not a tmux variable name; use letters, digits, and underscores" ))); } - let format = format!("#{{{name}}}"); + // The session rides along so the environment tmux consulted for + // this very expansion is known. + let format = format!("#{{session_id}}{VARIABLE_SEPARATOR}#{{{name}}}"); let value = self .server .format(pane.as_ref(), &format) .await .map_err(|error| tmux_error(&error))?; - values.insert(name, lossy(&value)); + let value = lossy(&value); + let (session, value) = value.split_once(VARIABLE_SEPARATOR).ok_or_else(|| { + decode_error(format!("tmux did not expand {name} with its session")) + })?; + sessions.insert(session.to_owned()); + values.insert(name, value.to_owned()); } + self.withhold_environment_values(values.keys(), &sessions) + .await?; Ok(Json(VariablesValue { values })) } diff --git a/crates/tmux-mcp/src/tools/inspect.rs b/crates/tmux-mcp/src/tools/inspect.rs index b822f13b..83e669ad 100644 --- a/crates/tmux-mcp/src/tools/inspect.rs +++ b/crates/tmux-mcp/src/tools/inspect.rs @@ -7,8 +7,9 @@ use rmcp::{tool, tool_router}; use crate::exec::Patterns; use crate::{ Branch, BranchPane, BranchWindow, Capture, CapturePaneArgs, Environment, EnvironmentEntry, - Hook, Hooks, Marks, MatchView, Matches, OptionArgs, OptionValue, Panes, SearchPanesArgs, - Sessions, ShowEnvironmentArgs, ShowHooksArgs, Snapshot, SnapshotArgs, TmuxTools, Tree, Windows, + EnvironmentState, Hook, Hooks, Marks, MatchView, Matches, OptionArgs, OptionValue, Panes, + SearchPanesArgs, Sessions, ShowEnvironmentArgs, ShowHooksArgs, Snapshot, SnapshotArgs, + TmuxTools, Tree, Windows, }; use super::error::{ToolError, bad_input, tmux_error}; @@ -505,11 +506,16 @@ impl TmuxTools { })) } - /// Read a tmux environment. + /// Read the names in a tmux environment, and the values the operator allowed. #[tool( - description = "Read the environment tmux hands to processes it starts, for the server \ - or for one session. This is not the environment of anything already \ - running: a pane started before a change keeps what it was given.", + description = "List the variables tmux hands to processes it starts, for the server \ + or for one session, and whether each is set or marked for removal. \ + Values are withheld: a tmux server inherits the environment of the \ + shell that started it, tokens and keys included. A value is returned \ + only for a name the operator listed in LIBTMUX_ENVIRONMENT_VALUES at \ + startup, and is then returned in clear. This is not the environment of \ + anything already running: a pane started before a change keeps what it \ + was given.", title = "Show tmux Environment", meta = crate::capability_meta!(Inspect, None, [Observe], [ProcessEnvironment], true, true, { "session" => [TmuxLookup] @@ -528,11 +534,21 @@ impl TmuxTools { Ok(Json(Environment { entries: entries .into_iter() - .map(|(name, entry)| EnvironmentEntry { - name, - value: match entry { - libtmux::EnvironmentEntry::Set(value) => Some(lossy(&value)), - libtmux::EnvironmentEntry::Removed => None, + .map(|(name, entry)| match entry { + libtmux::EnvironmentEntry::Set(value) => { + let allowed = self.environment_values.contains(&name); + EnvironmentEntry { + name, + state: EnvironmentState::Set, + value: allowed.then(|| lossy(&value)), + withheld: !allowed, + } + } + libtmux::EnvironmentEntry::Removed => EnvironmentEntry { + name, + state: EnvironmentState::Removed, + value: None, + withheld: false, }, }) .collect(), diff --git a/crates/tmux-mcp/src/views.rs b/crates/tmux-mcp/src/views.rs index 8473a8d0..785d101e 100644 --- a/crates/tmux-mcp/src/views.rs +++ b/crates/tmux-mcp/src/views.rs @@ -297,13 +297,29 @@ pub struct Killed { pub id: String, } +/// What tmux holds under one environment name. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentState { + /// tmux holds a value and hands it to processes it starts. + Set, + /// tmux removes the name from the environment of processes it starts. + Removed, +} + /// One tmux environment entry. #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct EnvironmentEntry { /// The variable name. pub name: String, - /// Its value, absent when the variable is marked for removal. + /// Whether the name holds a value or is marked for removal. + pub state: EnvironmentState, + /// The value, only for a set variable whose name the operator allowed in + /// `LIBTMUX_ENVIRONMENT_VALUES` at startup. pub value: Option, + /// True for a set variable whose value was not returned because the + /// operator did not allow its name. + pub withheld: bool, } /// A tmux environment, server-wide or for one session. diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index 7dcd6f7b..b813fd8f 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -3194,7 +3194,162 @@ async fn search_snapshot_and_configuration_reads_are_structured() { .as_array() .unwrap() .iter() - .any(|entry| { entry["name"] == "TMUX_MCP_PROBE" && entry["value"] == "secret-like" }) + .any(|entry| { entry["name"] == "TMUX_MCP_PROBE" && entry["withheld"] == true }) + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +const PLANTED_SECRET: &str = "planted-secret-9c41"; + +/// Plant a server secret, a session secret, an allowed value, and a removal. +async fn plant_environment(server: &Server) { + let session = server + .new_session("withheld") + .await + .expect("session starts"); + server + .set_environment("PLANTED_API_KEY", PLANTED_SECRET) + .await + .expect("server secret is planted"); + session + .set_environment("PLANTED_SESSION_TOKEN", PLANTED_SECRET) + .await + .expect("session secret is planted"); + server + .set_environment("PLANTED_ALLOWED", "allowed-value") + .await + .expect("allowed value is planted"); + server + .hide_environment("PLANTED_REMOVED") + .await + .expect("removal is planted"); +} + +/// Find one entry without printing the listing, which may hold real tokens. +fn environment_entry(listing: &Value, name: &str) -> Value { + listing["entries"] + .as_array() + .expect("entries") + .iter() + .find(|entry| entry["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("{name} is not listed")) +} + +/// A tmux server holds the environment of the shell that started it, so the +/// default answer names each variable and returns no value, and a format +/// that falls back to the environment is refused the same way. +/// +/// The fixture daemon inherits this test's own environment, so no failure +/// message here prints a listing: a regression would print real tokens. +#[tokio::test] +async fn environment_values_are_withheld_by_default() { + const SECRET: &str = PLANTED_SECRET; + let logged = LoggedTmux::new(); + let guard = TestServer::builder() + .tmux_executable(&logged.executable) + .start() + .await + .expect("logging tmux starts"); + let server = guard.server(); + plant_environment(server).await; + let entry = environment_entry; + + let tools = bare_tools(server); + logged.clear(); + let listing = call_tool(tools.clone(), "show_environment", serde_json::json!({})).await; + let dispatches = logged.command_dispatches("show-environment"); + let wire = serde_json::to_string(&listing).expect("result serializes"); + assert!( + !wire.contains(SECRET), + "a withheld value reached the client" + ); + assert_eq!(dispatches, 1, "the listing is one tmux command"); + let listing = listing.structured_content.expect("structured listing"); + assert!( + listing["entries"] + .as_array() + .expect("entries") + .iter() + .all(|entry| entry["value"].is_null()), + "a value was returned for a name nobody allowed" + ); + let secret = entry(&listing, "PLANTED_API_KEY"); + assert_eq!(secret["state"], "set"); + assert_eq!(secret["withheld"], true); + let removed = entry(&listing, "PLANTED_REMOVED"); + assert_eq!(removed["state"], "removed"); + assert_eq!(removed["withheld"], false); + + let session_listing = call_tool( + tools.clone(), + "show_environment", + serde_json::json!({"session": "withheld"}), + ) + .await; + let wire = serde_json::to_string(&session_listing).expect("result serializes"); + assert!(!wire.contains(SECRET), "a session value reached the client"); + + for name in ["PLANTED_API_KEY", "PLANTED_SESSION_TOKEN"] { + let refused = call_tool( + tools.clone(), + "get_tmux_variables", + serde_json::json!({"names": ["session_name", name]}), + ) + .await; + let wire = serde_json::to_string(&refused).expect("result serializes"); + assert!( + !wire.contains(SECRET), + "{name} reached the client as a format" + ); + assert_eq!(refused.is_error, Some(true), "{name} was not refused"); + assert!(wire.contains("invalid_input"), "{name} was refused untyped"); + } + let formats = call_tool( + tools.clone(), + "get_tmux_variables", + serde_json::json!({"names": ["session_name"]}), + ) + .await; + assert_eq!( + formats.structured_content.expect("variables")["values"]["session_name"], + "withheld" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// The operator's allowance releases the names it lists and no others. +#[tokio::test] +async fn an_allowed_environment_name_returns_its_value() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + plant_environment(server).await; + + let allowed = TmuxTools::builder(server.clone()) + .caller(None) + .environment_values(BTreeSet::from(["PLANTED_ALLOWED".to_owned()])) + .build(); + let listing = call_tool(allowed.clone(), "show_environment", serde_json::json!({})).await; + let wire = serde_json::to_string(&listing).expect("result serializes"); + assert!( + !wire.contains(PLANTED_SECRET), + "allowing one name released another" + ); + let listing = listing.structured_content.expect("structured listing"); + let named = environment_entry(&listing, "PLANTED_ALLOWED"); + assert_eq!(named["value"], "allowed-value"); + assert_eq!(named["withheld"], false); + let variables = call_tool( + allowed, + "get_tmux_variables", + serde_json::json!({"names": ["PLANTED_ALLOWED"]}), + ) + .await; + assert_eq!( + variables.structured_content.expect("variables")["values"]["PLANTED_ALLOWED"], + "allowed-value" ); guard.shutdown().await.expect("tmux fixture shuts down"); diff --git a/crates/tmux-mcp/tests/binary.rs b/crates/tmux-mcp/tests/binary.rs index ed464bf3..d228a1e2 100644 --- a/crates/tmux-mcp/tests/binary.rs +++ b/crates/tmux-mcp/tests/binary.rs @@ -152,6 +152,7 @@ fn base_command() -> Command { .env_remove("LIBTMUX_SOCKET") .env_remove("LIBTMUX_SOCKET_PATH") .env_remove("LIBTMUX_TMUX_CONFIG") + .env_remove("LIBTMUX_ENVIRONMENT_VALUES") .env_remove("TMUX_TMPDIR") .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -522,6 +523,78 @@ fn default_daemon_loads_the_shipped_minimal_configuration() { std::fs::remove_dir_all(root).expect("fixture cleanup"); } +/// Measured over JSON-RPC, because a transcript is where a value leaks to. +/// +/// The fixture daemon inherits this test's environment, so no failure message +/// prints a response. +#[test] +fn environment_values_stay_off_the_wire_unless_allowed() { + const SECRET: &str = "planted-secret-2d7e"; + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let guard = runtime.block_on(async { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + server.new_session("wire").await.expect("session starts"); + server + .set_environment("PLANTED_API_KEY", SECRET) + .await + .expect("secret is planted"); + server + .set_environment("PLANTED_ALLOWED", "allowed-value") + .await + .expect("allowed value is planted"); + guard + }); + let socket = guard.socket_path().to_str().expect("UTF-8 socket"); + let calls = [ + json!({"name": "show_environment", "arguments": {}}), + json!({"name": "get_tmux_variables", "arguments": {"names": ["PLANTED_API_KEY"]}}), + json!({ + "name": "call_read_tools_batch", + "arguments": {"operations": [ + {"tool": "show_environment", "arguments": {}}, + {"tool": "get_tmux_variables", "arguments": {"names": ["PLANTED_API_KEY"]}} + ]} + }), + ]; + + let mut default = Process::start(&["--socket", socket], &[]); + for call in &calls { + let response = default.request("tools/call", call).to_string(); + assert!( + !response.contains(SECRET), + "{} put a withheld value on the wire", + call["name"] + ); + } + default.finish(); + + let mut allowed = Process::start( + &["--socket", socket], + &[("LIBTMUX_ENVIRONMENT_VALUES", "PLANTED_ALLOWED")], + ); + for call in &calls { + let response = allowed.request("tools/call", call).to_string(); + assert!( + !response.contains(SECRET), + "{} put a withheld value on the wire", + call["name"] + ); + } + let listing = allowed.request("tools/call", &calls[0]).to_string(); + assert!( + listing.contains("allowed-value"), + "an allowed value is returned" + ); + allowed.finish(); + + let refused = failed_start(&[("LIBTMUX_ENVIRONMENT_VALUES", "A=B")]); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!(!refused.status.success()); + assert!(stderr.contains("LIBTMUX_ENVIRONMENT_VALUES"), "{stderr}"); + runtime.block_on(async { guard.shutdown().await.expect("tmux stops") }); +} + #[test] fn help_names_current_startup_controls_only() { let output = Command::new(BIN).arg("--help").output().expect("help runs"); diff --git a/crates/tmux-mcp/tests/protocol.rs b/crates/tmux-mcp/tests/protocol.rs index dd732152..8bb30518 100644 --- a/crates/tmux-mcp/tests/protocol.rs +++ b/crates/tmux-mcp/tests/protocol.rs @@ -137,7 +137,7 @@ async fn descriptions_annotations_and_manifest_metadata_survive_the_wire() { assert!( description.ends_with("Inspect tmux metadata; accepts no client-supplied executable input.") || description.ends_with("Returned content may be sensitive or untrusted.") - || description.ends_with("Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets.") + || description.ends_with("Read the tmux environment; accepts no client-supplied executable input. Values are withheld unless the operator allowed the name.") || description.ends_with("Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration.") || description.ends_with("Change tmux state; no client-supplied executable input.") || description.ends_with("Start a pane's configured process; accepts no command payload.") From 3994ae2fe27be7a36516aca2446cf9c7cff99903 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 00:55:30 -0500 Subject: [PATCH 074/117] tmux-mcp(fix[socket]): Keep a shared daemon alive why: Two tmux-mcp processes launched without arguments share the dedicated libtmux-mcp daemon, and the one that started it killed it on exit whatever else was using it. The other kept answering server_gone for the rest of its life. That is the README's install for every client, so two editor windows was enough. A per-process socket name would have ended the sharing the provenance tests pin, and leaked a daemon per crashed process. A kernel-released lock leaks nothing on a crash. what: - Every process on the dedicated socket holds a shared flock on libtmux-mcp.lease beside it, taken before the liveness check so an owner cannot stop the daemon between discovery and use - The owner stops the daemon only when a non-blocking exclusive lock succeeds, and holds it until the kill completes; otherwise it logs that it left the daemon running - No process stops a daemon it did not start, so an owner that exits first leaves the daemon for the next launch to treat as existing - rustix for flock: std's File::lock needs 1.89, above the 1.88 floor - README and CHANGELOG --- Cargo.lock | 1 + crates/tmux-mcp/Cargo.toml | 2 + crates/tmux-mcp/README.md | 16 ++++-- crates/tmux-mcp/src/bin/tmux-mcp.rs | 83 ++++++++++++++++++++++++++++- crates/tmux-mcp/tests/binary.rs | 41 ++++++++++++++ 5 files changed, 137 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 680869b5..5c754066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1745,6 +1745,7 @@ dependencies = [ "libtmux", "regex", "rmcp", + "rustix", "schemars", "serde", "serde_json", diff --git a/crates/tmux-mcp/Cargo.toml b/crates/tmux-mcp/Cargo.toml index 798a6bb2..acbd3c3a 100644 --- a/crates/tmux-mcp/Cargo.toml +++ b/crates/tmux-mcp/Cargo.toml @@ -34,6 +34,8 @@ getrandom = "0.4" libtmux = { workspace = true, features = ["control-mode", "query", "serde"] } regex = "1.13" rmcp = { version = "3.0", features = ["server", "macros", "transport-io"] } +# `std::fs::File::lock` needs 1.89, above this crate's floor. +rustix = { version = "1.1.4", features = ["fs"] } schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 69ddeb26..0bdc12fc 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -400,10 +400,18 @@ refusal, not a consent signal. Without arguments the server selects the `libtmux-mcp` socket, starts it with the shipped minimal configuration, and authenticates that this launch created -the daemon before enabling teardown by default. The owning process stops that -daemon at shutdown. An already-running daemon keeps its configuration and gets -conservative provenance. The server does not follow `$TMUX`. Select another -socket by path or name: +the daemon before enabling teardown by default. An already-running daemon +keeps its configuration and gets conservative provenance. The server does not +follow `$TMUX`. + +Several `tmux-mcp` processes without arguments share that one daemon. Each +holds a shared lock on `libtmux-mcp.lease` beside the socket, and the process +that started the daemon stops it at shutdown only when no other process holds +one. No process stops a daemon it did not start, so when the owner exits +first the daemon outlives every process using it, and the next launch finds +it already running. Stop it with `tmux -L libtmux-mcp kill-server`. + +Select another socket by path or name: ```console $ tmux-mcp --socket /tmp/tmux-1000/work diff --git a/crates/tmux-mcp/src/bin/tmux-mcp.rs b/crates/tmux-mcp/src/bin/tmux-mcp.rs index 9c875a82..7ff8c3fd 100644 --- a/crates/tmux-mcp/src/bin/tmux-mcp.rs +++ b/crates/tmux-mcp/src/bin/tmux-mcp.rs @@ -2,7 +2,7 @@ use std::fs::OpenOptions; use std::io::{self, Write as _}; -use std::os::unix::fs::OpenOptionsExt as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::Duration; @@ -196,6 +196,24 @@ async fn serve(options: Options) -> Result<(), Box> { .try_build()?; let environment_values = environment_values_from_env()?; + // Held before the liveness check, so an owner cannot stop the daemon + // between this process finding it and starting to use it. + let dedicated = Server::builder().socket_name(DEFAULT_SOCKET).build()?; + let lease = if server.socket_path() == dedicated.socket_path() { + Some( + SocketLease::share(server.socket_path()) + .await + .map_err(|error| { + format!( + "cannot lease the dedicated tmux socket at {}: {error}", + server.socket_path().display() + ) + })?, + ) + } else { + None + }; + let existing_before = match server.check_alive().await { Ok(()) => true, Err(libtmux::Error::ServerGone { .. }) => false, @@ -287,18 +305,79 @@ async fn serve(options: Options) -> Result<(), Box> { .map_err(Box::::from), Err(error) => Err(Box::new(error)), }; - if created_dedicated { + let alone = lease.as_ref().is_some_and(SocketLease::try_exclusive); + if created_dedicated && alone { let killed = server.kill().await; let shutdown = server.shutdown().await; + drop(lease); result?; killed?; shutdown?; } else { + if created_dedicated { + eprintln!( + "tmux-mcp: leaving the dedicated tmux daemon running, because another \ + tmux-mcp still uses it" + ); + } result?; } Ok(()) } +/// A share in the dedicated socket, held for this process's whole life. +/// +/// Every process on the dedicated socket holds a shared `flock` on a file +/// beside it, and the process that started the daemon stops it only when an +/// exclusive lock succeeds, which proves no other process holds a share. The +/// kernel releases a dead process's share, so a crash leaves no stale lease. +struct SocketLease(std::fs::File); + +impl SocketLease { + /// Take a share, waiting out an owner that is stopping the daemon. + async fn share(socket: &Path) -> io::Result { + let directory = socket + .parent() + .ok_or_else(|| io::Error::other("the socket path has no directory"))? + .to_path_buf(); + let path = socket.with_file_name(format!("{DEFAULT_SOCKET}.lease")); + tokio::task::spawn_blocking(move || { + // tmux refuses a socket directory that others can read, and + // creates this one 0700 itself when it gets there first. + match std::fs::DirBuilder::new().mode(0o700).create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(&path)?; + rustix::fs::flock(&file, rustix::fs::FlockOperation::LockShared)?; + Ok(Self(file)) + }) + .await + .map_err(io::Error::other)? + } + + /// Whether this process is the only one holding a share. + /// + /// On success the share becomes exclusive, so a process starting now + /// waits in [`Self::share`] until the daemon is gone and then starts its + /// own. On failure the share may be gone too, which matters to nothing: + /// the caller is exiting. + fn try_exclusive(&self) -> bool { + rustix::fs::flock( + &self.0, + rustix::fs::FlockOperation::NonBlockingLockExclusive, + ) + .is_ok() + } +} + const fn provenance_label(provenance: SocketProvenance) -> &'static str { match provenance { SocketProvenance::DedicatedMinimal => "dedicated minimal", diff --git a/crates/tmux-mcp/tests/binary.rs b/crates/tmux-mcp/tests/binary.rs index d228a1e2..7783a6cf 100644 --- a/crates/tmux-mcp/tests/binary.rs +++ b/crates/tmux-mcp/tests/binary.rs @@ -490,6 +490,47 @@ fn only_the_process_whose_config_marker_loaded_claims_minimal_provenance() { assert_eq!(follower_report["toolCount"], 41); } +/// Two clients on the default socket share one daemon, so the one that +/// started it must not stop it while the other still answers from it. +#[test] +fn the_owner_leaves_a_shared_dedicated_daemon_running() { + let root = PathBuf::from("/tmp/libtmux-rs-test") + .join(format!("mcp-shared-owner-{}", std::process::id())); + std::fs::create_dir_all(&root).expect("fixture root"); + let socket = root.join(format!( + "tmux-{}/libtmux-mcp", + std::fs::metadata(&root).expect("fixture metadata").uid() + )); + let environment = [("TMUX_TMPDIR", root.to_str().expect("UTF-8 fixture path"))]; + let mut owner = Process::start(&[], &environment); + let created = owner.request( + "tools/call", + &json!({"name": "create_session", "arguments": {"name": "shared"}}), + ); + let mut follower = Process::start(&[], &environment); + + let owner_log = owner.finish(); + let listed = follower.request( + "tools/call", + &json!({"name": "list_sessions", "arguments": {}}), + ); + follower.finish(); + let alive_after_both = daemon_is_alive(&socket); + stop_daemon(&socket); + std::fs::remove_dir_all(&root).expect("fixture cleanup"); + + assert_ne!(created["result"]["isError"], true, "{created}"); + assert_eq!( + listed["result"]["structuredContent"]["sessions"][0]["name"], "shared", + "the follower lost its daemon when the owner exited: {listed}" + ); + assert!(owner_log.contains("leaving"), "{owner_log}"); + assert!( + alive_after_both, + "a follower stopped a daemon it did not start" + ); +} + #[test] fn default_daemon_loads_the_shipped_minimal_configuration() { let root = PathBuf::from("/tmp/libtmux-rs-test") From 4018571e789d9953059d34eebc5fee3b77881d04 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 01:14:04 -0500 Subject: [PATCH 075/117] tmux-mcp(fix[run]): Let an interrupt stop a run why: A run_shell_command that reached its deadline kept its pane reserved, and the README's recovery, send_keys keys ["C-c"], was refused as active_run, retryable. Worse than the refusal: C-c from any source killed the whole run frame, so its completion marker never arrived and the reservation lasted until the pane closed. An agent whose dev server or hung test deadlined had kill_pane left. what: - The frame traps INT and QUIT with a handler, after the zsh trap capture, and restores the default in the command's own subshell. The command is interrupted as before; the frame survives to print the marker with its status, which releases the reservation - send_keys whose only keys are C-c or C-\ passes an active run's reservation; every other refusal still applies, and other input still waits. send_keys_batch rows take the same path - The completion proof also settles when the pane's process changes, so respawn_pane kill_first releases a program that ignores both - The active_run refusal names the interrupt - README, tool descriptions, TOOLS.md, CHANGELOG A tmux command client ignores SIGINT, so a run blocked in tmux wait-for survives C-c; a respawn stops it. --- crates/tmux-mcp/README.md | 12 +- crates/tmux-mcp/TOOLS.md | 4 +- crates/tmux-mcp/src/exec.rs | 6 +- crates/tmux-mcp/src/exec/run.rs | 34 ++++- crates/tmux-mcp/src/exec/tests.rs | 12 +- crates/tmux-mcp/src/tools/control.rs | 59 +++++---- crates/tmux-mcp/src/tools/observe.rs | 4 +- crates/tmux-mcp/src/tools/pane_input.rs | 43 ++++++- crates/tmux-mcp/tests/agent.rs | 157 ++++++++++++++++++++++++ 9 files changed, 285 insertions(+), 46 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 0bdc12fc..3a198032 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -236,8 +236,9 @@ without Enter stays buffer-free. The tool repeats the target check immediately before target-only paste and deletes the buffer after refusal or delivery. Synchronized input never expands paste. `run_shell_command` requires a single configured recipient and reserves its -resolved server and pane process-wide until completion or pane closure is -proved. Every MCP pane-input route observes that reservation. +resolved server and pane process-wide until completion, pane closure, or a +respawn is proved. Every MCP pane-input route observes that reservation except +`send_keys` with only `C-c` or `C-\` keys, which interrupts the command. `call_read_tools_batch` accepts at most 16 enabled inspect operations and caps the complete JSON-RPC response line, including its request ID and newline, at 1,000,000 bytes. Truncated payloads and omitted bytes are explicit, and every @@ -372,7 +373,7 @@ now run in the client. Update existing client calls with this mapping: | `tmux://panes/{id}/content` | Use `capture_pane` or continue from a cursor with `capture_since`. | | `enter_copy_mode`, `exit_copy_mode` | Read with capture, snapshot, search, or cursor tools. The attached person owns pane modes. | | Prompt `run_and_wait` | Use one `run_shell_command`; decide from `outcome` and `exit_status`. A deadline stops the wait, not the pane command. | -| Prompt `interrupt_gracefully` | Start with `snapshot_pane`. If `in_mode`, keep observing and let the attached person leave the mode. Otherwise use `send_keys` with `keys: ["C-c"]`, not `text: "C-c"`, then observe with `capture_since` or `wait_for_text`. `keys: ["C-\\"]` is stronger; do not turn pane recovery into teardown. | +| Prompt `interrupt_gracefully` | Start with `snapshot_pane`. If `in_mode`, keep observing and let the attached person leave the mode. Otherwise use `send_keys` with `keys: ["C-c"]`, not `text: "C-c"`, then observe with `capture_since` or `wait_for_text`. `keys: ["C-\\"]` is stronger. Either, sent alone, reaches a command an active `run_shell_command` still reserves; `respawn_pane` with `kill_first: true` replaces a program that ignores both. Do not turn pane recovery into teardown. | | Prompt `diagnose_pane` | Start with `snapshot_pane`; inspect `pane.command`, `dead`, `in_mode`, `mode`, `dropped`, and `content`. Repeat with `history: true` when the visible screen is insufficient, follow later output with `capture_since`, and use `search_panes` if the target is uncertain. | ### Asking first @@ -517,7 +518,10 @@ relation, foreground shell, route, and active-run ownership at exactly two checkpoints: before watcher setup and immediately before its single dispatch. Invalid syntax completes with a nonzero shell status. The process-wide reservation blocks other MCP pane input until completion is proved, but does -not lock tmux against an external client changing the pane. +not lock tmux against an external client changing the pane. `C-c` or `C-\` +sent alone passes the reservation, and the interrupted command still reports +completion, which releases the pane; `respawn_pane` with `kill_first: true` +releases it from a program that ignores both. **Waiting for something you did not start.** `wait_for_text` watches the pane's output stream for a pattern, with stop patterns for the failures you diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index dc466964..997fbd94 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -426,7 +426,7 @@ Restart a pane's configured process with no command payload Start a pane's confi ## `run_shell_command` -Run a shell command in a pane, wait for it to finish, and report its exit status with everything it wrote. This is the tool for "run this and tell me if it worked". Output is read from the pane's live stream, so nothing is missed and the shell prompt is not included. The command runs in a subshell, so cd and export do not persist and invalid syntax completes with a nonzero status. Valid inherited Bash and zsh ERR and DEBUG traps remain visible to the command while parent-shell traps and options remain unchanged. It requires one configured input recipient and observes its mode, liveness, input-off state, attended-client state, cohort, inherited-caller relation, known POSIX shell, and resolved route before watcher setup and again before dispatch. A process-wide endpoint-and-pane reservation blocks other MCP pane input until the completion marker or pane closure is proved. The resolved tmux executable and socket path must contain no ASCII terminal-control bytes. The reservation serializes this MCP's input, but tmux observations can still race with dispatch. The pane shell, tmux server, and configuration must be trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops this request while its watcher keeps the reservation until completion is proved. Run a shell command in a pane with your user's permissions. +Run a shell command in a pane, wait for it to finish, and report its exit status with everything it wrote. This is the tool for "run this and tell me if it worked". Output is read from the pane's live stream, so nothing is missed and the shell prompt is not included. The command runs in a subshell, so cd and export do not persist and invalid syntax completes with a nonzero status. Valid inherited Bash and zsh ERR and DEBUG traps remain visible to the command while parent-shell traps and options remain unchanged. It requires one configured input recipient and observes its mode, liveness, input-off state, attended-client state, cohort, inherited-caller relation, known POSIX shell, and resolved route before watcher setup and again before dispatch. A process-wide endpoint-and-pane reservation blocks other MCP pane input until the completion marker or pane closure is proved. The resolved tmux executable and socket path must contain no ASCII terminal-control bytes. The reservation serializes this MCP's input, but tmux observations can still race with dispatch. The pane shell, tmux server, and configuration must be trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops this request while its watcher keeps the reservation until completion is proved. To stop the command, send_keys with keys ["C-c"] alone passes the reservation, and the command reports completion when it ends; respawn_pane with kill_first replaces a program that ignores C-c and C-\. Run a shell command in a pane with your user's permissions. - Toolset: `execute` - Process reach: `pane-command` @@ -501,7 +501,7 @@ Select a window, making it its session's active window. Give a direction to move ## `send_keys` -Type text into a pane, press named keys in it, or both. `text` is sent literally, so C-c in it types those three characters. Use `keys` for anything without a character of its own -- C-c to interrupt a running command, Escape, Up, C-d -- which are tmux key names and are interpreted. Text, keys, and optional Enter keep that order in one tmux dispatch. Before input, the configured synchronized-pane cohort is observed; a dead, input-disabled, mode-owned, terminal-attended, or inherited-caller member refuses the whole call. Returned pane IDs describe configured membership, not confirmed delivery. The observation can race with tmux processing the input. Send input to a pane's program; a shell that receives it runs it with your user's permissions. +Type text into a pane, press named keys in it, or both. `text` is sent literally, so C-c in it types those three characters. Use `keys` for anything without a character of its own -- C-c to interrupt a running command, Escape, Up, C-d -- which are tmux key names and are interpreted. Text, keys, and optional Enter keep that order in one tmux dispatch. Before input, the configured synchronized-pane cohort is observed; a dead, input-disabled, mode-owned, terminal-attended, or inherited-caller member refuses the whole call, and so does an active run_shell_command, except for keys C-c or C-\ sent alone, which interrupt it. Returned pane IDs describe configured membership, not confirmed delivery. The observation can race with tmux processing the input. Send input to a pane's program; a shell that receives it runs it with your user's permissions. - Toolset: `execute` - Process reach: `pane-input` diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index b9bbebc4..ba38e9b4 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -50,9 +50,9 @@ pub enum RunOutcome { Completed, /// The time the caller allowed ran out. /// - /// This ends the waiting, not the command. The pane keeps working, so the - /// next thing typed at it lands in the running command rather than at a - /// prompt. + /// This ends the waiting, not the command. The pane stays reserved for it + /// until it ends, so other pane input is refused; `send_keys` with keys + /// `["C-c"]` alone interrupts it. Deadline, /// The pane stopped writing for good. PaneClosed, diff --git a/crates/tmux-mcp/src/exec/run.rs b/crates/tmux-mcp/src/exec/run.rs index 2d949362..e78852ea 100644 --- a/crates/tmux-mcp/src/exec/run.rs +++ b/crates/tmux-mcp/src/exec/run.rs @@ -124,7 +124,13 @@ impl RunProof { let marker = matches!(&capture, Ok(Ok(lines)) if lines.iter().any(|line| { completion_status(line.as_bytes(), &self.closing).is_some() })); + // A respawned pane keeps its id and runs a new process, and the shell + // that ran the frame went with the old one. let pane_ended = matches!(&refreshed, Ok(Ok(pane)) if pane.is_dead()) + || matches!(&refreshed, Ok(Ok(pane)) if matches!( + (self.pane.pid(), pane.pid()), + (Some(before), Some(now)) if before != now + )) || matches!(&refreshed, Ok(Err(Error::ObjectGone { .. }))) || matches!(&capture, Ok(Err(Error::ObjectGone { .. }))); marker || pane_ended @@ -503,6 +509,17 @@ pub(super) fn staged_line(path: &Path, shell: &[u8], suppress_history: bool) -> OsString::from_vec(line) } +/// Keeps the frame alive through `C-c` and `C-\` so it still prints the +/// closing marker, which is what releases the pane's reservation. Without it +/// the whole job dies, no marker comes, and the pane stays reserved until it +/// closes. A handler rather than `''`, because an ignored signal stays +/// ignored in the command it starts. +const INTERRUPT_SURVIVES: &[u8] = b"\\trap : INT QUIT\n"; + +/// Restores the default in the command's own subshell, which zsh would +/// otherwise run with the frame's handler. +const INTERRUPT_REACHES_COMMAND: &[u8] = b"\\trap - INT QUIT"; + fn marker_message(nonce: &str, closing: bool) -> Vec { let mut message = Vec::new(); message.extend_from_slice(b"'__LIBTMUX_MCP_DONE_''"); @@ -525,14 +542,18 @@ fn append_command_branch( closing: &[u8], ) { payload.extend_from_slice(if inherited_errexit { - b"*e*)\n\\set +e\nif " + b"*e*)\n\\set +e\n" } else { - b"*)\n\\set +e\nif " + b"*)\n\\set +e\n" }); + payload.extend_from_slice(INTERRUPT_SURVIVES); + payload.extend_from_slice(b"if "); payload.extend_from_slice(separator); payload.extend_from_slice(b" && "); payload.extend_from_slice(opening); payload.extend_from_slice(b"; then\n( "); + payload.extend_from_slice(INTERRUPT_REACHES_COMMAND); + payload.extend_from_slice(b"; "); payload.extend_from_slice(if inherited_errexit { b"\\set -e; \\eval " } else { @@ -567,13 +588,18 @@ fn render_trapped_payload( } payload.extend_from_slice(b"(\n"); payload.extend_from_slice(&capture.setup); - payload.extend_from_slice(b"\\set +e\nif "); + payload.extend_from_slice(b"\\set +e\n"); + // After the capture setup, which clears every zsh signal trap. + payload.extend_from_slice(INTERRUPT_SURVIVES); + payload.extend_from_slice(b"if "); payload.extend_from_slice(separator); payload.extend_from_slice(b" && "); payload.extend_from_slice(opening); payload.extend_from_slice(b"; then\nif [ \"$"); payload.extend_from_slice(capture.status.as_bytes()); - payload.extend_from_slice(b"\" -eq 0 ]; then\n( case \"$"); + payload.extend_from_slice(b"\" -eq 0 ]; then\n( "); + payload.extend_from_slice(INTERRUPT_REACHES_COMMAND); + payload.extend_from_slice(b"\ncase \"$"); payload.extend_from_slice(capture.errexit.as_bytes()); payload.extend_from_slice(b"\" in 1) \\set -e;; *) \\set +e;; esac\n\\eval \""); payload.push(b'$'); diff --git a/crates/tmux-mcp/src/exec/tests.rs b/crates/tmux-mcp/src/exec/tests.rs index 5db6f7e0..5433dc95 100644 --- a/crates/tmux-mcp/src/exec/tests.rs +++ b/crates/tmux-mcp/src/exec/tests.rs @@ -198,8 +198,9 @@ case $- in case $- in *e*) \set +e +\trap : INT QUIT if {separator} && {opening}; then -( \set -e; \eval '\set -x +( \trap - INT QUIT; \set -e; \eval '\set -x printf body # trailing comment' ) \set -- "$?" {separator} @@ -208,8 +209,9 @@ fi ;; *) \set +e +\trap : INT QUIT if {separator} && {opening}; then -( \set +e; \eval '\set -x +( \trap - INT QUIT; \set +e; \eval '\set -x printf body # trailing comment' ) \set -- "$?" {separator} @@ -222,8 +224,9 @@ esac case $- in *e*) \set +e +\trap : INT QUIT if {separator} && {opening}; then -( \set -e; \eval 'printf body # trailing comment' ) +( \trap - INT QUIT; \set -e; \eval 'printf body # trailing comment' ) \set -- "$?" {separator} {closing} @@ -231,8 +234,9 @@ fi ;; *) \set +e +\trap : INT QUIT if {separator} && {opening}; then -( \set +e; \eval 'printf body # trailing comment' ) +( \trap - INT QUIT; \set +e; \eval 'printf body # trailing comment' ) \set -- "$?" {separator} {closing} diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index cf3df4ec..61d077f6 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -18,6 +18,13 @@ use super::pane_input::{MissingSource, PaneInputReach, active_run_error}; /// Numbers temporary paste buffers so concurrent calls cannot share one. static PASTE_BUFFER_COUNTER: AtomicU64 = AtomicU64::new(0); +/// Keys that pass an active run's reservation when sent alone. +/// +/// Compared exactly: any other spelling is ordinary input and waits for the +/// run. The run frame survives both, so the command's completion is still +/// proved and the reservation released. +const INTERRUPT_KEYS: [&str; 2] = ["C-c", "C-\\"]; + fn literal_input(pane: &str, text: String) -> Command { Command::new("send-keys") .arg("-t") @@ -118,26 +125,33 @@ impl TmuxTools { if text.is_none() && keys.is_empty() && !enter { return Err(bad_input("send_keys needs text, keys, or enter".to_owned())); } - - let initial = self - .preflight_pane_input( - &pane, - PaneInputReach::Synchronized, - MissingSource::CallerInput, - ) - .await?; - let reservation = initial - .reserve() - .ok_or_else(|| active_run_error(initial.target.id().as_ref()))?; - let plan = self - .preflight_reserved_pane_input( - &pane, - PaneInputReach::Synchronized, - MissingSource::CallerInput, - &reservation, - ) - .await?; - if !initial.same_authority(&plan) || !plan.owns(&reservation) { + let interrupt = text.is_none() + && !enter + && keys + .iter() + .all(|key| INTERRUPT_KEYS.contains(&key.as_str())); + + let reach = PaneInputReach::Synchronized; + let missing = MissingSource::CallerInput; + let (initial, reservation, plan) = if interrupt { + let initial = self.preflight_interrupt(&pane, reach, missing).await?; + let plan = self.preflight_interrupt(&pane, reach, missing).await?; + (initial, None, plan) + } else { + let initial = self.preflight_pane_input(&pane, reach, missing).await?; + let reservation = initial + .reserve() + .ok_or_else(|| active_run_error(initial.target.id().as_ref()))?; + let plan = self + .preflight_reserved_pane_input(&pane, reach, missing, &reservation) + .await?; + (initial, Some(reservation), plan) + }; + if !initial.same_authority(&plan) + || reservation + .as_ref() + .is_some_and(|reservation| !plan.owns(reservation)) + { return Err(bad_input(format!( "pane {pane} changed its configured input authority before send dispatch" ))); @@ -325,8 +339,9 @@ impl TmuxTools { dispatch. Before input, the configured synchronized-pane cohort is \ observed; a dead, \ input-disabled, mode-owned, terminal-attended, or inherited-caller member \ - refuses the whole call. Returned pane IDs describe configured membership, \ - not confirmed delivery. The \ + refuses the whole call, and so does an active run_shell_command, except \ + for keys C-c or C-\\ sent alone, which interrupt it. Returned pane IDs \ + describe configured membership, not confirmed delivery. The \ observation can race with tmux processing the input.", title = "Send Keys To Pane", meta = crate::capability_meta!(Execute, PaneInput, [Change], [TmuxMetadata], true, true, { diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index d0cd885e..ea67840d 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -225,7 +225,9 @@ impl TmuxTools { race with dispatch. The pane shell, tmux server, and configuration must be \ trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops \ this request while its watcher keeps the reservation until completion is \ - proved.", + proved. To stop the command, send_keys with keys [\"C-c\"] alone passes \ + the reservation, and the command reports completion when it ends; \ + respawn_pane with kill_first replaces a program that ignores C-c and C-\\.", title = "Run Command In Pane", meta = crate::capability_meta!(Execute, PaneCommand, [Change], [TmuxMetadata, TerminalContent], true, true, { "pane" => [TmuxLookup], diff --git a/crates/tmux-mcp/src/tools/pane_input.rs b/crates/tmux-mcp/src/tools/pane_input.rs index 7e9f7d4d..100cff28 100644 --- a/crates/tmux-mcp/src/tools/pane_input.rs +++ b/crates/tmux-mcp/src/tools/pane_input.rs @@ -162,7 +162,9 @@ fn missing_source_error(pane: &str, missing: MissingSource) -> ToolError { pub(crate) fn active_run_error(pane: &str) -> ToolError { ErrorData::internal_error( format!( - "pane {pane} has an active run_shell_command; wait for its completion or pane closure before sending more input" + "pane {pane} has an active run_shell_command; wait for it to complete, or stop it \ + with send_keys keys [\"C-c\"], which passes the reservation and releases it once \ + the command ends" ), Some(serde_json::json!({ "kind": "active_run", @@ -351,19 +353,30 @@ fn pane_snapshot(panes: &[libtmux::Pane]) -> Result Ok(PaneSnapshot { handles, members }) } +/// How pane input treats a pane an active `run_shell_command` reserves. +#[derive(Clone, Copy)] +enum RunGate<'a> { + /// Refuse it, unless this input holds the reservation. + Respect(Option<&'a PaneReservation>), + /// Pass it: an interrupt is meant to reach the running command. + Interrupt, +} + fn validate_configured_members( configured: &[String], members: &BTreeMap, attended: &BTreeSet, generation: ServerGeneration, endpoint: &Path, - reservation: Option<&PaneReservation>, + gate: RunGate<'_>, ) -> Result<(), ToolError> { for id in configured { let candidate = members .get(id) .ok_or_else(|| pane_snapshot_error("a selected pane disappeared"))?; - if run_request::is_reserved(generation, endpoint, id, reservation) { + if let RunGate::Respect(reservation) = gate + && run_request::is_reserved(generation, endpoint, id, reservation) + { return Err(active_run_error(id)); } if attended.contains(id) { @@ -433,7 +446,7 @@ impl TmuxTools { reach: PaneInputReach, missing: MissingSource, ) -> Result { - self.preflight_pane_input_with_run(pane, reach, missing, None) + self.preflight_pane_input_with_run(pane, reach, missing, RunGate::Respect(None)) .await } @@ -444,7 +457,25 @@ impl TmuxTools { missing: MissingSource, reservation: &PaneReservation, ) -> Result { - self.preflight_pane_input_with_run(pane, reach, missing, Some(reservation)) + self.preflight_pane_input_with_run( + pane, + reach, + missing, + RunGate::Respect(Some(reservation)), + ) + .await + } + + /// Check an interrupt, which passes an active run's reservation. + /// + /// Every other refusal still applies. + pub(crate) async fn preflight_interrupt( + &self, + pane: &str, + reach: PaneInputReach, + missing: MissingSource, + ) -> Result { + self.preflight_pane_input_with_run(pane, reach, missing, RunGate::Interrupt) .await } @@ -453,7 +484,7 @@ impl TmuxTools { pane: &str, reach: PaneInputReach, missing: MissingSource, - reservation: Option<&PaneReservation>, + reservation: RunGate<'_>, ) -> Result { let generation = self .server diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index b813fd8f..fc40d0a1 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -1625,6 +1625,163 @@ async fn real_tmux_compat_run_shell_command_reports_output_status_and_cancellati guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A run that outlives its deadline keeps its pane reserved, and the recovery +/// the README prescribes has to reach it: `C-c` alone passes the reservation, +/// the frame survives the interrupt to report completion, and the pane is +/// free for the next run. Before this the interrupt was refused, and when it +/// did land the whole frame died without a marker, reserving the pane until +/// it closed. +#[tokio::test] +async fn an_interrupt_ends_a_run_that_outlived_its_deadline() { + for (shell, flags) in [ + ("/bin/sh", None), + ("/bin/bash", Some("--noprofile --norc")), + ("/bin/zsh", Some("-f")), + ] { + if !std::path::Path::new(shell).is_file() { + continue; + } + let shell_name = shell.rsplit('/').next().expect("shell basename"); + let (guard, tools, pane) = typing_fixture(&format!("interrupt-{shell_name}")).await; + if let Some(flags) = flags { + pane_handle(guard.server(), &pane) + .await + .respawn( + Some(&format!("exec {shell} {flags}")), + libtmux::Respawn::Replacing, + ) + .await + .expect("fixture pane changes shell"); + libtmux::test::retry_until(Duration::from_secs(2), async || { + pane_handle(guard.server(), &pane) + .await + .current_command() + .is_some_and(|command| command.as_bytes() == shell_name.as_bytes()) + }) + .await + .unwrap_or_else(|_| panic!("{shell_name} becomes the foreground shell")); + prompt_ready(guard.server(), &pane).await; + } + let baseline = client_count(guard.server()).await; + // `sleep`, not `tmux wait-for`: a tmux command client ignores SIGINT. + let started = format!("mcp-interrupt-{shell_name}-started"); + let request = tokio::spawn({ + let tools = tools.clone(); + let pane = pane.clone(); + let command = format!("tmux wait-for -S {started}; sleep 30"); + async move { + tools + .run_command( + args(serde_json::json!({ + "pane": pane, + "command": command, + "seconds": 1 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .map_err(tmux_mcp::ToolError::into_error_data) + } + }); + await_channel(guard.server(), &started).await; + let stopped = json(request.await.expect("request joins").expect("run answers")); + assert_eq!(stopped["outcome"], "deadline", "{shell_name}"); + let refused = tools + .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) + .await + .err() + .expect("other input waits for the run") + .into_error_data(); + assert_active_run(&refused, shell_name); + + tools + .send_keys(args(serde_json::json!({"pane": pane, "keys": ["C-c"]}))) + .await + .unwrap_or_else(|error| { + panic!( + "{shell_name}: the interrupt was refused: {}", + error.into_error_data() + ) + }); + assert_eq!( + clients_settle(guard.server(), baseline).await, + baseline, + "{shell_name}: the interrupted run never proved its completion" + ); + libtmux::test::retry_until(Duration::from_secs(5), async || { + tools + .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) + .await + .is_ok() + }) + .await + .unwrap_or_else(|_| panic!("{shell_name}: the reservation outlived the run")); + prompt_ready(guard.server(), &pane).await; + assert_eq!( + run_view(&tools, &pane, "true").await["exit_status"], + 0, + "{shell_name}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); + } +} + +/// A command that ignores both interrupts still leaves an escape short of +/// teardown: respawning the pane replaces the shell that ran the frame, and +/// the new process is the proof the run is over. +#[tokio::test] +async fn respawning_releases_a_run_that_ignores_interrupts() { + let (guard, tools, pane) = typing_fixture("interrupt-ignored").await; + let started = "mcp-interrupt-ignored-started"; + let request = tokio::spawn({ + let tools = tools.clone(); + let pane = pane.clone(); + async move { + tools + .run_command( + args(serde_json::json!({ + "pane": pane, + "command": format!("trap '' INT QUIT; tmux wait-for -S {started}; sleep 30"), + "seconds": 1 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .map_err(tmux_mcp::ToolError::into_error_data) + } + }); + await_channel(guard.server(), started).await; + let stopped = json(request.await.expect("request joins").expect("run answers")); + assert_eq!(stopped["outcome"], "deadline"); + tools + .send_keys(args(serde_json::json!({"pane": pane, "keys": ["C-c"]}))) + .await + .expect("an interrupt passes the reservation"); + + let respawned = call_tool( + tools.clone(), + "respawn_pane", + serde_json::json!({"pane": pane, "kill_first": true}), + ) + .await; + assert_ne!(respawned.is_error, Some(true), "{respawned:?}"); + libtmux::test::retry_until(Duration::from_secs(5), async || { + tools + .paste_text(args(serde_json::json!({"pane": pane, "text": ""}))) + .await + .is_ok() + }) + .await + .expect("respawning released the reservation"); + prompt_ready(guard.server(), &pane).await; + assert_eq!(run_view(&tools, &pane, "true").await["exit_status"], 0); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A server that exited is an answer about its panes, not a fault. /// /// The settlement test above reaches this branch only when tmux declines to From 621eff7f51feae2fa2bc483d7c200adbd8f5bbe2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 01:23:08 -0500 Subject: [PATCH 076/117] tmux-mcp(fix[session]): Accept session ids why: The initialize instructions tell an agent to prefer $ ids, and list_sessions returns them, but every tool that named a session matched names only. The id came back as object_gone with stale set, which told the agent to list again, and the listing showed the same id. what: - find_session looks a $ id up as one and anything else up by name; text starting with $ that is neither is invalid_input - option_scope uses it rather than a copy that already took both - The nine session inputs say they take an id or a name - TOOLS.md, CHANGELOG --- crates/tmux-mcp/src/model.rs | 11 ++-- crates/tmux-mcp/src/tools/contract.rs | 7 +++ crates/tmux-mcp/src/tools/mod.rs | 49 +++++++++------- crates/tmux-mcp/tests/agent.rs | 82 ++++++++++++++++++++++++++- 4 files changed, 125 insertions(+), 24 deletions(-) diff --git a/crates/tmux-mcp/src/model.rs b/crates/tmux-mcp/src/model.rs index 780604e8..882ded6d 100644 --- a/crates/tmux-mcp/src/model.rs +++ b/crates/tmux-mcp/src/model.rs @@ -10,7 +10,8 @@ use crate::schema::{ #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct SessionArgs { - /// The session name, as `list_sessions` reports it. + /// The session, by `$`-prefixed id as `list_sessions` reports it, or by + /// name. pub session: String, } @@ -82,7 +83,8 @@ pub struct RunCommandArgs { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct ShowEnvironmentArgs { - /// The session whose environment to read. Omit for the server's own. + /// The session whose environment to read, by `$`-prefixed id or name. + /// Omit for the server's own. pub session: Option, } @@ -90,7 +92,8 @@ pub struct ShowEnvironmentArgs { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct ShowHooksArgs { - /// The session whose hooks to read. Omit for the server's own. + /// The session whose hooks to read, by `$`-prefixed id or name. Omit for + /// the server's own. pub session: Option, } @@ -186,7 +189,7 @@ pub struct SearchPanesArgs { /// Search scrollback as well as the visible screen. #[serde(default)] pub history: bool, - /// Only search panes in this session, by name. + /// Only search panes in this session, by `$`-prefixed id or name. pub session: Option, /// Only search panes in this window, by `@`-prefixed id. pub window: Option, diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index ea33a5b6..09a5054c 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -28,6 +28,7 @@ const READ_BATCH_TRUNCATED_ERROR: &str = #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RenameSessionArgs { + /// The session to rename, by `$`-prefixed id or name. pub(crate) session: String, pub(crate) name: String, } @@ -51,6 +52,7 @@ pub(crate) struct WindowSizeArgs { #[serde(deny_unknown_fields)] pub(crate) struct MoveWindowArgs { pub(crate) window: String, + /// The session to move the window into, by `$`-prefixed id or name. pub(crate) destination_session: String, pub(crate) destination_index: i32, } @@ -95,6 +97,8 @@ pub struct VariablesValue { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SessionFlagArgs { + /// The session to change, by `$`-prefixed id or name. Omit to change the + /// global default. pub(crate) session: Option, pub(crate) enabled: bool, } @@ -102,6 +106,8 @@ pub(crate) struct SessionFlagArgs { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct HistoryLimitArgs { + /// The session to change, by `$`-prefixed id or name. Omit to change the + /// global default. pub(crate) session: Option, pub(crate) limit: u32, } @@ -123,6 +129,7 @@ pub(crate) struct SettingChanged { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct CreateWindowArgs { + /// The session to create the window in, by `$`-prefixed id or name. pub(crate) session: String, pub(crate) name: Option, pub(crate) start_directory: Option, diff --git a/crates/tmux-mcp/src/tools/mod.rs b/crates/tmux-mcp/src/tools/mod.rs index 72cb6c63..4aa429f2 100644 --- a/crates/tmux-mcp/src/tools/mod.rs +++ b/crates/tmux-mcp/src/tools/mod.rs @@ -245,20 +245,10 @@ impl TmuxTools { Some("server") => Ok(OptionScope::Server), None | Some("global-session") => Ok(OptionScope::GlobalSession), Some("global-window") => Ok(OptionScope::GlobalWindow), - Some("session") => { - let target = target.ok_or_else(|| needs("session"))?; - let session = self - .server - .sessions() - .await - .map_err(|e| tmux_error(&e))? - .into_iter() - .find(|session| { - session.id().to_string() == target || session.name() == target.as_bytes() - }) - .ok_or_else(|| bad_input(format!("no session {target}")))?; - Ok(OptionScope::Session(Box::new(session))) - } + Some("session") => Ok(OptionScope::Session(Box::new( + self.find_session(target.ok_or_else(|| needs("session"))?) + .await?, + ))), Some("window") => Ok(OptionScope::Window(Box::new( self.find_window(target.ok_or_else(|| needs("window"))?) .await?, @@ -451,14 +441,35 @@ impl TmuxTools { .ok_or_else(|| object_gone("pane", id)) } - /// Resolve a session by name, reporting an unknown one as invalid input. - pub(super) async fn find_session(&self, name: &str) -> Result { - self.server + /// Resolve a session by `$`-prefixed id or by name. + /// + /// An id is looked up as one, because the server's instructions tell an + /// agent to prefer ids. Text that starts with `$` and is neither an id + /// nor a session's name is invalid input, not a session that went away. + pub(super) async fn find_session(&self, target: &str) -> Result { + if target.starts_with('$') + && let Ok(id) = target.parse::() + { + return self + .server + .session_by_id(&id) + .await + .map_err(|e| tmux_error(&e))? + .ok_or_else(|| object_gone("session", target)); + } + let named = self + .server .sessions() .await .map_err(|e| tmux_error(&e))? .into_iter() - .find(|session| session.name() == name.as_bytes()) - .ok_or_else(|| object_gone("session", name)) + .find(|session| session.name() == target.as_bytes()); + match named { + Some(session) => Ok(session), + None if target.starts_with('$') => Err(bad_input(format!( + "{target} is not a session id or name: an id is $ followed by digits, as in $1" + ))), + None => Err(object_gone("session", target)), + } } } diff --git a/crates/tmux-mcp/tests/agent.rs b/crates/tmux-mcp/tests/agent.rs index fc40d0a1..cc450d12 100644 --- a/crates/tmux-mcp/tests/agent.rs +++ b/crates/tmux-mcp/tests/agent.rs @@ -17,7 +17,7 @@ use libtmux::{ SplitOptions, }; use serde_json::Value; -use tmux_mcp::{CallerIdentity, TmuxTools}; +use tmux_mcp::{CallerIdentity, Selection, TmuxTools}; use tokio_util::sync::CancellationToken; mod support; @@ -3741,3 +3741,83 @@ async fn window_selection_uses_core_fixture_setup() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// The server's instructions tell an agent to prefer ids, so every tool that +/// names a session takes the id `list_sessions` returned, not only its name. +#[tokio::test] +async fn every_session_input_accepts_the_listed_id() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let tools = TmuxTools::builder(server.clone()) + .caller(None) + .selection( + Selection::parse(Some("inspect,manage,execute,teardown"), None, None) + .expect("every toolset"), + ) + .build(); + server.new_session("by-id").await.expect("session starts"); + let donor = server.new_session("donor").await.expect("donor starts"); + let moving = donor + .new_window(NewWindowOptions::new("moving")) + .await + .expect("donor gains a second window") + .id() + .to_string(); + let listed = call_tool(tools.clone(), "list_sessions", serde_json::json!({})).await; + let id = listed.structured_content.expect("session listing")["sessions"] + .as_array() + .expect("sessions") + .iter() + .find(|session| session["name"] == "by-id") + .expect("the session is listed")["id"] + .as_str() + .expect("session id") + .to_owned(); + + for (tool, arguments) in [ + ("get_session_info", serde_json::json!({"session": id})), + ("show_environment", serde_json::json!({"session": id})), + ("show_hooks", serde_json::json!({"session": id})), + ( + "search_panes", + serde_json::json!({"pattern": "unmatched", "session": id}), + ), + ( + "show_option", + serde_json::json!({"name": "status", "scope": "session", "target": id}), + ), + ( + "set_history_limit", + serde_json::json!({"session": id, "limit": 5000}), + ), + ( + "set_mouse_enabled", + serde_json::json!({"session": id, "enabled": true}), + ), + ("create_window", serde_json::json!({"session": id})), + ( + "move_window", + serde_json::json!({"window": moving, "destination_session": id, "destination_index": 7}), + ), + ( + "rename_session", + serde_json::json!({"session": id, "name": "renamed"}), + ), + ("kill_session", serde_json::json!({"session": id})), + ] { + let result = call_tool(tools.clone(), tool, arguments).await; + assert_ne!(result.is_error, Some(true), "{tool}: {result:?}"); + } + + let malformed = call_tool( + tools, + "get_session_info", + serde_json::json!({"session": "$not-an-id"}), + ) + .await; + let wire = serde_json::to_string(&malformed).expect("result serializes"); + assert_eq!(malformed.is_error, Some(true), "{wire}"); + assert!(wire.contains("invalid_input"), "{wire}"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From a6c55deac6fce4fa77fc8085e2ac317664fe8753 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 01:31:08 -0500 Subject: [PATCH 077/117] tmux-mcp(fix[caller]): Read empty TMUX as absent why: TMUX= TMUX_PANE= is the usual way to un-nest tmux, and read as a malformed caller context. Startup logged its normal line, and then every send_keys, paste_text and run_shell_command refused with self_protection and a remedy only the operator could apply, after four tmux processes each. what: - CallerIdentity::from_values treats an empty variable as absent, so two empty variables are detached; one set beside an empty one is still partial and still refused - A context that is set and cannot be parsed is reported once on stderr at startup; the per-call refusal stays, since it guards the caller's own pane - CallerIdentity::is_malformed and TmuxTools::caller_is_malformed - README, CHANGELOG --- crates/tmux-mcp/README.md | 7 +++-- crates/tmux-mcp/src/bin/tmux-mcp.rs | 7 +++++ crates/tmux-mcp/src/caller.rs | 32 +++++++++++++++++++-- crates/tmux-mcp/src/tools/mod.rs | 9 ++++++ crates/tmux-mcp/tests/binary.rs | 44 +++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 3a198032..d886a09f 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -225,9 +225,10 @@ determine the effective configured recipient cohort. `send_keys` observes that cohort immediately before input and refuses the whole call if any configured pane is dead, input-disabled, in a tmux mode, attended by a non-control client, reserved by an active MCP run, or may be the inherited caller. Malformed state -fails closed. Caller context is detached only when both `TMUX` and `TMUX_PANE` -are absent; partial, empty, noncanonical, stale, or unresolved selected-daemon -context refuses input. `send_keys_batch` repeats the complete check for each +fails closed. Caller context is detached when `TMUX` and `TMUX_PANE` are both +absent or empty; partial, noncanonical, stale, or unresolved selected-daemon +context refuses input, and a context that cannot be parsed is also reported +once in the startup log. `send_keys_batch` repeats the complete check for each executed row. Returned pane IDs describe configured membership and do not prove delivery. `paste_text` applies the same refusals to its named target before buffer diff --git a/crates/tmux-mcp/src/bin/tmux-mcp.rs b/crates/tmux-mcp/src/bin/tmux-mcp.rs index 7ff8c3fd..b4fa14bf 100644 --- a/crates/tmux-mcp/src/bin/tmux-mcp.rs +++ b/crates/tmux-mcp/src/bin/tmux-mcp.rs @@ -291,6 +291,13 @@ async fn serve(options: Options) -> Result<(), Box> { .map(|pane| format!(", from pane {pane}")) .unwrap_or_default(), ); + if tools.caller_is_malformed() { + eprintln!( + "tmux-mcp: TMUX and TMUX_PANE do not describe one tmux pane, so pane-input and \ + teardown tools will refuse every call; unset both, or start tmux-mcp from a tmux \ + pane" + ); + } let (stdin, stdout) = stdio(); let transport = RequestIdTransport::new(AsyncRwTransport::::new_server( diff --git a/crates/tmux-mcp/src/caller.rs b/crates/tmux-mcp/src/caller.rs index f9663584..f2a39a52 100644 --- a/crates/tmux-mcp/src/caller.rs +++ b/crates/tmux-mcp/src/caller.rs @@ -39,7 +39,8 @@ pub enum Relation { /// /// `TMUX` carries `socket_path,server_pid,session_number`; `TMUX_PANE` carries /// the pane id. Either both variables form one complete identity or the -/// context is malformed; only two absent variables mean detached operation. +/// context is malformed; only two absent variables mean detached operation, +/// and an empty variable counts as absent. #[derive(Clone, Eq, PartialEq)] pub struct CallerIdentity { socket: Option, @@ -90,6 +91,8 @@ impl CallerIdentity { /// without a process-wide environment, which no test can hold alone. #[must_use] pub fn from_values(tmux: Option, pane: Option) -> Option { + let tmux = tmux.filter(|value| !value.is_empty()); + let pane = pane.filter(|value| !value.is_empty()); let detached = tmux.is_none() && pane.is_none(); if detached { return None; @@ -134,6 +137,15 @@ impl CallerIdentity { }) } + /// Whether the variables were set but do not describe one tmux pane. + /// + /// Pane-input and teardown tools refuse every call while this holds, + /// because they cannot rule out reaching the caller's own pane. + #[must_use] + pub const fn is_malformed(&self) -> bool { + self.malformed + } + /// The pane this process runs in, when tmux named one. #[must_use] pub fn pane_id(&self) -> Option<&str> { @@ -241,11 +253,11 @@ mod tests { use std::os::unix::ffi::OsStringExt as _; #[test] - fn any_present_caller_variable_is_not_detached() { + fn any_nonempty_caller_variable_is_not_detached() { for (tmux, pane) in [ - (Some(OsString::new()), Some(OsString::new())), (Some(OsString::from("/tmp/socket,1,0")), None), (None, Some(OsString::from("%0"))), + (Some(OsString::new()), Some(OsString::from("%0"))), ( Some(OsString::from("/tmp/socket,not-a-pid,0")), Some(OsString::from("%0")), @@ -254,6 +266,20 @@ mod tests { let caller = CallerIdentity::from_values(tmux, pane) .expect("only two absent variables represent a detached caller"); assert!(caller.malformed); + assert!(caller.is_malformed()); + } + } + + /// `TMUX= TMUX_PANE=` is how a shell un-nests tmux, and means the same as + /// unsetting both. + #[test] + fn empty_caller_variables_are_detached() { + for (tmux, pane) in [ + (Some(OsString::new()), Some(OsString::new())), + (Some(OsString::new()), None), + (None, Some(OsString::new())), + ] { + assert_eq!(CallerIdentity::from_values(tmux, pane), None); } } diff --git a/crates/tmux-mcp/src/tools/mod.rs b/crates/tmux-mcp/src/tools/mod.rs index 4aa429f2..22deb5e8 100644 --- a/crates/tmux-mcp/src/tools/mod.rs +++ b/crates/tmux-mcp/src/tools/mod.rs @@ -368,6 +368,15 @@ impl TmuxTools { self.caller.as_ref().and_then(|caller| caller.pane_id()) } + /// Whether the inherited caller context is set but malformed, which makes + /// pane-input and teardown tools refuse every call. + #[must_use] + pub fn caller_is_malformed(&self) -> bool { + self.caller + .as_ref() + .is_some_and(|caller| caller.is_malformed()) + } + /// Classify a refusal that protects the pane this process talks through. pub(super) fn self_protection(message: String) -> ToolError { ErrorData::invalid_params( diff --git a/crates/tmux-mcp/tests/binary.rs b/crates/tmux-mcp/tests/binary.rs index 7783a6cf..39462c2b 100644 --- a/crates/tmux-mcp/tests/binary.rs +++ b/crates/tmux-mcp/tests/binary.rs @@ -564,6 +564,50 @@ fn default_daemon_loads_the_shipped_minimal_configuration() { std::fs::remove_dir_all(root).expect("fixture cleanup"); } +/// `TMUX= TMUX_PANE=` is how a shell un-nests tmux; it means detached. A +/// context that is set and malformed says so once, at startup. +#[test] +fn empty_caller_variables_are_detached_and_malformed_ones_are_logged() { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let (guard, pane) = runtime.block_on(async { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let pane = guard + .server() + .new_session("caller") + .await + .expect("session starts") + .panes() + .await + .expect("panes list") + .remove(0) + .id() + .to_string(); + (guard, pane) + }); + let socket = guard.socket_path().to_str().expect("UTF-8 socket"); + let typed = json!({"name": "send_keys", "arguments": {"pane": pane, "text": "x"}}); + + let mut empty = Process::start(&["--socket", socket], &[("TMUX", ""), ("TMUX_PANE", "")]); + let sent = empty.request("tools/call", &typed); + let empty_log = empty.finish(); + assert_ne!(sent["result"]["isError"], true, "{sent}"); + assert!(!empty_log.contains("TMUX_PANE"), "{empty_log}"); + + let mut malformed = Process::start( + &["--socket", socket], + &[("TMUX", "not-a-context"), ("TMUX_PANE", "%0")], + ); + let refused = malformed.request("tools/call", &typed); + let malformed_log = malformed.finish(); + assert_eq!(refused["result"]["isError"], true, "{refused}"); + assert_eq!( + malformed_log.matches("TMUX and TMUX_PANE").count(), + 1, + "{malformed_log}" + ); + runtime.block_on(async { guard.shutdown().await.expect("tmux stops") }); +} + /// Measured over JSON-RPC, because a transcript is where a value leaks to. /// /// The fixture daemon inherits this test's environment, so no failure message From 687c4617b6d81d31c0951df41311103409a8a7b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 01:44:58 -0500 Subject: [PATCH 078/117] tmux-mcp(fix[errors]): Type errors rmcp raises why: The README promises kind, retryable and stale on every error, but rmcp answers arguments that do not deserialize with a failed result holding only its own message, and any unrouted name with a bare -32602 "tool not found". A tool the startup selection withheld and a typo read the same, with no hint that LIBTMUX_TOOLSETS would help, and --help listed no environment variable at all. what: - call_tool is ours: an unrouted name is invalid_input, and says whether the selection left the tool out or no such tool exists - A failed result without the ToolError body is rmcp's argument error and gets that body with invalid_input, at the top level and for rows of call_read_tools_batch; other protocol errors get the three fields - --help gains an ENVIRONMENT section for the seven LIBTMUX_ settings - README, CHANGELOG The ToolError body still omits the JSON-RPC code the error.rs comment describes; no caller asked for it here. --- crates/tmux-mcp/README.md | 6 +- crates/tmux-mcp/src/cli.rs | 19 ++++++ crates/tmux-mcp/src/lib.rs | 21 +++++++ crates/tmux-mcp/src/tools/contract.rs | 3 +- crates/tmux-mcp/src/tools/error.rs | 59 ++++++++++++++++++- crates/tmux-mcp/tests/binary.rs | 83 ++++++++++++++++++++++++++- 6 files changed, 186 insertions(+), 5 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index d886a09f..2397b198 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -426,7 +426,7 @@ $ tmux-mcp --socket-name work `-S` and `-L` work too. `LIBTMUX_SOCKET_PATH` and `LIBTMUX_SOCKET` provide the same startup choices. Set `LIBTMUX_TMUX_CONFIG` to use an explicit tmux configuration at an absolute path. Socket names and paths are mutually -exclusive. `tmux-mcp --help` lists every flag. +exclusive. `tmux-mcp --help` lists every flag and environment variable. ## Trust boundary @@ -563,7 +563,9 @@ an agent reads fields rather than parsing text: ``` Failures are typed too. Every error carries the same three fields, so an agent -decides what to do next without reading prose: +decides what to do next without reading prose. That includes arguments that +do not match a tool's schema, and a call to a tool the startup selection left +out, whose message names the settings that offer it: ```json {"kind": "object_gone", "retryable": false, "stale": true} diff --git a/crates/tmux-mcp/src/cli.rs b/crates/tmux-mcp/src/cli.rs index 2c1544e9..e3c98a48 100644 --- a/crates/tmux-mcp/src/cli.rs +++ b/crates/tmux-mcp/src/cli.rs @@ -45,6 +45,25 @@ pub const HELP: &str = concat!( "\n", "Without -S or -L the server uses the dedicated libtmux-mcp socket.\n", "\n", + "ENVIRONMENT:\n", + " LIBTMUX_SOCKET_PATH Socket path, as -S; ignored when -S or -L is given\n", + " LIBTMUX_SOCKET Socket name, as -L; not together with\n", + " LIBTMUX_SOCKET_PATH\n", + " LIBTMUX_TMUX_CONFIG Absolute path of a tmux configuration file for a\n", + " daemon this starts\n", + " LIBTMUX_TOOLSETS Comma-separated toolsets: inspect, manage, execute,\n", + " teardown. Default: all four when this process\n", + " started the dedicated daemon, the first three\n", + " otherwise; empty offers none\n", + " LIBTMUX_TOOLS Comma-separated tools to add to the toolsets\n", + " LIBTMUX_EXCLUDE_TOOLS Comma-separated tools to remove; wins over both\n", + " LIBTMUX_ENVIRONMENT_VALUES\n", + " Comma-separated tmux environment variables whose\n", + " values tools may return. Default: none\n", + "\n", + "A tool left out by selection is neither listed nor callable. A malformed\n", + "setting stops startup.\n", + "\n", "The protocol runs on stdout, so anything this prints for a person goes to\n", "stderr, where an MCP client collects it as the server's log.\n", ); diff --git a/crates/tmux-mcp/src/lib.rs b/crates/tmux-mcp/src/lib.rs index 63b2fc03..b6fc5218 100644 --- a/crates/tmux-mcp/src/lib.rs +++ b/crates/tmux-mcp/src/lib.rs @@ -129,6 +129,27 @@ const INSTRUCTIONS: &str = concat!( #[tool_handler(router = self.tool_router)] impl ServerHandler for TmuxTools { + async fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + if !self.tool_router.has_route(&request.name) { + return Err(tools::error::unoffered_tool( + &request.name, + tools::router().has_route(&request.name), + )); + } + let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + match self.tool_router.call(call).await { + Ok(rmcp::model::CallToolResponse::Complete(result)) => Ok( + rmcp::model::CallToolResponse::Complete(tools::error::typed_result(result)), + ), + Ok(other) => Ok(other), + Err(error) => Err(tools::error::typed_protocol_error(error)), + } + } + async fn list_resources( &self, _request: Option, diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 09a5054c..a2257098 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -1171,6 +1171,7 @@ impl TmuxTools { .await { Ok(rmcp::model::CallToolResponse::Complete(result)) => { + let result = super::error::typed_result(result); let failed = result.is_error == Some(true); if !batch.push(BatchItem { index, @@ -1213,11 +1214,11 @@ impl TmuxTools { Err(error) => { if !batch.push(BatchItem { index, + error: Some(super::error::typed_protocol_error(error)), tool, success: false, result: None, result_truncated: false, - error: Some(error), }) { break; } diff --git a/crates/tmux-mcp/src/tools/error.rs b/crates/tmux-mcp/src/tools/error.rs index d9a18eb5..c45e166b 100644 --- a/crates/tmux-mcp/src/tools/error.rs +++ b/crates/tmux-mcp/src/tools/error.rs @@ -1,4 +1,4 @@ -use rmcp::model::{ContentBlock, ErrorData, IntoContents}; +use rmcp::model::{CallToolResult, ContentBlock, ErrorCode, ErrorData, IntoContents}; /// A tool-execution failure, reported as `isError` tool content. /// @@ -202,6 +202,63 @@ pub(super) fn bad_input(message: impl Into) -> ToolError { .into() } +/// Give a failed result rmcp answered itself the body every tool failure has. +/// +/// rmcp reports arguments that do not deserialize as a failed result whose +/// only content is its own message. Every tool here fails through +/// [`ToolError`], whose content is a JSON object carrying `data`, so a failed +/// result without one is rmcp's, and it concerned the arguments. +pub(crate) fn typed_result(mut result: CallToolResult) -> CallToolResult { + if result.is_error != Some(true) { + return result; + } + let [block] = result.content.as_slice() else { + return result; + }; + let Some(text) = block.as_text() else { + return result; + }; + let typed = serde_json::from_str::(&text.text) + .is_ok_and(|body| body.get("data").is_some_and(serde_json::Value::is_object)); + if !typed { + result.content = bad_input(text.text.clone()).into_contents(); + } + result +} + +/// Give a protocol error rmcp raised before any tool ran the three fields. +pub(crate) fn typed_protocol_error(mut error: ErrorData) -> ErrorData { + if error.data.is_none() { + let kind = if error.code == ErrorCode::INVALID_PARAMS { + "invalid_input" + } else { + "internal" + }; + error.data = Some(serde_json::json!({ + "kind": kind, + "retryable": false, + "stale": false, + })); + } + error +} + +/// Refuse a tool name this process does not serve. +/// +/// The name is either a tool the startup selection left out or no tool at +/// all, and the fix differs: only the operator can offer the first. +pub(crate) fn unoffered_tool(tool: &str, exists: bool) -> ErrorData { + let message = if exists { + format!( + "tool {tool} is not offered: this server's startup selection left it out; the \ + operator can add it with LIBTMUX_TOOLSETS or LIBTMUX_TOOLS" + ) + } else { + format!("no tool {tool}") + }; + bad_input(message).into_error_data() +} + #[cfg(test)] mod tests { use std::time::Duration; diff --git a/crates/tmux-mcp/tests/binary.rs b/crates/tmux-mcp/tests/binary.rs index 39462c2b..72621709 100644 --- a/crates/tmux-mcp/tests/binary.rs +++ b/crates/tmux-mcp/tests/binary.rs @@ -564,6 +564,77 @@ fn default_daemon_loads_the_shipped_minimal_configuration() { std::fs::remove_dir_all(root).expect("fixture cleanup"); } +/// Errors rmcp raises before a tool runs carry the same `kind` as the rest, +/// and a withheld tool says who can offer it. +#[test] +fn argument_and_routing_errors_are_typed() { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let guard = + runtime.block_on(async { TestServer::builder().start().await.expect("tmux starts") }); + let socket = guard.socket_path().to_str().expect("UTF-8 socket"); + let mut process = Process::start(&["--socket", socket], &[]); + + let missing = process.request("tools/call", &json!({"name": "send_keys", "arguments": {}})); + let batched = process.request( + "tools/call", + &json!({ + "name": "call_read_tools_batch", + "arguments": {"operations": [{"tool": "capture_pane", "arguments": {}}]} + }), + ); + let withheld = process.request( + "tools/call", + &json!({"name": "kill_session", "arguments": {"session": "x"}}), + ); + let unknown = process.request( + "tools/call", + &json!({"name": "no_such_tool", "arguments": {}}), + ); + process.finish(); + runtime.block_on(async { guard.shutdown().await.expect("tmux stops") }); + + let body: Value = serde_json::from_str( + missing["result"]["content"][0]["text"] + .as_str() + .expect("failed result text"), + ) + .unwrap_or_else(|error| panic!("untyped argument error {missing}: {error}")); + assert_eq!(missing["result"]["isError"], true, "{missing}"); + assert_eq!(body["data"]["kind"], "invalid_input", "{missing}"); + assert!( + body["message"] + .as_str() + .is_some_and(|text| text.contains("pane")), + "{missing}" + ); + let nested = + batched["result"]["structuredContent"]["results"][0]["result"]["content"][0]["text"] + .as_str() + .expect("nested failed result text"); + assert!(nested.contains("invalid_input"), "{batched}"); + + assert_eq!( + withheld["error"]["data"]["kind"], "invalid_input", + "{withheld}" + ); + assert!( + withheld["error"]["message"] + .as_str() + .is_some_and(|text| text.contains("LIBTMUX_TOOLSETS")), + "{withheld}" + ); + assert_eq!( + unknown["error"]["data"]["kind"], "invalid_input", + "{unknown}" + ); + assert!( + unknown["error"]["message"] + .as_str() + .is_some_and(|text| !text.contains("LIBTMUX_TOOLSETS")), + "{unknown}" + ); +} + /// `TMUX= TMUX_PANE=` is how a shell un-nests tmux; it means detached. A /// context that is set and malformed says so once, at startup. #[test] @@ -685,7 +756,17 @@ fn help_names_current_startup_controls_only() { let output = Command::new(BIN).arg("--help").output().expect("help runs"); let help = String::from_utf8_lossy(&output.stdout); assert!(output.status.success()); - for flag in ["--socket", "--socket-name"] { + for flag in [ + "--socket", + "--socket-name", + "LIBTMUX_SOCKET_PATH", + "LIBTMUX_SOCKET ", + "LIBTMUX_TMUX_CONFIG", + "LIBTMUX_TOOLSETS", + "LIBTMUX_TOOLS ", + "LIBTMUX_EXCLUDE_TOOLS", + "LIBTMUX_ENVIRONMENT_VALUES", + ] { assert!(help.contains(flag), "{flag}"); } for retired in ["--safety", "--confirm", "--no-confirm", "TMUX_MCP_CONFIRM"] { From aa6223b83d8fa0d98ac5a532bed80125018dad2b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 02:13:13 -0500 Subject: [PATCH 079/117] tmux-mcp(fix[text]): End escape strings where tmux does why: The filter behind capture_since, wait_for_text and run_shell_command ended an OSC, APC, PM or DCS only at BEL or ESC \. After `printf 'x\033]y'` it dropped every later byte until a BEL, and the tail ring keeps the filter as a per-pane checkpoint, so capture_since reported a busy pane as silent with missed:false. tmux leaves every string but DCS data at the next ESC, CAN or SUB (INPUT_STATE_ANYWHERE, input.c 367-371 at e880cf63) and recovers. what: - Model the input.c tables: ground, esc_enter (526-550), esc_intermediate (553-564), the csi states (567-629), dcs_enter, dcs_parameter, dcs_intermediate (632-680), dcs_handler and dcs_escape (683-702, no ANYWHERE), osc_string (717-728, BEL ends it), and apc/rename/consume_st/dcs_ignore (705-714, 731-764) - Run C0 bytes inside escape and CSI states, as tmux does - ESC ( B and ESC ) 0 are three-byte sequences, not two - C1 ST (0x9c) stays string data: tmux reads it as input (osc table 0x20-0xff), so honouring it would diverge - Not modelled: the 5 s ground timer (input.c 811-832); a string nothing ends hides text until the next ESC - Replace the test that asserted ESC does not end an OSC; add the capture_since reproduction and one test per table rule --- crates/tmux-mcp/src/text.rs | 187 +++++++++++++++++++++++++++++------- 1 file changed, 151 insertions(+), 36 deletions(-) diff --git a/crates/tmux-mcp/src/text.rs b/crates/tmux-mcp/src/text.rs index 549af048..910560d6 100644 --- a/crates/tmux-mcp/src/text.rs +++ b/crates/tmux-mcp/src/text.rs @@ -10,6 +10,21 @@ //! running into its predecessor. What it cannot do is resolve cursor //! addressing: a program that draws by moving the cursor produces text here in //! the order it was written, not in the order it appears on screen. +//! +//! Where a sequence ends is tmux's call: text swallowed here that tmux showed +//! is output a caller never sees. So each state is one of the transition +//! tables in tmux's `input.c`, named on the variant, merged only where the +//! tables agree on what a reader sees. tmux also abandons a string after five +//! seconds without a terminator (`input_start_ground_timer`); this has no +//! clock, so a string nothing ends hides text until the next `ESC`. + +/// `ESC`, which starts a sequence from every state but device control data. +const ESCAPE: u8 = 0x1b; +/// CAN and SUB, which abandon a sequence wherever `ESC` would start one. +const CANCEL: u8 = 0x18; +const SUBSTITUTE: u8 = 0x1a; +/// BEL, which ends an operating system command and no other string. +const BELL: u8 = 0x07; /// Where the escape-sequence scanner is between chunks. /// @@ -17,18 +32,30 @@ /// the scanner's position outlives a single chunk. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum State { - /// Ordinary text. + /// `ground`: ordinary text. #[default] Text, - /// Just past `ESC`, waiting to learn which kind of sequence this is. + /// `esc_enter`: just past `ESC`. Escape, - /// Inside `ESC [ ... final`, which ends at a byte in `0x40..=0x7e`. + /// `esc_intermediate`: `ESC` then bytes in `0x20..=0x2f`, as in `ESC ( B`. + EscapeIntermediate, + /// `csi_enter`, `csi_parameter`, `csi_intermediate` and `csi_ignore`. ControlSequence, - /// Inside a string sequence (`OSC`, `APC`, `PM`, `DCS`), which ends at - /// `BEL` or at `ESC \`. + /// `dcs_enter`: just past `ESC P`. + DeviceControlEnter, + /// `dcs_parameter`. + DeviceControlParameter, + /// `dcs_intermediate`. + DeviceControlIntermediate, + /// `dcs_handler`: device control data, which only `ESC \` ends. The data + /// carries escapes of its own, as tmux's passthrough does. + DeviceControl, + /// `dcs_escape`: device control data just past an `ESC`. + DeviceControlEscape, + /// `osc_string`, which `BEL` also ends. C1 ST (`0x9c`) is data to tmux. + OperatingSystemCommand, + /// `apc_string`, `rename_string`, `consume_st` and `dcs_ignore`. String, - /// Inside a string sequence, just past an `ESC` that may terminate it. - StringEscape, } /// Strips escape sequences from a pane's output, across chunk boundaries. @@ -67,42 +94,85 @@ impl TextFilter { } fn push_byte(&mut self, byte: u8, out: &mut Option<&mut Vec>) { - match self.state { - State::Text => self.push_text_byte(byte, out), - State::Escape => { - self.state = match byte { - b'[' => State::ControlSequence, - // OSC, APC, PM, DCS all run until a string terminator. - b']' | b'_' | b'^' | b'P' => State::String, - // Everything else is a two-byte sequence, already whole. - _ => State::Text, - }; - } - State::ControlSequence => { - if (0x40..=0x7e).contains(&byte) { + // `INPUT_STATE_ANYWHERE`, in every table but the two holding device + // control data. + if !matches!( + self.state, + State::DeviceControl | State::DeviceControlEscape + ) { + match byte { + CANCEL | SUBSTITUTE => { self.state = State::Text; + return; + } + ESCAPE => { + self.state = State::Escape; + return; } - } - State::String => match byte { - 0x07 => self.state = State::Text, - 0x1b => self.state = State::StringEscape, _ => {} - }, - State::StringEscape => { - // `ESC \` ends the string; any other ESC-something restarts - // the wait for a terminator rather than ending it. - self.state = if byte == b'\\' { - State::Text - } else { - State::String - }; } } + + // A byte an arm does not name is collected or ignored in place. + self.state = match self.state { + State::Text => self.execute(byte, out, State::Text), + State::Escape => match byte { + 0x00..=0x1f => self.execute(byte, out, State::Escape), + 0x20..=0x2f => State::EscapeIntermediate, + b'P' => State::DeviceControlEnter, + b'[' => State::ControlSequence, + b']' => State::OperatingSystemCommand, + // SOS, PM, APC, and screen's `ESC k` title. + b'X' | b'^' | b'_' | b'k' => State::String, + // Any other final byte completes a two-byte sequence. + 0x30..=0x7e => State::Text, + _ => State::Escape, + }, + State::EscapeIntermediate => match byte { + 0x00..=0x1f => self.execute(byte, out, State::EscapeIntermediate), + 0x30..=0x7e => State::Text, + _ => State::EscapeIntermediate, + }, + State::ControlSequence => match byte { + 0x00..=0x1f => self.execute(byte, out, State::ControlSequence), + 0x40..=0x7e => State::Text, + _ => State::ControlSequence, + }, + State::DeviceControlEnter => match byte { + 0x20..=0x2f => State::DeviceControlIntermediate, + 0x3a => State::String, + 0x30..=0x3f => State::DeviceControlParameter, + 0x40..=0x7e => State::DeviceControl, + _ => State::DeviceControlEnter, + }, + State::DeviceControlParameter => match byte { + 0x20..=0x2f => State::DeviceControlIntermediate, + 0x3a | 0x3c..=0x3f => State::String, + 0x40..=0x7e => State::DeviceControl, + _ => State::DeviceControlParameter, + }, + State::DeviceControlIntermediate => match byte { + 0x30..=0x3f => State::String, + 0x40..=0x7e => State::DeviceControl, + _ => State::DeviceControlIntermediate, + }, + State::DeviceControl if byte == ESCAPE => State::DeviceControlEscape, + State::DeviceControlEscape if byte == b'\\' => State::Text, + State::DeviceControlEscape => State::DeviceControl, + State::OperatingSystemCommand if byte == BELL => State::Text, + state @ (State::DeviceControl | State::OperatingSystemCommand | State::String) => state, + }; + } + + /// Act on `byte` as text would, then stay in `state`: tmux runs a control + /// byte inside an escape or control sequence and carries on around it. + fn execute(&mut self, byte: u8, out: &mut Option<&mut Vec>, state: State) -> State { + self.push_text_byte(byte, out); + state } fn push_text_byte(&mut self, byte: u8, out: &mut Option<&mut Vec>) { match byte { - 0x1b => self.state = State::Escape, b'\r' => { // Held: `\r\n` is one line break, and a lone `\r` is a line // rewritten in place, which reads better as another line than @@ -199,9 +269,48 @@ mod tests { assert_eq!(filtered(&[b"a\x1b_marker\x1b\\b"]), "ab"); } + /// `printf 'x\033]y'` in zsh, then `echo AFTER-$((2+2))`: the OSC never + /// ends, and the next prompt's colour is what makes tmux give up on it. + #[test] + fn an_unterminated_osc_ends_at_the_next_escape() { + let text = filtered(&[ + b"printf 'x\\033]y'\r\n", + b"x\x1b]y", + b"\x1b[0m$ ", + b"\x1b[32mecho\x1b[39m AFTER-$((2+2))\r\n", + b"AFTER-4\r\n", + ]); + assert_eq!(text, "printf 'x\\033]y'\nx$ echo AFTER-$((2+2))\nAFTER-4\n"); + } + #[test] - fn an_escape_inside_a_string_does_not_end_it_early() { - assert_eq!(filtered(&[b"a\x1b]0;ti\x1b(tle\x07b"]), "ab"); + fn an_escape_inside_an_osc_ends_it() { + assert_eq!(filtered(&[b"a\x1b]0;title\x1b(Bb"]), "ab"); + } + + #[test] + fn cancel_and_substitute_abandon_a_sequence() { + assert_eq!(filtered(&[b"a\x1b]0;title\x18b\x1b[1\x1ac"]), "abc"); + } + + #[test] + fn bel_ends_an_osc_but_not_an_application_program_command() { + assert_eq!(filtered(&[b"a\x1b_apc\x07still apc\x1b\\b"]), "ab"); + } + + #[test] + fn a_device_control_string_ends_only_at_string_terminator() { + assert_eq!(filtered(&[b"a\x1bPq\x1b(data\x18\x07more\x1b\\b"]), "ab"); + } + + #[test] + fn c1_string_terminator_is_string_data() { + assert_eq!(filtered(&[b"a\x1b]0;x\x9cstill title\x07b"]), "ab"); + } + + #[test] + fn an_escape_restarts_an_unfinished_control_sequence() { + assert_eq!(filtered(&[b"a\x1b[3\x1b[0mb"]), "ab"); } #[test] @@ -209,6 +318,12 @@ mod tests { assert_eq!(filtered(&[b"a\x1b=b\x1b>c"]), "abc"); } + /// `tput sgr0` writes `ESC ( B` before `ESC [ m`. + #[test] + fn a_sequence_with_an_intermediate_is_removed_whole() { + assert_eq!(filtered(&[b"a\x1b(Bb\x1b)0c"]), "abc"); + } + #[test] fn carriage_return_and_newline_are_one_break() { assert_eq!(filtered(&[b"one\r\ntwo\r\n"]), "one\ntwo\n"); From c140bd8f52a4be3c1ec761ad4f02b6df6b85b940 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 02:18:09 -0500 Subject: [PATCH 080/117] Formats(fix[q]): Decode the tab that 3.8-rc escapes why: tmux d57d75de (in 3.8-rc) added newline and tab to format_quote_shell's set, and 402d366b added braces. The decoder took the braces and the newline but not the tab, so `\` in any #{q:} value was InvalidEscape. On tmux next-3.9 a session or pane whose path holds a tab made list-sessions, list-panes and Server::hierarchy fail: the inspect example reported DecodeListing { list-sessions, InvalidEscape, field session_path }. Found by the format_rows fuzz target's round trip on a captured next-3.9 listing. what: - Add \t to QUOTE_SHELL_SPECIALS; say the set changed in 3.8-rc - Add the tab to the test's tmux-sourced 3.8-rc additions, which fails both the set comparison and the round trip without the fix - Fold the tab into the existing changelog entry for this set --- crates/libtmux/src/formats/row.rs | 8 ++++---- crates/libtmux/src/formats/tests.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/libtmux/src/formats/row.rs b/crates/libtmux/src/formats/row.rs index e242125c..52245c4e 100644 --- a/crates/libtmux/src/formats/row.rs +++ b/crates/libtmux/src/formats/row.rs @@ -11,10 +11,10 @@ use super::{DecoderKind, FormatDescriptor, ListProfile}; /// digit or one of the letters `vis` emits, so the two escaping layers below /// compose into one unambiguous grammar. /// -/// `{`, `}` and `\n` joined the set on the unreleased tree reporting as -/// `next-3.9`. No release through 3.7c escapes them, so accepting them here -/// is a safe superset on every version -- no `since::` gate needed. -pub(super) const QUOTE_SHELL_SPECIALS: &[u8] = b"|&;<>()$`\\\"'*?[# =%{}\n"; +/// `{`, `}`, `\n` and `\t` joined the set in 3.8-rc. No earlier release +/// escapes them, so accepting them here is a safe superset on every version -- +/// no `since::` gate needed. +pub(super) const QUOTE_SHELL_SPECIALS: &[u8] = b"|&;<>()$`\\\"'*?[# =%{}\n\t"; /// The field separator a format plan's template renders between values. /// diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index 727d3440..c2962914 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -39,8 +39,8 @@ const Q_SHELL_ESCAPED: [u8; 19] = [ 0x7c, 0x26, 0x3b, 0x3c, 0x3e, 0x28, 0x29, 0x24, 0x60, 0x5c, 0x22, 0x27, 0x2a, 0x3f, 0x5b, 0x23, 0x20, 0x3d, 0x25, ]; -/// `{`, `}` and a raw newline: what `next-3.9` added to the set above. -const Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS: [u8; 3] = *b"{}\n"; +/// `{`, `}`, newline and tab: what 3.8-rc added to the set above. +const Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS: [u8; 4] = *b"{}\n\t"; const SHORT_SENTINEL: &str = "zot-private"; const LONG_SENTINEL: &str = "quartz-private-payload-with-a-distinct-and-deliberately-long-shape"; const CONTROL_SENTINEL: [u8; 3] = [0x02, 0x03, 0x04]; @@ -713,10 +713,10 @@ fn format_codec_tmux_3_2a_q_escape_set_round_trips_exactly() { #[test] fn format_codec_next_3_9_q_escape_additions_round_trip_exactly() { - // Before QUOTE_SHELL_SPECIALS grew these three bytes, this failed with - // InvalidEscape at the first backslash -- the exact failure real - // next-3.9 output produces for any value containing a brace or a raw - // newline, such as `#{q:buffer_mode_format}` or `#{q:window_layout}`. + // Without these bytes in QUOTE_SHELL_SPECIALS this fails with + // InvalidEscape at the first backslash -- the failure real 3.8-rc output + // produces for any value holding a brace, a newline or a tab, such as + // `#{q:window_layout}` or a `#{q:pane_current_path}` with a tab in it. let mut stdout = Vec::with_capacity(Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS.len() * 2 + 2); for byte in Q_SHELL_ESCAPED_NEXT_3_9_ADDITIONS { stdout.extend_from_slice(&[b'\\', byte]); From 0fc7f448288ab525b6b315500f88afe8213fa6e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 02:47:32 -0500 Subject: [PATCH 081/117] Fuzz(feat): Cover framing, rows and the text filter why: design.md said every parser of outside bytes was fuzzed, but the format-row codec that decodes every listing, control-mode block framing and tmux-mcp's escape filter had no target, and no target checked more than "does not panic". Of the filter target's two seeds, one was a shape the parser rejects. what: - format_rows: arbitrary stdout through each listing's hydration on 3.2a, 3.5 (vis), 3.7d and next-3.9 plans, then the same bytes as field values written the way that release prints them and decoded back; the encoder moves out of the tests so both share it - control_block: read_line_within over a byte stream in and out of %begin blocks, closed blocks fed to the actor's ReplySlots; asserts no line escapes its block and each reply holds exactly the blocks it was owed, stopping at the first %error, with stdout/stderr split - text_filter: compiles tmux-mcp's text.rs by path rather than adding a feature to a published binary crate; asserts chunk-split invariance and that text after CAN ESC \ (any input), or after CAN, SUB or ESC [ m (inputs with no P), is never swallowed - filter_expr_json: what parses must serialize and read back equal - Seeds from real tmux 3.7d and next-3.9 output (pipe-pane, a -C transcript, list-* rows), tmuxp's examples, and one per rejection - fuzz/ declares its own [workspace] so a checkout nested in another workspace, such as a worktree, still builds it Each oracle fails when its subject is broken: text_filter on the filter before the escape-string fix, control_block with is_last ignoring %error, filter_expr_json with or serialized as and, and format_rows on the tab decoded by the previous commit. --- .github/CONTRIBUTING.md | 11 +- crates/libtmux/docs/design.md | 38 +++-- crates/libtmux/src/control.rs | 6 + crates/libtmux/src/control/actor.rs | 2 +- crates/libtmux/src/control/fuzz.rs | 156 ++++++++++++++++++ crates/libtmux/src/formats.rs | 8 +- crates/libtmux/src/formats/fuzz.rs | 103 ++++++++++++ crates/libtmux/src/formats/row.rs | 44 +++++ crates/libtmux/src/formats/tests.rs | 37 +---- crates/libtmux/src/lib.rs | 3 + crates/libtmux/src/target.rs | 2 +- fuzz/Cargo.lock | 4 +- fuzz/Cargo.toml | 26 +++ fuzz/fuzz_targets/control_block.rs | 13 ++ fuzz/fuzz_targets/filter_expr_json.rs | 16 +- fuzz/fuzz_targets/format_rows.rs | 13 ++ fuzz/fuzz_targets/text_filter.rs | 62 +++++++ fuzz/seeds/control_block/control-session | Bin 0 -> 659 bytes .../control_block/lookalikes-inside-a-block | Bin 0 -> 77 bytes fuzz/seeds/filter_expr_json/and | 2 +- fuzz/seeds/filter_expr_json/eq | 2 +- fuzz/seeds/filter_expr_json/not-or | 1 + fuzz/seeds/filter_expr_json/rejected-depth | 1 + fuzz/seeds/filter_expr_json/rejected-overflow | 1 + fuzz/seeds/filter_expr_json/rejected-target | 1 + .../filter_expr_json/rejected-unknown-key | 1 + fuzz/seeds/filter_expr_json/rejected-version | 1 + fuzz/seeds/format_rows/panes-3.7d | 4 + fuzz/seeds/format_rows/panes-next-3.9 | 4 + fuzz/seeds/format_rows/sessions-3.7d | 2 + fuzz/seeds/format_rows/sessions-next-3.9 | 2 + fuzz/seeds/format_rows/sessions-vis-3.5 | 1 + fuzz/seeds/format_rows/windows-3.7d | 4 + fuzz/seeds/format_rows/windows-next-3.9 | 3 + fuzz/seeds/text_filter/every-string | 1 + fuzz/seeds/text_filter/unterminated-osc | 2 + fuzz/seeds/text_filter/zsh-session | 10 ++ fuzz/seeds/workspace_yaml/tmuxp-blank-panes | 27 +++ fuzz/seeds/workspace_yaml/tmuxp-env-variables | 17 ++ .../workspace_yaml/tmuxp-shorthands-json | 20 +++ fuzz/seeds/workspace_yaml/tmuxp-skip-send | 9 + .../workspace_yaml/tmuxp-start-directory | 41 +++++ 42 files changed, 639 insertions(+), 62 deletions(-) create mode 100644 crates/libtmux/src/control/fuzz.rs create mode 100644 crates/libtmux/src/formats/fuzz.rs create mode 100644 fuzz/fuzz_targets/control_block.rs create mode 100644 fuzz/fuzz_targets/format_rows.rs create mode 100644 fuzz/fuzz_targets/text_filter.rs create mode 100644 fuzz/seeds/control_block/control-session create mode 100644 fuzz/seeds/control_block/lookalikes-inside-a-block create mode 100644 fuzz/seeds/filter_expr_json/not-or create mode 100644 fuzz/seeds/filter_expr_json/rejected-depth create mode 100644 fuzz/seeds/filter_expr_json/rejected-overflow create mode 100644 fuzz/seeds/filter_expr_json/rejected-target create mode 100644 fuzz/seeds/filter_expr_json/rejected-unknown-key create mode 100644 fuzz/seeds/filter_expr_json/rejected-version create mode 100644 fuzz/seeds/format_rows/panes-3.7d create mode 100644 fuzz/seeds/format_rows/panes-next-3.9 create mode 100644 fuzz/seeds/format_rows/sessions-3.7d create mode 100644 fuzz/seeds/format_rows/sessions-next-3.9 create mode 100644 fuzz/seeds/format_rows/sessions-vis-3.5 create mode 100644 fuzz/seeds/format_rows/windows-3.7d create mode 100644 fuzz/seeds/format_rows/windows-next-3.9 create mode 100644 fuzz/seeds/text_filter/every-string create mode 100644 fuzz/seeds/text_filter/unterminated-osc create mode 100644 fuzz/seeds/text_filter/zsh-session create mode 100644 fuzz/seeds/workspace_yaml/tmuxp-blank-panes create mode 100644 fuzz/seeds/workspace_yaml/tmuxp-env-variables create mode 100644 fuzz/seeds/workspace_yaml/tmuxp-shorthands-json create mode 100644 fuzz/seeds/workspace_yaml/tmuxp-skip-send create mode 100644 fuzz/seeds/workspace_yaml/tmuxp-start-directory diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 94aee348..8291b67d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -334,11 +334,12 @@ not in its generated output, and survive rerecording. A `missing` row is a field a listing could carry and does not; adding one is ordinary work, leaving it unrecorded is not. -**Parsers that read from outside are fuzzed.** The control-mode line parser, -the filter-expression wire format, and the workspace YAML loader each have a -target under `fuzz/`. Add one when adding a parser that reads bytes this crate -did not write, and seed it — an unseeded target proves only that arbitrary -input is not valid input. +**Parsers that read from outside are fuzzed.** `just fuzz-list` names a target +for each; `design.md` says what each checks. Add one when adding a parser that +reads bytes this workspace did not write, seed it from real output, and give it +an oracle where one exists — an unseeded target proves only that arbitrary +input is not valid input, and one without an oracle proves only that it does +not panic. **Packaging is a gate.** `just package` builds the published crates and verifies what the tarballs contain. A packaged crate ships its README, so a diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 6702647f..a1a54ec0 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -1791,14 +1791,25 @@ doing it during an alpha rather than after one. ### Fuzzing the parsers that read from outside -Three surfaces take bytes this crate did not write, and each is fuzzed: - -- the control-mode line parser, which reads from a tmux that keeps running, so - a malformed line is not a command that failed but bytes it has to survive; -- the versioned filter-expression wire format, which can arrive from a config - file, a CLI argument, or an MCP tool call; -- the tmuxp-style workspace loader, which walks a hand-written nested document - deciding what each value means. +Every surface that takes bytes this workspace did not write is fuzzed: + +- the control-mode line parser (`control_line`), which reads from a tmux that + keeps running, so a malformed line is not a command that failed but bytes it + has to survive; +- control-mode block framing (`control_block`): a stream read inside and + outside `%begin` blocks, each closed block handed to the slots that assemble + a chain's reply, checked that no line escapes its block and no reply holds + blocks another command owns; +- the format-row codec every listing decodes through (`format_rows`), whose + names, paths and titles users and programs write, checked by writing values + the way each dialect of tmux prints them and decoding them back; +- the versioned filter-expression wire format (`filter_expr_json`), which can + arrive from a config file, a CLI argument, or an MCP tool call, checked that + what it accepts writes out and reads back as the same expression; +- the tmuxp-style workspace loader (`workspace_yaml`), which walks a + hand-written nested document deciding what each value means; +- tmux-mcp's escape filter (`text_filter`), which reads pane output, checked + that text written after a sequence tmux would have ended is never swallowed. `fuzz/` is not a workspace member. It needs nightly and a sanitizer, and `just check` has to stay runnable on stable, so it is excluded and reached @@ -1812,13 +1823,16 @@ command. `fuzz/seeds/` carries those shapes -- including a line that is not UTF-8, because pane output is not required to be. What the fuzzer discovers from them is not checked in; the seeds are. -`__fuzz_parse_control_line` exists because the parser is private and should -stay private. It is behind `unstable-fuzzing`, which is not in `full` and -which nothing but `fuzz/` turns on. +The `__fuzz_*` functions exist because the parsers are private and should stay +private. They are behind `unstable-fuzzing`, which is not in `full` and which +nothing but `fuzz/` turns on. tmux-mcp's filter needs only `std`, so the +target compiles its source file instead of adding a feature to a published +binary crate. CI runs them weekly rather than per-push. This kind of testing finds things by running for a long time, so a schedule is worth more than a gate nobody can -wait for, and a crash is uploaded as an artifact rather than left in a log. +wait for. Each target's corpus is cached between runs, since the corpus is +what grows, and a crash is uploaded as an artifact rather than left in a log. ### The public surface is recorded, because nothing else reports drift diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index e745290b..c8766cf7 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -62,6 +62,8 @@ use crate::version::since::CONTROL_PANE_OFF; use crate::{Command, Error, IdParseError, PaneId, Server, SessionId, TmuxText, WindowId}; mod actor; +#[cfg(feature = "unstable-fuzzing")] +mod fuzz; mod protocol; #[cfg(test)] @@ -1596,6 +1598,10 @@ pub fn __fuzz_parse_control_line(line: &[u8]) { let _ = Line::parse(line); } +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub use fuzz::__fuzz_control_blocks; + #[cfg(test)] mod tests; diff --git a/crates/libtmux/src/control/actor.rs b/crates/libtmux/src/control/actor.rs index e83b4ecb..43d3ab5d 100644 --- a/crates/libtmux/src/control/actor.rs +++ b/crates/libtmux/src/control/actor.rs @@ -236,7 +236,7 @@ impl ReplySlots { self.push_ordered(result, deadline, None, 1); } - #[cfg(test)] + #[cfg(any(test, feature = "unstable-fuzzing"))] pub(super) fn push_chain( &mut self, result: oneshot::Sender>, diff --git a/crates/libtmux/src/control/fuzz.rs b/crates/libtmux/src/control/fuzz.rs new file mode 100644 index 00000000..96f37022 --- /dev/null +++ b/crates/libtmux/src/control/fuzz.rs @@ -0,0 +1,156 @@ +//! Control-mode block framing, reachable from `fuzz/` without becoming public. + +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use tokio::sync::oneshot; + +use super::BlockResult; +use super::actor::ReplySlots; +use super::protocol::{Line, read_line_within}; +use crate::{Error, TmuxText}; + +/// One queued request: how many blocks it is owed, and its caller's end. +struct Queued { + owed: usize, + reply: Option>>, +} + +/// Frame arbitrary control-mode output into replies, and check each reply. +/// +/// The first byte sets the line budget and how many requests are queued; the +/// next byte per request says how many commands it chained and whether its +/// caller has gone. The rest is what tmux wrote. Lines are read the way the +/// connection actor reads them, and every closed block goes to the actor's +/// reply slots. +/// +/// # Panics +/// +/// When a line inside a block is anything but output or that block's own +/// terminator, or when a reply holds other than the blocks it was owed: the +/// next ones in order, stopping at the first failure. +#[doc(hidden)] +pub fn __fuzz_control_blocks(data: &[u8]) { + let Some((&header, rest)) = data.split_first() else { + return; + }; + let limit = 32 * (1 + usize::from(header >> 4)); + let queued_count = usize::from(header & 0b111).min(rest.len()); + let (requests, mut stdout) = rest.split_at(queued_count); + + let mut slots = ReplySlots::default(); + let queued: Vec = requests + .iter() + .map(|&request| { + let owed = usize::from(request & 0b11) + 1; + let (sender, receiver) = oneshot::channel(); + // A caller that stopped waiting leaves a tombstone, which must + // still consume its blocks. + let reply = (request & 0x80 == 0).then_some(receiver); + slots.push_chain(sender, owed); + Queued { owed, reply } + }) + .collect(); + + let mut blocks = Vec::new(); + let mut pending = Vec::new(); + let mut open: Option<(u64, Vec)> = None; + loop { + let within = open.as_ref().map(|(number, _)| *number); + let read = ready(read_line_within(&mut stdout, &mut pending, limit, within)); + // A line is consumed whole, or refused whole when it breaks the budget. + assert!(pending.is_empty(), "{} bytes left pending", pending.len()); + let Ok(Some(line)) = read else { + break; + }; + match (open.take(), line) { + (None, Line::BlockStart(number)) => open = Some((number, Vec::new())), + (None, _) => {} + (Some((number, mut output)), Line::Text(text)) => { + output.push(text); + open = Some((number, output)); + } + ( + Some((number, output)), + Line::BlockEnd { + number: end, + succeeded, + }, + ) if end == number => { + let block = BlockResult { + number, + succeeded, + output, + sensitive_input: false, + chained: 0, + }; + blocks.push(block.clone()); + slots.complete(block); + } + (Some((number, _)), line) => unreachable!("inside block {number}, read {line:?}"), + } + } + + check_replies(queued, &blocks); +} + +/// Compare each reply with the blocks it was owed. +fn check_replies(queued: Vec, blocks: &[BlockResult]) { + let mut next = 0; + for Queued { owed, reply } in queued { + let start = next; + while next < blocks.len() && next - start < owed { + next += 1; + if !blocks[next - 1].succeeded() { + break; + } + } + let taken = &blocks[start..next]; + let finished = taken + .last() + .is_some_and(|last| taken.len() == owed || !last.succeeded()); + + let Some(mut reply) = reply else { + continue; + }; + let received = reply.try_recv(); + let (Some((last, earlier)), true) = (taken.split_last(), finished) else { + assert!( + received.is_err(), + "answered after {} of {owed} blocks", + taken.len() + ); + continue; + }; + assert!( + matches!(received, Ok(Ok(_))), + "no reply after {owed} blocks: {received:?}" + ); + let Ok(Ok(result)) = received else { + continue; + }; + let expected: Vec<&TmuxText> = taken.iter().flat_map(BlockResult::output).collect(); + assert_eq!(result.output().iter().collect::>(), expected); + assert_eq!(result.number(), last.number()); + assert_eq!(result.succeeded(), last.succeeded()); + if !result.succeeded() { + let before: Vec<&TmuxText> = earlier.iter().flat_map(BlockResult::output).collect(); + let (stdout, stderr) = result.split_by_outcome(); + assert_eq!(stdout.iter().collect::>(), before, "stdout"); + assert_eq!(stderr, last.output(), "stderr"); + } + } +} + +/// Poll a future that cannot wait: every read here is from memory. +fn ready(future: F) -> F::Output { + let mut future = pin!(future); + match future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + { + Poll::Ready(output) => output, + Poll::Pending => unreachable!("an in-memory reader never waits"), + } +} diff --git a/crates/libtmux/src/formats.rs b/crates/libtmux/src/formats.rs index 1865da76..c92d44a9 100644 --- a/crates/libtmux/src/formats.rs +++ b/crates/libtmux/src/formats.rs @@ -2,18 +2,22 @@ use crate::version::{ReleaseSuffix, ReleaseVersion, TmuxVersion}; +#[cfg(feature = "unstable-fuzzing")] +mod fuzz; mod plan; mod row; mod text; +#[cfg(feature = "unstable-fuzzing")] +pub use fuzz::__fuzz_format_rows; pub(crate) use plan::{FormatPlan, PlanFieldState, PlanPurpose}; #[cfg(test)] use plan::{PlanVersion, TransportDialect, for_profile_selection_test}; #[cfg(test)] -use row::QUOTE_SHELL_SPECIALS; -#[cfg(test)] pub(crate) use row::{FIELD_SEPARATOR, FormatCodecPhase, decode_ascii}; pub(crate) use row::{FormatCodecError, FormatCodecErrorKind, ParsedRow, ParsedSlot, decode_text}; +#[cfg(test)] +use row::{QUOTE_SHELL_SPECIALS, encode_like_tmux}; pub use text::TmuxText; /// Decoder applied to a parsed format slot. diff --git a/crates/libtmux/src/formats/fuzz.rs b/crates/libtmux/src/formats/fuzz.rs new file mode 100644 index 00000000..3455dde0 --- /dev/null +++ b/crates/libtmux/src/formats/fuzz.rs @@ -0,0 +1,103 @@ +//! The format-row codec, reachable from `fuzz/` without becoming public. + +use super::plan::TransportDialect; +use super::row::{FIELD_SEPARATOR, encode_like_tmux}; +use super::{FormatPlan, ListProfile}; +use crate::snapshot::{ + hydrate_client_infos_from_stdout, hydrate_pane_projections_from_stdout, + hydrate_session_infos_from_stdout, hydrate_window_projections_from_stdout, + pane_projection_plan, window_projection_plan, +}; +use crate::{ServerIdentity, TmuxVersion}; + +/// `format_quote_shell`'s set from 3.2a through 3.7. +const Q_ESCAPED_BEFORE_3_8: &[u8] = b"|&;<>()$`\\\"'*?[# =%"; +/// `format_quote_shell`'s set from 3.8-rc, which added `{}`, newline and tab. +const Q_ESCAPED_FROM_3_8: &[u8] = b"|&;<>(){}$`\\\"'*?[# =%\n\t"; + +/// The releases a selector picks, each with the set it quotes with. 3.5 is +/// the `vis` dialect; `next-3.9` stands for 3.8-rc and later. +const RELEASES: [(&[u8], &[u8]); 4] = [ + (b"tmux 3.2a\n", Q_ESCAPED_BEFORE_3_8), + (b"tmux 3.5\n", Q_ESCAPED_BEFORE_3_8), + (b"tmux 3.7d\n", Q_ESCAPED_BEFORE_3_8), + (b"tmux next-3.9\n", Q_ESCAPED_FROM_3_8), +]; + +/// Decode arbitrary listing output, then check that decoding inverts tmux. +/// +/// The first byte picks a listing and a tmux release. The rest is decoded as +/// that listing's stdout, and then, split at NUL, read as field values that +/// are written the way that release prints them and decoded again. +/// +/// # Panics +/// +/// When a value tmux could print does not decode to itself. +#[doc(hidden)] +pub fn __fuzz_format_rows(data: &[u8]) { + let Some((&selector, rest)) = data.split_first() else { + return; + }; + let (release, escaped) = RELEASES[usize::from(selector & 0b11)]; + let Ok(version) = TmuxVersion::parse_output(release) else { + return; + }; + let identity = ServerIdentity::from_socket_path("/fuzz".into()); + let plan = match (selector >> 2) & 0b11 { + 0 => { + let plan = FormatPlan::for_profile(ListProfile::Sessions, &version); + let _ = hydrate_session_infos_from_stdout(&plan, rest); + plan + } + 1 => { + let plan = FormatPlan::for_profile(ListProfile::Clients, &version); + let _ = hydrate_client_infos_from_stdout(&plan, rest); + plan + } + 2 => { + let Ok(plan) = window_projection_plan(&version) else { + return; + }; + let _ = hydrate_window_projections_from_stdout(&identity, &plan, rest); + plan + } + _ => { + let Ok(plan) = pane_projection_plan(&version) else { + return; + }; + let _ = hydrate_pane_projections_from_stdout(&identity, &plan, rest); + plan + } + }; + round_trip(&plan, rest, escaped); +} + +/// Write NUL-separated `values` as rows of `plan`, and decode them back. +fn round_trip(plan: &FormatPlan, values: &[u8], escaped: &[u8]) { + let fields = plan.descriptors.len(); + let mut expected: Vec<&[u8]> = values.split(|&byte| byte == 0).collect(); + expected.resize(expected.len().next_multiple_of(fields), b""); + + let mut wire = Vec::new(); + for (field, value) in expected.iter().enumerate() { + encode_like_tmux(value, escaped, plan.dialect, &mut wire); + wire.push(FIELD_SEPARATOR); + if field % fields == fields - 1 { + wire.push(b'\n'); + } + } + + let rows = plan.parse_rows(&wire); + let dialect = plan.dialect == TransportDialect::Vis; + assert!( + rows.is_ok(), + "{:?} decoding {wire:?} (vis: {dialect})", + rows.as_ref().err() + ); + let decoded: Vec> = rows + .iter() + .flatten() + .flat_map(|row| row.slots().map(|slot| slot.as_bytes().to_vec())) + .collect(); + assert_eq!(decoded, expected, "{wire:?} (vis: {dialect})"); +} diff --git a/crates/libtmux/src/formats/row.rs b/crates/libtmux/src/formats/row.rs index 52245c4e..b00aa633 100644 --- a/crates/libtmux/src/formats/row.rs +++ b/crates/libtmux/src/formats/row.rs @@ -263,6 +263,50 @@ const fn vis_cstyle_byte(letter: u8) -> Option { } } +/// Write `value` as tmux prints `#{q:value}`: a backslash before each byte of +/// `escaped`, then, for [`TransportDialect::Vis`], `VIS_OCTAL|VIS_CSTYLE| +/// VIS_NOSLASH` over the result. +/// +/// Written from tmux's side rather than from the decoder's constants, so a +/// round trip through it checks the decoder against tmux. +#[cfg(any(test, feature = "unstable-fuzzing"))] +pub(crate) fn encode_like_tmux( + value: &[u8], + escaped: &[u8], + dialect: TransportDialect, + wire: &mut Vec, +) { + for &byte in value { + if escaped.contains(&byte) { + wire.extend_from_slice(&[b'\\', byte]); + } else if dialect == TransportDialect::RawQ + // `isvisible` keeps printable ASCII, space, tab, and newline. + || byte.is_ascii_graphic() + || matches!(byte, b' ' | b'\t' | b'\n') + { + wire.push(byte); + } else if let Some(letter) = [ + (0x07, b'a'), + (0x08, b'b'), + (0x0b, b'v'), + (0x0c, b'f'), + (0x0d, b'r'), + ] + .into_iter() + .find_map(|(control, letter)| (byte == control).then_some(letter)) + { + wire.extend_from_slice(&[b'\\', letter]); + } else { + wire.extend_from_slice(&[ + b'\\', + b'0' + (byte >> 6), + b'0' + ((byte >> 3) & 0o7), + b'0' + (byte & 0o7), + ]); + } + } +} + /// Decode exactly three octal digits into one byte. fn decode_octal_escape(digits: &[u8]) -> Option { let mut value: u8 = 0; diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index c2962914..b2481553 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -9,7 +9,7 @@ use super::{ PANE_INFO_SUPPLEMENTS, ParsedRow, ParsedSlot, PlanFieldState, PlanPurpose, PlanVersion, ProfileSet, QUOTE_SHELL_SPECIALS, RequiredContext, SESSION_ID, SESSION_INFO_DESCRIPTORS, SESSION_INFO_SUPPLEMENTS, SemanticOwner, TransportDialect, WINDOW_ID, WINDOW_INFO_DESCRIPTORS, - WINDOW_INFO_SUPPLEMENTS, for_profile_selection_test, + WINDOW_INFO_SUPPLEMENTS, encode_like_tmux, for_profile_selection_test, }; #[cfg(feature = "test-support")] use crate::Command; @@ -213,7 +213,7 @@ fn format_codec_round_trips_every_byte_through_the_vis_dialect() { for byte in 1..=u8::MAX { let mut wire = Vec::new(); - encode_like_tmux_vis(byte, &mut wire); + encode_like_tmux(&[byte], &Q_SHELL_ESCAPED, TransportDialect::Vis, &mut wire); wire.push(b'='); wire.push(b'\n'); @@ -227,39 +227,6 @@ fn format_codec_round_trips_every_byte_through_the_vis_dialect() { } } -/// Reproduce tmux's `#{q:}` then `VIS_OCTAL|VIS_CSTYLE|VIS_NOSLASH` output -/// for one single-byte value. -fn encode_like_tmux_vis(byte: u8, wire: &mut Vec) { - if QUOTE_SHELL_SPECIALS.contains(&byte) { - wire.push(b'\\'); - wire.push(byte); - return; - } - // `isvisible` keeps printable ASCII, space, tab, and newline literal. - if byte.is_ascii_graphic() || matches!(byte, b' ' | b'\t' | b'\n') { - wire.push(byte); - return; - } - if let Some(letter) = [ - (0x07, b'a'), - (0x08, b'b'), - (0x0b, b'v'), - (0x0c, b'f'), - (0x0d, b'r'), - ] - .into_iter() - .find_map(|(control, letter)| (byte == control).then_some(letter)) - { - wire.push(b'\\'); - wire.push(letter); - return; - } - wire.push(b'\\'); - wire.push(b'0' + (byte >> 6)); - wire.push(b'0' + ((byte >> 3) & 0o7)); - wire.push(b'0' + (byte & 0o7)); -} - fn rows(plan: &FormatPlan, stdout: &[u8]) -> Vec { plan.parse_rows(stdout) .ok() diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 5ed71935..20784209 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -364,6 +364,9 @@ pub use error::{ Error, ErrorKind, IdParseError, ListingDecodeError, ObjectKind, OptionErrorKind, ScopeError, ServerConfigurationErrorKind, ServerGoneKind, }; +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub use formats::__fuzz_format_rows; pub use formats::TmuxText; pub use hooks::{IndexedHooks, ReplaceMode, SparseValues}; #[cfg(feature = "control-mode")] diff --git a/crates/libtmux/src/target.rs b/crates/libtmux/src/target.rs index 3deeb256..a7044d61 100644 --- a/crates/libtmux/src/target.rs +++ b/crates/libtmux/src/target.rs @@ -360,7 +360,7 @@ impl Hash for ServerIdentity { } impl ServerIdentity { - #[cfg(test)] + #[cfg(any(test, feature = "unstable-fuzzing"))] pub(crate) fn from_socket_path(socket_path: PathBuf) -> Self { Self { socket_path } } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 9615f3bf..48ec90d4 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -162,7 +162,7 @@ dependencies = [ [[package]] name = "libtmux" -version = "0.1.0-alpha.8" +version = "0.1.0-alpha.11" dependencies = [ "caseless", "futures-core", @@ -385,7 +385,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tmux-workspace" -version = "0.1.0-alpha.8" +version = "0.1.0-alpha.11" dependencies = [ "libtmux", "thiserror", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 93ea0d79..386b1e72 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -11,6 +11,11 @@ rust-version = "1.85" [package.metadata] cargo-fuzz = true +# Its own workspace root. Cargo keeps searching upward past a workspace that +# excludes a package, so a checkout nested in another workspace -- a git +# worktree inside the main checkout -- would otherwise claim this one. +[workspace] + [dependencies] libfuzzer-sys = "0.4" serde_json = "1" @@ -37,3 +42,24 @@ path = "fuzz_targets/workspace_yaml.rs" test = false doc = false bench = false + +[[bin]] +name = "format_rows" +path = "fuzz_targets/format_rows.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "control_block" +path = "fuzz_targets/control_block.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "text_filter" +path = "fuzz_targets/text_filter.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/control_block.rs b/fuzz/fuzz_targets/control_block.rs new file mode 100644 index 00000000..a191dc1d --- /dev/null +++ b/fuzz/fuzz_targets/control_block.rs @@ -0,0 +1,13 @@ +//! Control-mode block framing, fed arbitrary output from tmux. +//! +//! `control_line` classifies one line at a time. This reads a stream the way +//! the connection does, inside and outside `%begin` blocks, and hands each +//! closed block to the reply slots that assemble a chain's answer: no line may +//! escape its block, and no reply may hold another command's blocks. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + libtmux::control::__fuzz_control_blocks(data); +}); diff --git a/fuzz/fuzz_targets/filter_expr_json.rs b/fuzz/fuzz_targets/filter_expr_json.rs index 71ecb640..09a8e99e 100644 --- a/fuzz/fuzz_targets/filter_expr_json.rs +++ b/fuzz/fuzz_targets/filter_expr_json.rs @@ -2,14 +2,26 @@ //! //! An expression can arrive from outside the process -- a config file, a CLI //! argument, an MCP tool call -- so the deserializer is reachable by anything -//! that can write JSON. +//! that can write JSON. Whatever it accepts must serialize, and read back as +//! the same expression. #![no_main] use libfuzzer_sys::fuzz_target; +use libtmux::query::FilterExpr; fuzz_target!(|data: &[u8]| { let Ok(text) = std::str::from_utf8(data) else { return; }; - let _ = serde_json::from_str::>(text); + let Ok(expression) = serde_json::from_str::>(text) else { + return; + }; + let written = serde_json::to_string(&expression); + assert!(written.is_ok(), "accepted but not written: {written:?}"); + let Ok(written) = written else { + return; + }; + let read = serde_json::from_str::>(&written); + assert!(read.is_ok(), "{written} did not read back: {read:?}"); + assert_eq!(read.ok(), Some(expression), "{written}"); }); diff --git a/fuzz/fuzz_targets/format_rows.rs b/fuzz/fuzz_targets/format_rows.rs new file mode 100644 index 00000000..8203f73f --- /dev/null +++ b/fuzz/fuzz_targets/format_rows.rs @@ -0,0 +1,13 @@ +//! The format-row codec every `list-*` result is decoded through, fed +//! arbitrary bytes. +//! +//! Names, paths and titles in those rows are written by users and programs, +//! so the bytes are not this crate's. Beyond not panicking, the same bytes are +//! encoded as tmux prints them and must decode back to themselves. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + libtmux::__fuzz_format_rows(data); +}); diff --git a/fuzz/fuzz_targets/text_filter.rs b/fuzz/fuzz_targets/text_filter.rs new file mode 100644 index 00000000..b5d711b2 --- /dev/null +++ b/fuzz/fuzz_targets/text_filter.rs @@ -0,0 +1,62 @@ +//! tmux-mcp's escape-sequence filter, fed arbitrary pane output. +//! +//! The filter is private to tmux-mcp and needs only `std`, so its source is +//! compiled in here rather than exported through a feature of a published +//! crate. A later `use crate::` in it breaks this build, not the filter. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[allow(dead_code, reason = "compiled from tmux-mcp, which uses all of it")] +#[path = "../../crates/tmux-mcp/src/text.rs"] +mod text; + +use text::TextFilter; + +/// Printable bytes written once tmux is back in its ground state. +const RESYNC_TEXT: &[u8] = b"resync"; + +fn rendered(chunks: &[&[u8]]) -> Vec { + let mut filter = TextFilter::new(); + let mut out = Vec::new(); + for chunk in chunks { + filter.push(chunk, &mut out); + } + out +} + +fuzz_target!(|data: &[u8]| { + let whole = rendered(&[data]); + + // tmux reports output in chunks of its own choosing, so where one ends + // must not change the text. + let split = data.first().map_or(0, |&byte| usize::from(byte) % (data.len() + 1)); + let (head, tail) = data.split_at(split); + assert_eq!(rendered(&[head, tail]), whole, "split at {split}"); + + // Text written once tmux is back in `ground` is on its screen, so it must + // be in the filter's output. CAN then `ESC \` gets there from every state: + // CAN is data in `dcs_handler` and `dcs_escape`, and `ESC \` ends those. + assert_resyncs(data, b"\x18\x1b\\"); + + // Only `ESC P` reaches device control data. Without a `P`, every state + // honours `INPUT_STATE_ANYWHERE`, so CAN, SUB or a whole escape sequence + // -- the next prompt's colour -- ends whatever was open. + if !data.contains(&b'P') { + for resync in [&b"\x18"[..], b"\x1a", b"\x1b[m"] { + assert_resyncs(data, resync); + } + } +}); + +fn assert_resyncs(data: &[u8], resync: &[u8]) { + let mut input = data.to_vec(); + input.extend_from_slice(resync); + input.extend_from_slice(RESYNC_TEXT); + let text = rendered(&[&input]); + assert!( + text.ends_with(RESYNC_TEXT), + "text after {resync:?} was swallowed: {:?}", + String::from_utf8_lossy(&text) + ); +} diff --git a/fuzz/seeds/control_block/control-session b/fuzz/seeds/control_block/control-session new file mode 100644 index 0000000000000000000000000000000000000000..197a59c1772de363dbcde732eddf83603e5a049b GIT binary patch literal 659 zcmb7?F>iw~6ohF;e#H$%osf)4f}M#U(gkcMAXRpSji@s9Uw5rdMWmLubnx=MyL&#_ zU!Pf)Jsq;5H@DHEYJ_YA6K4)C7+78ys|mV{R=cI_v_>06-grMc>q|9s_Ga{hYyi&; zD!k>?nlq~0SwpN!6Uxy<-ZI?%K$y~yGKt7lXI}!Y@$&d-aDjDxRw-wi-1NI?a!^S! z@{ZV?IH8v;@_w&hIi;`Ye3>|<=aK)bnM@8UsZRXwi_4X|IlRvS1%pw8!{!D@{mQCS VJbV{fgUjPC@ Date: Sat, 19 Sep 2026 02:52:51 -0500 Subject: [PATCH 082/117] CI(fix[fuzz]): Keep the corpus between weekly runs why: The fuzz job runs weekly because "the corpus grows", but it passed fuzz/seeds/$TARGET as the only corpus, so what each run found was left in the runner's checkout and thrown away; every run restarted from the seeds. The justfile recipe already passes fuzz/corpus first. what: - Restore fuzz/corpus/$TARGET from actions/cache by prefix, pass it first so libFuzzer writes there, and save it under a per-run key even when the run fails (cache entries are immutable) - Run the three new targets in the matrix - Note that an entry unused for seven days is evicted, so a late weekly run restarts from the seeds --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 230ce452..84245a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,13 @@ jobs: strategy: fail-fast: false matrix: - target: [control_line, filter_expr_json, workspace_yaml] + target: + - control_block + - control_line + - filter_expr_json + - format_rows + - text_filter + - workspace_yaml steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@v1 @@ -137,6 +143,15 @@ jobs: with: tool: cargo-fuzz - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + # The corpus is what this job grows. Cache entries are immutable, so each + # run saves under its own key and the next restores the newest by prefix. + # An entry unused for seven days is evicted: a late run restarts from seeds. + - name: Restore the ${{ matrix.target }} corpus + uses: actions/cache/restore@v6 + with: + path: fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} + restore-keys: fuzz-corpus-${{ matrix.target }}- - name: Fuzz ${{ matrix.target }} env: TARGET: ${{ matrix.target }} @@ -146,8 +161,17 @@ jobs: # statically, which a sanitizer cannot instrument, so the target # has to be named here rather than left to how cargo-fuzz arrived. host=$(rustc +nightly -vV | sed -n 's/^host: //p') - cargo +nightly fuzz run --target "$host" "$TARGET" "fuzz/seeds/$TARGET" \ + # libFuzzer writes new inputs to the first directory only. + mkdir -p "fuzz/corpus/$TARGET" + cargo +nightly fuzz run --target "$host" "$TARGET" \ + "fuzz/corpus/$TARGET" "fuzz/seeds/$TARGET" \ -- -max_total_time=300 -rss_limit_mb=4096 + - name: Save the ${{ matrix.target }} corpus + if: always() + uses: actions/cache/save@v6 + with: + path: fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} - name: Keep any crash that was found if: failure() uses: actions/upload-artifact@v7 From 9f48528e740c0ecd264de3942203a9badc2d6885 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 03:07:55 -0500 Subject: [PATCH 083/117] API(fix[time]): Return timestamps as SystemTime why: Five accessors returned an i64 of Unix seconds, so every caller comparing one against a clock picked the unit and the epoch itself, and a millisecond mistake type-checked. SystemTime carries both. The field handles keep i64. They are the filter grammar's operands, and that grammar reaches libtmux-macros, the JSON wire format and tmux-mcp's search schema, all of which compare integers. Each accessor says so in one sentence, so reading a field two ways is documented rather than a surprise. The decoder now refuses a value SystemTime cannot hold, as it refuses an out-of-range integer, so an accessor never has to. On Unix that set is empty: std's SystemTime holds any i64 of seconds, before 1970 included, which the existing -2 and i64::MIN fixtures rely on. Conversion uses checked arithmetic because `UNIX_EPOCH + offset` panics on overflow. what: - Session::created, Session::last_attached, Window::last_activity, Client::created and ServerGeneration::start_time return SystemTime - ServerGeneration keeps its seconds for Display and equality; start_time is no longer const - Server::generation refuses a start time SystemTime cannot hold - Test every accessor against the test's own clock window and against its handle's Unix seconds, and every i64 through the conversion - Record the break in the changelog, the migration notes, the parity ledger and public-api.txt --- crates/libtmux/docs/migration.md | 24 +++++++ crates/libtmux/docs/parity.md | 1 + crates/libtmux/docs/public-api.txt | 10 +-- crates/libtmux/src/client.rs | 13 ++-- crates/libtmux/src/server.rs | 11 +-- crates/libtmux/src/session.rs | 28 +++++--- crates/libtmux/src/snapshot.rs | 26 +++++++ crates/libtmux/src/snapshot/tests.rs | 16 +++++ crates/libtmux/src/target.rs | 10 ++- crates/libtmux/src/window.rs | 19 +++-- crates/libtmux/tests/commands.rs | 100 +++++++++++++++++++++++++++ crates/libtmux/tests/hierarchy.rs | 2 +- crates/libtmux/tests/mutations.rs | 2 +- 13 files changed, 229 insertions(+), 33 deletions(-) diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index 0fefbd53..95e1295b 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -1,5 +1,29 @@ # Migrating from 0.1.0-alpha.11 +## Timestamps are `SystemTime` + +`Session::created`, `Session::last_attached`, `Window::last_activity`, +`Client::created` and `ServerGeneration::start_time` return +`std::time::SystemTime` in place of an `i64` of Unix seconds: + +```no_run +# fn age(session: &libtmux::Session) -> Result<(), std::time::SystemTimeError> { +use std::time::{SystemTime, UNIX_EPOCH}; + +// was: let created: i64 = session.created(); +let created: SystemTime = session.created(); +let age = SystemTime::now().duration_since(created)?; + +// The seconds are one conversion away. +let seconds = created.duration_since(UNIX_EPOCH)?.as_secs(); +# let _ = (age, seconds); +# Ok(()) +# } +``` + +The field handles are unchanged: `session.get(fields.session_created)` and a +filter on it still see the `i64` tmux reports. + ## `respawn` and `display_menu` take types, not literals ```no_run diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 22619d4f..d4209de1 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -717,6 +717,7 @@ that are unsound, ambiguous, or specific to dynamic language mechanics. | Reaching a pane's session means reading `session_id` and looking it up. | `Pane::session`, mirroring `Window::session`: it re-reads tmux, so a session renamed since discovery reports as it is now. Covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | A `wait-for` lock is taken and released by two calls, so a locker that returns early leaves the channel held and every later locker blocked. | `Server::with_channel_lock` pairs them, releasing on the error path as the other `with_*` scopes do. The queued-drop wedge is a tmux defect (`cmd-wait-for.c`) and stays documented on `Server::lock_channel`. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | | A respawn's kill flag is a positional boolean, and a menu item is a string triple. | `Respawn::{Replacing, OnlyIfDead}` on `Pane::respawn` and `Window::respawn`, and `MenuItem` on `Server::display_menu`: a literal that decides whether a running process is killed, and three strings that compile in any order, both say what they mean at the call site. Covered by [`tests/mutations.rs`](../tests/mutations.rs) and [`tests/commands.rs`](../tests/commands.rs). | Object mutations and interactions | `implemented` | +| `session_created`, `window_activity` and the other timestamps arrive as strings of Unix seconds, so a caller converts before comparing against a clock and picks the unit itself. | `Session::created`, `Session::last_attached`, `Window::last_activity`, `Client::created` and `ServerGeneration::start_time` return `SystemTime`. A value the platform's `SystemTime` cannot hold fails decoding rather than panicking. The field handles keep Unix seconds as `i64`, because the query grammar compares integers. Covered by [`tests/commands.rs`](../tests/commands.rs). | Formats, snapshots, winlinks, and queries | `implemented` | | Every command is one tmux process, and a persistent connection is reachable only by writing lines to it. | `Server::over_control_mode` routes the whole typed API over an attached `ControlSender`, so `sessions`, `send_keys`, `capture` and the option accessors answer from `%begin`/`%end` blocks instead of forking. Per-command attribution survives, because a block is per command. Covered by [`tests/control_mode_routing.rs`](../tests/control_mode_routing.rs), which proves no process is spawned by pointing `tmux_executable` at a stub that logs its argv. | Commands, transports, and control mode | `implemented` | | A client listing cannot tell a library's own control connection from a person's terminal, so "is anybody attached" counts the asker. | `Client::is_own` answers it on a handle; `Server::owns_control_client` remains for a caller holding a pid and no handle. Covered by [`tests/control.rs`](../tests/control.rs). | Commands, transports, and control mode | `implemented` | | Options and hooks return display-quoted strings that callers re-parse. | `Server::options` and `Server::hooks` read values through `show-options -v`; the listing form reads names only rather than re-parsing tmux quoting. | Options, hooks, and advanced command families | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 24bec8b3..981c074b 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -315,7 +315,7 @@ function libtmux::CaptureOptions::visible: const fn() -> Self function libtmux::Client::attached_pane: async fn(&self) -> Result, libtmux::Error> function libtmux::Client::attached_session: async fn(&self) -> Result, libtmux::Error> function libtmux::Client::attached_window: async fn(&self) -> Result, libtmux::Error> -function libtmux::Client::created: fn(&self) -> i64 +function libtmux::Client::created: fn(&self) -> SystemTime function libtmux::Client::detach: async fn(self) -> Result<(), libtmux::Error> function libtmux::Client::get: fn>(&self, field: F) -> libtmux::Availability<::Value<'_>> function libtmux::Client::height: fn(&self) -> Option @@ -603,12 +603,12 @@ function libtmux::ServerBuilder::socket_name: fn(self, name: impl Into function libtmux::ServerBuilder::socket_path: fn(self, path: impl Into) -> Self function libtmux::ServerBuilder::tmux_executable: fn(self, executable: impl Into) -> Self function libtmux::ServerGeneration::pid: const fn(self) -> u32 -function libtmux::ServerGeneration::start_time: const fn(self) -> i64 +function libtmux::ServerGeneration::start_time: fn(self) -> SystemTime function libtmux::Session::active_window: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::append_option: async fn(&self, name: &str, value: impl Into) -> Result<(), libtmux::Error> function libtmux::Session::attached_client_count: fn(&self) -> u32 function libtmux::Session::cmd: async fn(&self, command: libtmux::Command) -> Result -function libtmux::Session::created: fn(&self) -> i64 +function libtmux::Session::created: fn(&self) -> SystemTime function libtmux::Session::detach_clients: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Session::display: async fn(&self, message: &str) -> Result<(), libtmux::Error> function libtmux::Session::environment: async fn(&self, name: &str) -> Result, libtmux::Error> @@ -623,7 +623,7 @@ function libtmux::Session::hooks: async fn(&self) -> Result &libtmux::SessionId function libtmux::Session::is_attached: fn(&self) -> bool function libtmux::Session::kill: async fn(self) -> Result<(), libtmux::Error> -function libtmux::Session::last_attached: fn(&self) -> Option +function libtmux::Session::last_attached: fn(&self) -> Option function libtmux::Session::last_window: async fn(&self) -> Result, libtmux::Error> function libtmux::Session::lock: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Session::name: fn(&self) -> &libtmux::TmuxText @@ -705,7 +705,7 @@ function libtmux::Window::is_active: const fn(&self) -> bool function libtmux::Window::is_linked: const fn(&self) -> bool function libtmux::Window::is_zoomed: fn(&self) -> bool function libtmux::Window::kill: async fn(self) -> Result<(), libtmux::Error> -function libtmux::Window::last_activity: fn(&self) -> i64 +function libtmux::Window::last_activity: fn(&self) -> SystemTime function libtmux::Window::last_pane: async fn(&self) -> Result, libtmux::Error> function libtmux::Window::layout: fn(&self) -> &libtmux::TmuxText function libtmux::Window::link_to: async fn(&self, session: &libtmux::Session, index: Option) -> Result<(), libtmux::Error> diff --git a/crates/libtmux/src/client.rs b/crates/libtmux/src/client.rs index 34b4b2a1..5227b5e4 100644 --- a/crates/libtmux/src/client.rs +++ b/crates/libtmux/src/client.rs @@ -5,15 +5,16 @@ use std::fmt; use std::hash::{Hash, Hasher}; use std::os::unix::ffi::OsStringExt as _; use std::sync::Arc; +use std::time::SystemTime; use crate::formats::TmuxText; use crate::internal::core::Core; use crate::internal::listing; #[cfg(feature = "query")] use crate::query::{FilterSchema, Filterable, ReadField}; -use crate::snapshot::ClientInfo; #[cfg(feature = "query")] use crate::snapshot::{Availability, ClientFields, FieldRef}; +use crate::snapshot::{ClientInfo, stored_time}; use crate::target::ServerIdentity; use crate::{Command, Error, ObjectKind}; @@ -89,10 +90,14 @@ impl Client { self.info.client_height().copied().available() } - /// Return when the client connected, as a Unix timestamp. + /// Return when the client connected. + /// + /// tmux keeps whole seconds. `ClientFields::client_created` filters and + /// reads the same field as the `i64` of Unix seconds tmux reports, because + /// the query grammar compares integers. #[must_use] - pub fn created(&self) -> i64 { - *self.info.client_created() + pub fn created(&self) -> SystemTime { + stored_time(*self.info.client_created()) } /// Report whether the client is attached read-only. diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 04fc73ef..fc8bccdd 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1537,7 +1537,7 @@ impl Server { /// # Errors /// /// Returns an error when tmux cannot be reached, or answers with something - /// that is not a pid and a start time. + /// that is not a pid and a start time this platform's `SystemTime` holds. /// /// # Examples /// @@ -1566,12 +1566,13 @@ impl Server { let answer = self.format(None, "#{pid} #{start_time}").await?; let text = answer.to_string_lossy(); let mut parts = text.split_whitespace(); - let parsed = parts + let pid = parts.next().and_then(|pid| pid.parse::().ok()); + let start_time = parts .next() - .and_then(|pid| pid.parse::().ok()) - .zip(parts.next().and_then(|start| start.parse::().ok())); + .and_then(|start| start.parse::().ok()) + .filter(|start| crate::snapshot::unix_time(*start).is_some()); - let Some((pid, start_time)) = parsed else { + let (Some(pid), Some(start_time)) = (pid, start_time) else { return Err(Error::UnreadableFormatValue { format: "#{pid} #{start_time}", detail: crate::IdParseError::new('#'), diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index c9be290c..f71fd684 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -6,6 +6,7 @@ use std::ffi::{OsStr, OsString}; use std::fmt; use std::hash::{Hash, Hasher}; use std::sync::Arc; +use std::time::SystemTime; use crate::formats::TmuxText; use crate::internal::core::Core; @@ -14,9 +15,9 @@ use crate::internal::scoped; use crate::pane::Pane; #[cfg(feature = "query")] use crate::query::{FilterSchema, Filterable, ReadField}; -use crate::snapshot::SessionInfo; #[cfg(feature = "query")] use crate::snapshot::{Availability, FieldRef, SessionFields}; +use crate::snapshot::{SessionInfo, stored_time}; use crate::target::{ServerIdentity, SessionId}; use crate::window::Window; use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; @@ -232,19 +233,30 @@ impl Session { self.attached_client_count() > 0 } - /// Return when the session was created, as a Unix timestamp. + /// Return when the session was created. + /// + /// tmux keeps whole seconds. `SessionFields::session_created` filters and + /// reads the same field as the `i64` of Unix seconds tmux reports, because + /// the query grammar compares integers. #[must_use] - pub fn created(&self) -> i64 { - *self.info.session_created() + pub fn created(&self) -> SystemTime { + stored_time(*self.info.session_created()) } - /// Return when a client last attached, as a Unix timestamp. + /// Return when a client last attached. /// /// This is `None` for a session that has never been attached, which is the - /// ordinary state for one started with `new-session -d`. + /// ordinary state for one started with `new-session -d`. tmux keeps whole + /// seconds. `SessionFields::session_last_attached` filters and reads the + /// same field as the `i64` of Unix seconds tmux reports, because the query + /// grammar compares integers. #[must_use] - pub fn last_attached(&self) -> Option { - self.info.session_last_attached().copied().available() + pub fn last_attached(&self) -> Option { + self.info + .session_last_attached() + .copied() + .available() + .map(stored_time) } /// Return the identity of the server this session belongs to. diff --git a/crates/libtmux/src/snapshot.rs b/crates/libtmux/src/snapshot.rs index bda4ea60..2ff78526 100644 --- a/crates/libtmux/src/snapshot.rs +++ b/crates/libtmux/src/snapshot.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use std::fmt; use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[cfg(test)] use crate::formats::{DecoderKind, decode_ascii}; @@ -565,12 +566,37 @@ fn decode_i32(slot: ParsedSlot<'_>) -> Result { .ok_or_else(|| invalid_value(&slot)) } +/// Decode Unix seconds, refusing a value [`unix_time`] cannot convert. +/// +/// A stored timestamp is therefore always one [`stored_time`] can read. fn decode_timestamp(slot: ParsedSlot<'_>) -> Result { signed_text(slot.as_bytes()) .and_then(|text| text.parse::().ok()) + .filter(|seconds| unix_time(*seconds).is_some()) .ok_or_else(|| invalid_value(&slot)) } +/// Convert Unix seconds, the unit of every tmux timestamp, to a `SystemTime`. +/// +/// A negative value is a time before 1970. `None` when this platform's +/// `SystemTime` cannot hold the value. +pub(crate) fn unix_time(seconds: i64) -> Option { + let offset = Duration::from_secs(seconds.unsigned_abs()); + if seconds < 0 { + UNIX_EPOCH.checked_sub(offset) + } else { + UNIX_EPOCH.checked_add(offset) + } +} + +/// Read a timestamp that [`decode_timestamp`] or a caller already checked. +/// +/// The fallback exists so an unrepresentable value is not a panic, not +/// because it is reachable. +pub(crate) fn stored_time(seconds: i64) -> SystemTime { + unix_time(seconds).unwrap_or(UNIX_EPOCH) +} + fn decode_identity(slot: ParsedSlot<'_>) -> Result where T: FromStr + ToString, diff --git a/crates/libtmux/src/snapshot/tests.rs b/crates/libtmux/src/snapshot/tests.rs index e3a8783a..58d80924 100644 --- a/crates/libtmux/src/snapshot/tests.rs +++ b/crates/libtmux/src/snapshot/tests.rs @@ -1586,6 +1586,22 @@ fn snapshot_catalog_typed_decoders_reject_noncanonical_or_overflowing_values() { } } +/// Every Unix second tmux can print converts, before 1970 included, and none +/// panics on the way: `UNIX_EPOCH + offset` would for a negative value. +#[test] +fn unix_time_converts_every_second_an_i64_holds() { + use std::time::UNIX_EPOCH; + + for seconds in [i64::MIN, -1, 0, 1, i64::MAX] { + let time = super::unix_time(seconds).expect("a Unix SystemTime holds any i64 of seconds"); + let back = match time.duration_since(UNIX_EPOCH) { + Ok(after) => i128::from(after.as_secs()), + Err(before) => -i128::from(before.duration().as_secs()), + }; + assert_eq!(back, i128::from(seconds), "{seconds} round-trips"); + } +} + #[test] fn snapshot_catalog_identity_decoders_reject_noncanonical_or_overflowing_values() { for (profile, field, invalid) in [ diff --git a/crates/libtmux/src/target.rs b/crates/libtmux/src/target.rs index a7044d61..a85ecf11 100644 --- a/crates/libtmux/src/target.rs +++ b/crates/libtmux/src/target.rs @@ -6,6 +6,7 @@ use std::hash::{Hash, Hasher}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::time::SystemTime; use crate::error::IdParseError; @@ -294,10 +295,13 @@ impl ServerGeneration { self.pid } - /// When that server started, as tmux reports it. + /// When that server started, to the whole second tmux keeps. + /// + /// [`Display`](fmt::Display) prints the same moment as the Unix seconds + /// tmux reports. #[must_use] - pub const fn start_time(self) -> i64 { - self.start_time + pub fn start_time(self) -> SystemTime { + crate::snapshot::stored_time(self.start_time) } } diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index e2f98e0f..1124ec60 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -4,6 +4,7 @@ use std::ffi::{OsStr, OsString}; use std::fmt; use std::hash::{Hash, Hasher}; use std::sync::Arc; +use std::time::SystemTime; use crate::formats::TmuxText; use crate::internal::core::Core; @@ -13,9 +14,9 @@ use crate::pane::Pane; #[cfg(feature = "query")] use crate::query::{FilterSchema, Filterable, ReadField}; use crate::session::Session; -use crate::snapshot::WindowProjection; #[cfg(feature = "query")] use crate::snapshot::{Availability, FieldRef, WindowFields, WindowInfo}; +use crate::snapshot::{WindowProjection, stored_time}; use crate::target::{ServerIdentity, SessionId, WindowId}; use crate::{Command, CommandResult, Error, ObjectKind, TmuxArg}; @@ -256,26 +257,32 @@ impl Window { self.projection.link().has_bell() } - /// Return when the window last produced output, in seconds since the - /// Unix epoch. + /// Return when the window last produced output. /// /// tmux stamps this on every byte a pane in the window writes, whatever /// the window options say. That is what separates it from /// [`Self::has_activity`], which is an alert and stays false unless /// `monitor-activity` was turned on -- and it is off by default. /// + /// tmux keeps whole seconds. `WindowFields::window_activity` filters and + /// reads the same field as the `i64` of Unix seconds tmux reports, because + /// the query grammar compares integers. + /// /// # Examples /// /// ``` /// # fn main() -> Result<(), Box> { /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; /// # runtime.block_on(async { + /// use std::time::{Duration, SystemTime}; + /// /// let guard = libtmux::test::TestServer::new().await?; /// let session = guard.server().new_session("busy").await?; /// let window = session.active_window().await?.expect("a window"); /// /// // A window that has just been made has already produced output. - /// assert!(window.last_activity() > 0); + /// let idle = SystemTime::now().duration_since(window.last_activity())?; + /// assert!(idle < Duration::from_secs(3600)); /// /// guard.shutdown().await?; /// # Ok::<(), Box>(()) @@ -284,8 +291,8 @@ impl Window { /// # } /// ``` #[must_use] - pub fn last_activity(&self) -> i64 { - *self.projection.window().window_activity() + pub fn last_activity(&self) -> SystemTime { + stored_time(*self.projection.window().window_activity()) } /// Report whether one of the window's panes is zoomed to fill it. diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index 65b0aa77..9a276406 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -1081,6 +1081,106 @@ async fn a_client_reports_its_own_terminal_and_type() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Every timestamp accessor is a `SystemTime` inside the test's own window, +/// and names the moment its filter handle reads as Unix seconds. +/// +/// A unit error -- milliseconds for seconds, or an offset -- lands outside the +/// window, which a check for "positive" did not catch. +#[cfg(all(feature = "control-mode", feature = "query"))] +#[tokio::test] +async fn timestamps_are_system_times_that_agree_with_their_handles() { + use std::time::{SystemTime, UNIX_EPOCH}; + + use libtmux::control::ControlMode; + use libtmux::query::Filterable as _; + use libtmux::{Availability, Client, Session, Window}; + + // tmux keeps whole seconds, so the window opens on the second it started. + let since_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("the clock is past 1970"); + let before = UNIX_EPOCH + Duration::from_secs(since_epoch.as_secs()); + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let mut session = server.new_session("clock").await.expect("session"); + let control = ControlMode::attach(server, session.id()) + .await + .expect("control mode attaches"); + retry_until(Duration::from_secs(10), async || { + server + .clients() + .await + .is_ok_and(|clients| !clients.is_empty()) + }) + .await + .expect("the attached client is listed"); + + session.refresh().await.expect("session refreshes"); + let window = session + .active_window() + .await + .expect("window lookup") + .expect("a window"); + let client = server.clients().await.expect("clients").remove(0); + let generation = server.generation().await.expect("generation"); + let after = SystemTime::now(); + + let seconds = |time: SystemTime| { + let offset = time.duration_since(UNIX_EPOCH).expect("after 1970"); + i64::try_from(offset.as_secs()).expect("fits tmux's seconds") + }; + let sessions = Session::filter_fields(); + let last_attached = session.last_attached().expect("a client attached"); + for (label, time, handle) in [ + ( + "session_created", + session.created(), + session.get(sessions.session_created), + ), + ( + "session_last_attached", + last_attached, + session.get(sessions.session_last_attached), + ), + ( + "window_activity", + window.last_activity(), + window.get(Window::filter_fields().window_activity), + ), + ( + "client_created", + client.created(), + client.get(Client::filter_fields().client_created), + ), + ] { + assert!( + before <= time && time <= after, + "{label} is {time:?}, outside {before:?}..={after:?}", + ); + assert_eq!( + handle, + Availability::Available(seconds(time)), + "{label}'s handle reads the same moment as Unix seconds", + ); + } + + let started = generation.start_time(); + assert!( + before <= started && started <= after, + "start_time is {started:?}, outside {before:?}..={after:?}", + ); + assert!( + generation + .to_string() + .ends_with(&format!(" started {}", seconds(started))), + "Display prints the same moment as Unix seconds", + ); + + let _ = control.shutdown().await; + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// The remaining dispatch-only commands must reach tmux and be accepted. /// /// These change something a headless server cannot show back -- a prefix key diff --git a/crates/libtmux/tests/hierarchy.rs b/crates/libtmux/tests/hierarchy.rs index b323cccd..5d04a6fc 100644 --- a/crates/libtmux/tests/hierarchy.rs +++ b/crates/libtmux/tests/hierarchy.rs @@ -97,7 +97,7 @@ async fn listings_preserve_tmux_order_and_report_snapshot_values() { "a detached session reports no clients" ); assert_eq!(session.attached_client_count(), 0); - assert!(session.created() > 0); + assert!(session.created() > std::time::UNIX_EPOCH); assert_eq!( session.last_attached(), None, diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index 53d289b1..a5653455 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -42,7 +42,7 @@ async fn creating_an_object_returns_it_hydrated_in_one_command() { assert_eq!(session.name().as_bytes().to_vec(), b"work".to_vec()); assert_eq!(session.window_count(), 1); // The handle came back populated, so no follow-up listing was needed. - assert!(session.created() > 0); + assert!(session.created() > std::time::UNIX_EPOCH); let window = session .new_window(NewWindowOptions::new("editor").command("sleep 300")) From b1db400ef4096cb09f26c573b3b09eddbeda9f33 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 03:35:34 -0500 Subject: [PATCH 084/117] Server(feat[keys]): Read key bindings as fields why: Server::key_bindings returned tmux's bind-key lines, which quote a key by its content, print a table name bare with spaces and all, and carry no note, so no caller could split one back into its parts. tmux 3.7 added list-keys -F, which reads each part as a format. The typed reader frames those formats the way a listing plan does, #{q:} with `=` between fields, and a row that does not frame fails the listing rather than being skipped. Below 3.7 it refuses; the lines stay for those releases, which four of the five compatibility lanes run. It never sends -T. tmux 3.7 through 3.7c send a listing of exactly one binding to the message log instead of stdout, so a one-binding table lists empty, in either form. Listing every table and narrowing here avoids that except on a server with a single binding left. Measured on 3.7c (empty) and 3.7d (printed); 3.8-rc's source drops the condition. The command stays tmux's rendering, argument syntax with `\;` between commands, and is not parsed further. That is not the single command string bind_key takes, which the first round-trip test showed: tmux read `\;` as a literal and display-message got four arguments. what: - Add Server::typed_key_bindings, KeyBinding and since::LIST_KEYS_FORMAT - Add formats::split_quoted_rows for listings of formats the catalog does not carry, and a fuzz target with seeds captured from tmux - Note the 3.7-3.7c defect on Server::key_bindings and in design.md - Test the fields on real tmux, including two one-binding tables, and the refusal below 3.7 - Record the addition in the changelog, the parity ledger and public-api.txt --- .github/workflows/ci.yml | 1 + crates/libtmux/docs/design.md | 19 ++ crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 13 ++ crates/libtmux/src/formats.rs | 3 +- crates/libtmux/src/formats/row.rs | 108 +++++++++ crates/libtmux/src/lib.rs | 7 +- crates/libtmux/src/server.rs | 12 +- crates/libtmux/src/server/keys.rs | 253 +++++++++++++++++++++ crates/libtmux/src/version.rs | 6 + crates/libtmux/tests/commands.rs | 106 +++++++++ fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/list_keys.rs | 11 + fuzz/seeds/list_keys/defaults | 275 +++++++++++++++++++++++ fuzz/seeds/list_keys/escapes-and-newline | 3 + 15 files changed, 821 insertions(+), 5 deletions(-) create mode 100644 crates/libtmux/src/server/keys.rs create mode 100644 fuzz/fuzz_targets/list_keys.rs create mode 100644 fuzz/seeds/list_keys/defaults create mode 100644 fuzz/seeds/list_keys/escapes-and-newline diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84245a2e..a493bb58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,7 @@ jobs: - filter_expr_json - format_rows - text_filter + - list_keys - workspace_yaml steps: - uses: actions/checkout@v7 diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index a1a54ec0..18058796 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -1358,6 +1358,25 @@ assertion blamed the arguments. `TestServer::daemon_state` exists because of that: a test driving tmux cannot tell the two apart from the reply, and the fixture is the daemon's parent, so it is the only thing that can. +### A one-binding key listing goes to the message log on tmux 3.7 through 3.7c + +`list-keys` gained `-F` in 3.7, and in the same release its print loop reads +`if ((single && tc != NULL) || n == 1) status_message_set(...)`: a listing of +exactly one binding becomes a status message instead of a line of output. With +no attached client the message goes to the server's message log. The command +still exits zero, so `list-keys -T ` on a table holding one binding +answers with nothing, in the `bind-key` form and the `-F` form alike. + +`Server::typed_key_bindings` therefore never passes `-T`. It lists every table +and narrows the rows itself, and across every table a listing is one binding +only on a server with a single binding left. `Server::key_bindings` still +sends `-T`, since its lines are tmux's own, and says so. + +The source of 3.7 and 3.7c has the condition and 3.8-rc does not. Measured: +3.7c prints nothing for a one-binding table and 3.7d prints it. +`real_tmux_compat_key_bindings_read_as_fields` binds two one-binding tables, +and fails on 3.7c when `-T` is sent. + ### Two shapes that make a test flaky under load Both of these passed locally for a long time and failed in CI, which has fewer diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index d4209de1..1daa0345 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -525,7 +525,7 @@ Sources: `src/libtmux/server.py`, `docs/api/libtmux.server.md`, | `run_shell` | Foreground returns stdout lines; background returns `None`; stderr raises. `cwd` requires 3.4, `show_stderr` 3.6, and positional args 3.7; unsupported options warn and disappear. | `Server::run_shell` returns the command's output and `Server::spawn_shell` runs it without waiting; covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | | `wait_for` | Unit; default can block indefinitely. Lock, unlock, and set actions are independent booleans without mutual-exclusion validation. | `Server::signal_channel`, `lock_channel`, `unlock_channel`, and `wait_for_channel` cover `wait-for -S`, `-L`, `-U`, and the flagless blocking form. The wait is bounded rather than indefinite, and running out of time is `ChannelWait::TimedOut` rather than an error, so a caller separates it from a failure to reach tmux. | Options, hooks, and advanced command families | `implemented` | | `bind_key`, `unbind_key` | Unit; stderr raises. `unbind_key(None, all_keys=False)` is forwarded for tmux to reject. | `Server::bind_key` and `Server::unbind_key`, both taking the key table by name. | Options, hooks, and advanced command families | `implemented` | -| `list_keys` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | `Server::key_bindings` lists default or table-scoped bindings. The tmux 3.7 format selector is not exposed. | Options, hooks, and advanced command families | `in progress` | +| `list_keys` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | `Server::key_bindings` returns the `bind-key` lines on every release. `Server::typed_key_bindings` reads `list-keys -F` into `KeyBinding` fields on tmux 3.7 and later (`since::LIST_KEYS_FORMAT`) and refuses below it; it narrows a table itself, because 3.7 through 3.7c print nothing for a one-binding `list-keys -T`. A free-form template is not exposed. Covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | | `list_commands` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | Not offered. `list-commands` enumerates the running tmux's command table as raw strings; nothing here dispatches by name at runtime, so the answer would be documentation rather than API. `Server::cmd` reaches it, which is also where a caller would use it. | Options, hooks, and advanced command families | `excluded` | | `lock_server`, `start_server` | Unit; stderr raises. Lock requires an attached client. | `Server::lock_all` and `Server::start`; covered by [`tests/mutations.rs`](../tests/mutations.rs). | Object mutations and interactions | `implemented` | | `server_access` | Returns access-rule lines only in list mode, otherwise `None`. Hard error below 3.3; read-only plus write raises `ValueError`. | `access_rules()`, `grant_access(user, AccessMode)`, and `revoke_access(user)`, all refusing below tmux 3.3 with `Error::UnsupportedCapability`. `AccessMode` is an enum rather than two flags, so tmux's exclusive `-r`/`-w` pair cannot both be passed: the contradiction Python raises `ValueError` for is unrepresentable. Covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index 981c074b..f534ce6d 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -238,6 +238,7 @@ constant libtmux::since::CAPTURE_TRIM_BLANK_CELLS: libtmux::ReleaseVersion constant libtmux::since::CLIENTS_HIDE_STOPPED: libtmux::ReleaseVersion constant libtmux::since::CONTROL_PANE_OFF: libtmux::ReleaseVersion constant libtmux::since::JSON_LAYOUTS: libtmux::ReleaseVersion +constant libtmux::since::LIST_KEYS_FORMAT: libtmux::ReleaseVersion constant libtmux::since::MIRRORED_LAYOUTS: libtmux::ReleaseVersion constant libtmux::since::PANE_BORDER_FORMAT_PER_PANE: libtmux::ReleaseVersion constant libtmux::since::PANE_BORDER_STYLE_PER_PANE: libtmux::ReleaseVersion @@ -379,6 +380,11 @@ function libtmux::IdParseError::expected_sigil: const fn(self) -> char function libtmux::JoinOptions::full: const fn(self) -> Self function libtmux::JoinOptions::new: const fn(direction: libtmux::SplitDirection) -> Self function libtmux::JoinOptions::size: const fn(self, size: libtmux::PaneSize) -> Self +function libtmux::KeyBinding::command: const fn(&self) -> &libtmux::TmuxText +function libtmux::KeyBinding::key: const fn(&self) -> &libtmux::TmuxText +function libtmux::KeyBinding::note: const fn(&self) -> Option<&libtmux::TmuxText> +function libtmux::KeyBinding::repeats: const fn(&self) -> bool +function libtmux::KeyBinding::table: const fn(&self) -> &libtmux::TmuxText function libtmux::Layout::as_str: const fn(self) -> &'static str function libtmux::Layout::minimum_release: const fn(self) -> libtmux::ReleaseVersion function libtmux::ListingDecodeError::field_name: const fn(&self) -> Option<&'static str> @@ -580,6 +586,7 @@ function libtmux::Server::start: async fn(&self) -> Result<(), libtmux::Error> function libtmux::Server::tmux_executable: fn(&self) -> &OsStr function libtmux::Server::typed_global_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::typed_global_window_option: async fn(&self, name: &str) -> Result, libtmux::Error> +function libtmux::Server::typed_key_bindings: async fn(&self, table: Option<&str>) -> Result, libtmux::Error> function libtmux::Server::typed_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::unbind_key: async fn(&self, table: &str, key: &str) -> Result<(), libtmux::Error> function libtmux::Server::unlock_channel: async fn(&self, channel: &str) -> Result<(), libtmux::Error> @@ -1017,6 +1024,7 @@ impl Clone for libtmux::EnvironmentEntry impl Clone for libtmux::ErrorKind impl Clone for libtmux::IdParseError impl Clone for libtmux::JoinOptions +impl Clone for libtmux::KeyBinding impl Clone for libtmux::Layout impl Clone for libtmux::LayoutSpec impl Clone for libtmux::ListingDecodeError @@ -1192,6 +1200,7 @@ impl Debug for libtmux::Error impl Debug for libtmux::ErrorKind impl Debug for libtmux::IdParseError impl Debug for libtmux::JoinOptions +impl Debug for libtmux::KeyBinding impl Debug for libtmux::Layout impl Debug for libtmux::LayoutSpec impl Debug for libtmux::ListingDecodeError @@ -1354,6 +1363,7 @@ impl Eq for libtmux::EnvironmentEntry impl Eq for libtmux::ErrorKind impl Eq for libtmux::IdParseError impl Eq for libtmux::JoinOptions +impl Eq for libtmux::KeyBinding impl Eq for libtmux::Layout impl Eq for libtmux::LayoutSpec impl Eq for libtmux::ListingDecodeError @@ -1502,6 +1512,7 @@ impl Hash for libtmux::ChannelWait impl Hash for libtmux::Client impl Hash for libtmux::ErrorKind impl Hash for libtmux::IdParseError +impl Hash for libtmux::KeyBinding impl Hash for libtmux::Layout impl Hash for libtmux::MenuItem impl Hash for libtmux::Pane @@ -1577,6 +1588,7 @@ impl PartialEq for libtmux::EnvironmentEntry impl PartialEq for libtmux::ErrorKind impl PartialEq for libtmux::IdParseError impl PartialEq for libtmux::JoinOptions +impl PartialEq for libtmux::KeyBinding impl PartialEq for libtmux::Layout impl PartialEq for libtmux::LayoutSpec impl PartialEq for libtmux::ListingDecodeError @@ -1864,6 +1876,7 @@ struct libtmux::DispatchLimits struct libtmux::EngineCapabilities struct libtmux::IdParseError struct libtmux::JoinOptions +struct libtmux::KeyBinding struct libtmux::ListingDecodeError struct libtmux::MenuItem struct libtmux::NewSessionOptions diff --git a/crates/libtmux/src/formats.rs b/crates/libtmux/src/formats.rs index c92d44a9..db71303a 100644 --- a/crates/libtmux/src/formats.rs +++ b/crates/libtmux/src/formats.rs @@ -14,8 +14,9 @@ pub(crate) use plan::{FormatPlan, PlanFieldState, PlanPurpose}; #[cfg(test)] use plan::{PlanVersion, TransportDialect, for_profile_selection_test}; #[cfg(test)] -pub(crate) use row::{FIELD_SEPARATOR, FormatCodecPhase, decode_ascii}; +pub(crate) use row::{FIELD_SEPARATOR, decode_ascii}; pub(crate) use row::{FormatCodecError, FormatCodecErrorKind, ParsedRow, ParsedSlot, decode_text}; +pub(crate) use row::{FormatCodecPhase, split_quoted_rows}; #[cfg(test)] use row::{QUOTE_SHELL_SPECIALS, encode_like_tmux}; pub use text::TmuxText; diff --git a/crates/libtmux/src/formats/row.rs b/crates/libtmux/src/formats/row.rs index b00aa633..e60c6c92 100644 --- a/crates/libtmux/src/formats/row.rs +++ b/crates/libtmux/src/formats/row.rs @@ -450,6 +450,27 @@ impl FormatCodecError { } } + /// Construct a failure in a field named outside the catalog. + pub(crate) const fn uncatalogued( + kind: FormatCodecErrorKind, + phase: FormatCodecPhase, + row: usize, + field: usize, + field_name: &'static str, + offset: Option, + ) -> Self { + Self { + kind, + phase, + row: Some(row), + field: Some(field), + field_name: Some(field_name), + expected: None, + offset, + profile: None, + } + } + /// Construct an ASCII decoder failure from slot coordinates only. const fn non_ascii(slot: &ParsedSlot<'_>) -> Self { Self { @@ -704,3 +725,90 @@ pub(crate) fn decode_ascii(slot: ParsedSlot<'_>) -> Result<&str, FormatCodecErro pub(crate) fn decode_text(slot: ParsedSlot<'_>) -> TmuxText { TmuxText::from_bytes(slot.as_bytes()) } + +/// Split output of a template naming formats the catalog does not carry. +/// +/// Each of `names` was rendered `#{q:name}` or bare, followed by +/// [`FIELD_SEPARATOR`], and each row ends with LF, as in a plan's template. +/// Only [`TransportDialect::RawQ`] escapes are accepted, so a caller needs a +/// release outside the `vis` range. A row that does not frame fails the +/// whole listing: nothing is skipped. +pub(crate) fn split_quoted_rows( + stdout: &[u8], + names: [&'static str; N], +) -> Result; N]>, FormatCodecError> { + let mut cursor = 0; + let mut rows = Vec::new(); + + while cursor < stdout.len() { + let row = rows.len(); + let mut fields: [Vec; N] = std::array::from_fn(|_| Vec::new()); + for (field, (bytes, name)) in fields.iter_mut().zip(names).enumerate() { + let error = |kind, phase, offset| { + FormatCodecError::uncatalogued(kind, phase, row, field, name, Some(offset)) + }; + loop { + let offset = cursor; + let Some(byte) = stdout.get(cursor).copied() else { + return Err(error( + FormatCodecErrorKind::MissingFieldTerminator, + FormatCodecPhase::Field, + offset, + )); + }; + cursor += 1; + match byte { + 0 => { + return Err(error( + FormatCodecErrorKind::EmbeddedNul, + FormatCodecPhase::Field, + offset, + )); + } + FIELD_SEPARATOR => break, + b'\\' => match stdout.get(cursor).copied() { + Some(escaped) if QUOTE_SHELL_SPECIALS.contains(&escaped) => { + bytes.push(escaped); + cursor += 1; + } + Some(_) => { + return Err(error( + FormatCodecErrorKind::InvalidEscape, + FormatCodecPhase::Escape, + cursor, + )); + } + None => { + return Err(error( + FormatCodecErrorKind::DanglingEscape, + FormatCodecPhase::Escape, + cursor, + )); + } + }, + _ => bytes.push(byte), + } + } + } + + let last = N.saturating_sub(1); + let terminator = |kind| { + FormatCodecError::uncatalogued( + kind, + FormatCodecPhase::RowTerminator, + row, + last, + names.get(last).copied().unwrap_or_default(), + Some(cursor), + ) + }; + match stdout.get(cursor) { + Some(b'\n') => cursor += 1, + Some(_) => return Err(terminator(FormatCodecErrorKind::UnexpectedRowTerminator)), + None => return Err(terminator(FormatCodecErrorKind::MissingRowLf)), + } + rows.push(fields); + } + + Ok(rows) +} diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index 20784209..b14b6b8c 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -377,9 +377,12 @@ pub use options::{ option_schema, }; pub use pane::{CaptureOptions, CapturedLine, Pane, PaneWait}; +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub use server::__fuzz_parse_key_bindings; pub use server::{ - AccessMode, AccessRule, ChannelWait, Chooser, MenuItem, NewSessionOptions, Principal, - PromptKind, Server, ServerBuilder, SessionTree, WindowTree, + AccessMode, AccessRule, ChannelWait, Chooser, KeyBinding, MenuItem, NewSessionOptions, + Principal, PromptKind, Server, ServerBuilder, SessionTree, WindowTree, }; #[cfg(feature = "query")] pub use server::{SessionTreeFields, WindowTreeFields}; diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index fc8bccdd..bff2b21e 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -25,6 +25,11 @@ mod channels; mod discovery; mod interactive; pub use interactive::MenuItem; +mod keys; +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub use keys::__fuzz_parse_key_bindings; +pub use keys::KeyBinding; mod settings; pub use builder::ServerBuilder; pub use discovery::{SessionTree, WindowTree}; @@ -1098,7 +1103,12 @@ impl Server { /// /// Each line is a complete `bind-key` command in tmux's own quoting, which /// this crate deliberately does not re-parse: the same value is rendered - /// bare, double quoted, or single quoted depending on content. + /// bare, double quoted, or single quoted depending on content, and a table + /// name is printed bare, spaces and all. [`Self::typed_key_bindings`] + /// reads the same bindings as fields on tmux 3.7 and later. + /// + /// On tmux 3.7 through 3.7c, a `table` holding exactly one binding lists + /// empty: those releases send a one-line listing to the message log. /// /// # Errors /// diff --git a/crates/libtmux/src/server/keys.rs b/crates/libtmux/src/server/keys.rs new file mode 100644 index 00000000..4a18a1ac --- /dev/null +++ b/crates/libtmux/src/server/keys.rs @@ -0,0 +1,253 @@ +//! Key bindings read as fields through `list-keys -F`. + +use super::Server; +use crate::formats::{FormatCodecError, FormatCodecErrorKind, FormatCodecPhase, TmuxText}; +use crate::version::since::LIST_KEYS_FORMAT; +use crate::{Command, Error, ListingDecodeError}; + +/// The fields [`TEMPLATE`] renders, in order. +const FIELDS: [&str; 5] = [ + "key_table", + "key_string", + "key_repeat", + "key_note", + "key_command", +]; + +/// `list-keys -F` template for [`FIELDS`], framed as a format plan's is. +const TEMPLATE: &str = + "#{q:key_table}=#{q:key_string}=#{key_repeat}=#{q:key_note}=#{q:key_command}="; + +/// One key binding, as tmux holds it. +/// +/// `bind-key -n` is `-T root`, so [`Self::table`] answers it. The command is +/// the text a `bind-key` line in a configuration file carries, with `\;` +/// between commands, and is not parsed further. That is argument syntax: as +/// the single command string [`Server::bind_key`] takes, `\;` is a literal +/// semicolon rather than a separator. +/// +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; +/// # runtime.block_on(async { +/// let guard = libtmux::test::TestServer::new().await?; +/// let server = guard.server(); +/// server.bind_key("prefix", "Y", "display-message hello").await?; +/// +/// let version = server.capabilities().await?.tmux_version().clone(); +/// if version.has_behavior(&libtmux::since::LIST_KEYS_FORMAT) { +/// let bindings = server.typed_key_bindings(Some("prefix")).await?; +/// let bound = bindings.iter().find(|binding| binding.key() == "Y"); +/// +/// let bound = bound.expect("the binding is listed"); +/// assert_eq!(bound.command(), "display-message hello"); +/// assert!(!bound.repeats()); +/// assert_eq!(bound.note(), None); +/// } +/// +/// guard.shutdown().await?; +/// # Ok::<(), Box>(()) +/// # })?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct KeyBinding { + table: TmuxText, + key: TmuxText, + command: TmuxText, + note: Option, + repeats: bool, +} + +impl KeyBinding { + /// Return the key table, such as `prefix`, `root` or `copy-mode-vi`. + #[must_use] + pub const fn table(&self) -> &TmuxText { + &self.table + } + + /// Return the key as tmux names it, such as `C-b` or `"`, unquoted. + #[must_use] + pub const fn key(&self) -> &TmuxText { + &self.key + } + + /// Return the bound command list, as a `bind-key` line renders it. + #[must_use] + pub const fn command(&self) -> &TmuxText { + &self.command + } + + /// Return the note `bind-key -N` attached, if any. + /// + /// An empty note reads as none: tmux reports both as an empty value. + #[must_use] + pub const fn note(&self) -> Option<&TmuxText> { + self.note.as_ref() + } + + /// Report whether the binding repeats, `bind-key -r`. + #[must_use] + pub const fn repeats(&self) -> bool { + self.repeats + } +} + +/// Decode `list-keys -F` output rendered with [`TEMPLATE`]. +/// +/// A row that does not frame, or whose repeat flag is neither `0` nor `1`, +/// fails the whole listing rather than being skipped. +fn parse(stdout: &[u8]) -> Result, FormatCodecError> { + crate::formats::split_quoted_rows(stdout, FIELDS)? + .into_iter() + .enumerate() + .map(|(row, [table, key, repeat, note, command])| { + let repeats = match repeat.as_slice() { + b"0" => false, + b"1" => true, + _ => { + return Err(FormatCodecError::uncatalogued( + FormatCodecErrorKind::InvalidValue, + FormatCodecPhase::Decode, + row, + 2, + FIELDS[2], + None, + )); + } + }; + Ok(KeyBinding { + table: TmuxText::from(table), + key: TmuxText::from(key), + command: TmuxText::from(command), + note: (!note.is_empty()).then(|| TmuxText::from(note)), + repeats, + }) + }) + .collect() +} + +impl Server { + /// List key bindings as fields: table, key, command, note and repeat. + /// + /// `table` narrows the listing to one key table. A table with no bindings + /// lists empty, since tmux keeps no empty table to refuse. + /// + /// Every table is fetched and the narrowing done here. tmux 3.7 through + /// 3.7c send a listing of exactly one binding to the message log instead + /// of printing it, so `list-keys -T` on a table holding one binding + /// answers with nothing. Across every table, that happens only on a + /// server with a single binding left, which lists none on those releases. + /// + /// # Errors + /// + /// Returns [`Error::UnsupportedCapability`] below tmux 3.7, which has no + /// `list-keys -F`; [`Self::key_bindings`] reads the `bind-key` lines + /// those releases print. Returns [`Error::DecodeListing`] when a row does + /// not decode, and an error when tmux cannot be reached or refuses the + /// listing. + pub async fn typed_key_bindings(&self, table: Option<&str>) -> Result, Error> { + self.require("list-keys formats", LIST_KEYS_FORMAT).await?; + + let result = self + .cmd(Command::new("list-keys").arg("-F").arg(TEMPLATE)) + .await?; + if !result.success() { + return Err(Error::from_refused_result("list-keys", &result, None)); + } + + let bindings = parse(result.stdout()).map_err(|detail| Error::DecodeListing { + list_command: "list-keys", + detail: ListingDecodeError::new(detail), + })?; + Ok(match table { + Some(table) => bindings + .into_iter() + .filter(|binding| binding.table == table) + .collect(), + None => bindings, + }) + } +} + +/// Decode `list-keys -F` output, for fuzzing only. +/// +/// Not a supported API: it lets a fuzzer reach the decoder without making it +/// public, behind a feature no release turns on. +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub fn __fuzz_parse_key_bindings(stdout: &[u8]) { + let _ = parse(stdout); +} + +#[cfg(test)] +mod tests { + use super::{KeyBinding, parse}; + use crate::TmuxText; + use crate::formats::FormatCodecErrorKind; + + fn binding(table: &str, key: &str, command: &str) -> KeyBinding { + KeyBinding { + table: TmuxText::from(table), + key: TmuxText::from(key), + command: TmuxText::from(command), + note: None, + repeats: false, + } + } + + /// tmux 3.7c's output for two bindings; its `#{q:}` leaves LF bare. + #[test] + fn rows_decode_escapes_and_keep_bare_newlines() { + let stdout = br#"solo=M-\'=0==display-message\ a\=b\ \\\;\ display-message\ c= +sp\ ace=\"=1=a +note=display-message\ \"two\ words\"= +"#; + + let solo = binding("solo", "M-'", r"display-message a=b \; display-message c"); + let mut spaced = binding("sp ace", "\"", "display-message \"two words\""); + spaced.note = Some(TmuxText::from("a\nnote")); + spaced.repeats = true; + + assert_eq!(parse(stdout), Ok(vec![solo, spaced])); + assert_eq!(parse(b""), Ok(Vec::new())); + } + + /// Each malformed shape fails the listing; none yields a shorter one. + #[test] + fn a_row_that_does_not_frame_fails_the_listing() { + let good = b"root=x=0==send-keys=\n".as_slice(); + for (stdout, kind) in [ + ( + b"root=x=0==send-keys".as_slice(), + FormatCodecErrorKind::MissingFieldTerminator, + ), + (b"root=x=0==send-keys=", FormatCodecErrorKind::MissingRowLf), + ( + b"root=x=0==send-keys=x\n", + FormatCodecErrorKind::UnexpectedRowTerminator, + ), + ( + b"root=\\x=0==send-keys=\n", + FormatCodecErrorKind::InvalidEscape, + ), + (b"root=x\\", FormatCodecErrorKind::DanglingEscape), + ( + b"root=x\0=0==send-keys=\n", + FormatCodecErrorKind::EmbeddedNul, + ), + ( + b"root=x=2==send-keys=\n", + FormatCodecErrorKind::InvalidValue, + ), + ] { + let listing = [good, stdout].concat(); + let error = parse(&listing).expect_err("a malformed row fails"); + assert_eq!(error.kind(), kind, "{:?}", String::from_utf8_lossy(stdout)); + assert_eq!(error.row(), Some(1)); + } + } +} diff --git a/crates/libtmux/src/version.rs b/crates/libtmux/src/version.rs index d418cecb..0a8c1160 100644 --- a/crates/libtmux/src/version.rs +++ b/crates/libtmux/src/version.rs @@ -728,6 +728,12 @@ pub mod since { /// `capture-pane -F`, and so [`crate::Pane::capture_lines`]. pub const CAPTURE_LINE_FLAGS: ReleaseVersion = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL); + /// `list-keys -F`, and so [`crate::Server::typed_key_bindings`]. + /// + /// Below this release `list-keys` prints only its own `bind-key` lines, + /// which quote a key and leave a table name bare, and carry no note. + pub const LIST_KEYS_FORMAT: ReleaseVersion = ReleaseVersion::new(3, 7, ReleaseSuffix::FINAL); + /// Taking a pane out of a control client's stream without crashing the /// server, and so [`crate::control::ControlSender::mute_pane`] using `off`. /// diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index 9a276406..0885e4c7 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -117,6 +117,112 @@ async fn key_bindings_can_be_added_and_removed() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// Each field reads back as tmux holds it, for the shapes the `bind-key` lines +/// get wrong: a table name with a space, a key tmux quotes, a note with a +/// newline, the repeat flag, and a command holding the field separator. +/// +/// `solo` holds one binding, the listing tmux 3.7 through 3.7c print nowhere +/// when asked for with `-T`. +#[tokio::test] +async fn real_tmux_compat_key_bindings_read_as_fields() { + use libtmux::{Error, since}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let version = server + .capabilities() + .await + .expect("capabilities") + .tmux_version() + .clone(); + if !version.has_behavior(&since::LIST_KEYS_FORMAT) { + let refused = server + .typed_key_bindings(None) + .await + .expect_err("list-keys -F is 3.7 and later"); + assert!( + matches!(refused, Error::UnsupportedCapability { .. }), + "{refused:?}" + ); + guard.shutdown().await.expect("tmux fixture shuts down"); + return; + } + + for command in [ + Command::new("bind-key") + .arg("-r") + .arg("-N") + .arg("a\nnote") + .arg("-T") + .arg("sp ace") + .arg("\"") + .arg("display-message 'two words'"), + Command::new("bind-key") + .arg("-T") + .arg("solo") + .arg("M-'") + .arg("display-message a=b ; display-message c"), + ] { + let bound = server.cmd(command).await.expect("bind-key runs"); + assert!(bound.success(), "{:?}", bound.stderr_lossy()); + } + + let spaced = server + .typed_key_bindings(Some("sp ace")) + .await + .expect("bindings decode"); + assert_eq!(spaced.len(), 1); + assert_eq!(spaced[0].table(), "sp ace"); + assert_eq!(spaced[0].key(), "\""); + assert_eq!(spaced[0].command(), "display-message \"two words\""); + assert_eq!( + spaced[0].note().map(libtmux::TmuxText::as_bytes), + Some(b"a\nnote".as_slice()) + ); + assert!(spaced[0].repeats()); + + let solo = server + .typed_key_bindings(Some("solo")) + .await + .expect("bindings decode"); + assert_eq!(solo.len(), 1, "a one-binding table is listed"); + assert_eq!(solo[0].key(), "M-'"); + assert_eq!( + solo[0].command(), + r"display-message a=b \; display-message c" + ); + assert_eq!(solo[0].note(), None); + assert!(!solo[0].repeats()); + + // Nothing tmux printed was dropped: one binding per `bind-key` line, and + // the command is the text that line ends with. + let all = server + .typed_key_bindings(None) + .await + .expect("bindings decode"); + let lines = server + .key_bindings(None) + .await + .expect("bindings are listed"); + assert_eq!(all.len(), lines.len()); + let command = solo[0].command().as_str().expect("the command is UTF-8"); + assert!( + lines + .iter() + .any(|line| line.contains("-T solo") && line.ends_with(command)), + "{lines:?}", + ); + assert!( + server + .typed_key_bindings(Some("no-such-table")) + .await + .expect("bindings decode") + .is_empty() + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[tokio::test] async fn formats_expand_against_a_target() { let guard = TestServer::builder().start().await.expect("tmux starts"); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 386b1e72..ed85e5ca 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -36,6 +36,13 @@ test = false doc = false bench = false +[[bin]] +name = "list_keys" +path = "fuzz_targets/list_keys.rs" +test = false +doc = false +bench = false + [[bin]] name = "workspace_yaml" path = "fuzz_targets/workspace_yaml.rs" diff --git a/fuzz/fuzz_targets/list_keys.rs b/fuzz/fuzz_targets/list_keys.rs new file mode 100644 index 00000000..f325b597 --- /dev/null +++ b/fuzz/fuzz_targets/list_keys.rs @@ -0,0 +1,11 @@ +//! The `list-keys -F` decoder, fed arbitrary bytes. +//! +//! Table names, notes and commands are whatever a configuration bound, so the +//! decoder reads bytes nobody in this process wrote. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + libtmux::__fuzz_parse_key_bindings(data); +}); diff --git a/fuzz/seeds/list_keys/defaults b/fuzz/seeds/list_keys/defaults new file mode 100644 index 00000000..f4ce24c8 --- /dev/null +++ b/fuzz/seeds/list_keys/defaults @@ -0,0 +1,275 @@ +copy-mode=Escape=0==send-keys\ -X\ cancel= +copy-mode=Space=0==send-keys\ -X\ page-down= +copy-mode=,=0==send-keys\ -X\ jump-reverse= +copy-mode=\;=0==send-keys\ -X\ jump-again= +copy-mode=F=0==command-prompt\ -1\ -p\ \"\(jump\ backward\)\"\ {\ send-keys\ -X\ jump-backward\ --\ \"\%\%\"\ }= +copy-mode=N=0==send-keys\ -X\ search-reverse= +copy-mode=P=0==send-keys\ -X\ toggle-position= +copy-mode=R=0==send-keys\ -X\ rectangle-toggle= +copy-mode=T=0==command-prompt\ -1\ -p\ \"\(jump\ to\ backward\)\"\ {\ send-keys\ -X\ jump-to-backward\ --\ \"\%\%\"\ }= +copy-mode=X=0==send-keys\ -X\ set-mark= +copy-mode=f=0==command-prompt\ -1\ -p\ \"\(jump\ forward\)\"\ {\ send-keys\ -X\ jump-forward\ --\ \"\%\%\"\ }= +copy-mode=g=0==command-prompt\ -p\ \"\(goto\ line\)\"\ {\ send-keys\ -X\ goto-line\ --\ \"\%\%\"\ }= +copy-mode=n=0==send-keys\ -X\ search-again= +copy-mode=q=0==send-keys\ -X\ cancel= +copy-mode=r=0==send-keys\ -X\ refresh-from-pane= +copy-mode=t=0==command-prompt\ -1\ -p\ \"\(jump\ to\ forward\)\"\ {\ send-keys\ -X\ jump-to-forward\ --\ \"\%\%\"\ }= +copy-mode=Home=0==send-keys\ -X\ start-of-line= +copy-mode=End=0==send-keys\ -X\ end-of-line= +copy-mode=NPage=0==send-keys\ -X\ page-down= +copy-mode=PPage=0==send-keys\ -X\ page-up= +copy-mode=Up=0==send-keys\ -X\ cursor-up= +copy-mode=Down=0==send-keys\ -X\ cursor-down= +copy-mode=Left=0==send-keys\ -X\ cursor-left= +copy-mode=Right=0==send-keys\ -X\ cursor-right= +copy-mode=MouseDown1Pane=0==select-pane= +copy-mode=MouseDrag1Pane=0==select-pane\ \\\;\ send-keys\ -X\ begin-selection= +copy-mode=MouseDragEnd1Pane=0==send-keys\ -X\ copy-pipe-and-cancel= +copy-mode=WheelDownPane=0==select-pane\ \\\;\ send-keys\ -X\ -N\ 5\ scroll-down= +copy-mode=WheelUpPane=0==select-pane\ \\\;\ send-keys\ -X\ -N\ 5\ scroll-up= +copy-mode=DoubleClick1Pane=0==select-pane\ \\\;\ send-keys\ -X\ select-word\ \\\;\ run-shell\ -d\ 0.3\ \\\;\ send-keys\ -X\ copy-pipe-and-cancel= +copy-mode=TripleClick1Pane=0==select-pane\ \\\;\ send-keys\ -X\ select-line\ \\\;\ run-shell\ -d\ 0.3\ \\\;\ send-keys\ -X\ copy-pipe-and-cancel= +copy-mode=M-1=0==command-prompt\ -N\ -I\ 1\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-2=0==command-prompt\ -N\ -I\ 2\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-3=0==command-prompt\ -N\ -I\ 3\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-4=0==command-prompt\ -N\ -I\ 4\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-5=0==command-prompt\ -N\ -I\ 5\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-6=0==command-prompt\ -N\ -I\ 6\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-7=0==command-prompt\ -N\ -I\ 7\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-8=0==command-prompt\ -N\ -I\ 8\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-9=0==command-prompt\ -N\ -I\ 9\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode=M-\<=0==send-keys\ -X\ history-top= +copy-mode=M-\>=0==send-keys\ -X\ history-bottom= +copy-mode=M-R=0==send-keys\ -X\ top-line= +copy-mode=M-b=0==send-keys\ -X\ previous-word= +copy-mode=M-f=0==send-keys\ -X\ next-word-end= +copy-mode=M-l=0==send-keys\ -X\ cursor-centre-horizontal= +copy-mode=M-m=0==send-keys\ -X\ back-to-indentation= +copy-mode=M-r=0==send-keys\ -X\ middle-line= +copy-mode=M-v=0==send-keys\ -X\ page-up= +copy-mode=M-w=0==send-keys\ -X\ copy-pipe-and-cancel= +copy-mode=M-x=0==send-keys\ -X\ jump-to-mark= +copy-mode=M-{=0==send-keys\ -X\ previous-paragraph= +copy-mode=M-}=0==send-keys\ -X\ next-paragraph= +copy-mode=M-Up=0==send-keys\ -X\ halfpage-up= +copy-mode=M-Down=0==send-keys\ -X\ halfpage-down= +copy-mode=C-Space=0==send-keys\ -X\ begin-selection= +copy-mode=C-\[=0==send-keys\ -X\ cancel= +copy-mode=C-a=0==send-keys\ -X\ start-of-line= +copy-mode=C-b=0==send-keys\ -X\ cursor-left= +copy-mode=C-c=0==send-keys\ -X\ cancel= +copy-mode=C-e=0==send-keys\ -X\ end-of-line= +copy-mode=C-f=0==send-keys\ -X\ cursor-right= +copy-mode=C-g=0==send-keys\ -X\ clear-selection= +copy-mode=C-k=0==send-keys\ -X\ copy-pipe-end-of-line-and-cancel= +copy-mode=C-l=0==send-keys\ -X\ recentre-top-bottom= +copy-mode=C-n=0==send-keys\ -X\ cursor-down= +copy-mode=C-p=0==send-keys\ -X\ cursor-up= +copy-mode=C-r=0==command-prompt\ -i\ -I\ \"\#{pane_search_string}\"\ -T\ search\ -p\ \"\(search\ up\)\"\ {\ send-keys\ -X\ search-backward-incremental\ --\ \"\%\%\"\ }= +copy-mode=C-s=0==command-prompt\ -i\ -I\ \"\#{pane_search_string}\"\ -T\ search\ -p\ \"\(search\ down\)\"\ {\ send-keys\ -X\ search-forward-incremental\ --\ \"\%\%\"\ }= +copy-mode=C-v=0==send-keys\ -X\ page-down= +copy-mode=C-w=0==send-keys\ -X\ copy-pipe-and-cancel= +copy-mode=C-Up=0==send-keys\ -X\ scroll-up= +copy-mode=C-Down=0==send-keys\ -X\ scroll-down= +copy-mode=C-M-b=0==send-keys\ -X\ previous-matching-bracket= +copy-mode=C-M-f=0==send-keys\ -X\ next-matching-bracket= +copy-mode-vi=Enter=0==send-keys\ -X\ copy-pipe-and-cancel= +copy-mode-vi=Escape=0==send-keys\ -X\ clear-selection= +copy-mode-vi=Space=0==send-keys\ -X\ begin-selection= +copy-mode-vi=\#=0==send-keys\ -FX\ search-backward\ --\ \"\#{copy_cursor_word}\"= +copy-mode-vi=\$=0==send-keys\ -X\ end-of-line= +copy-mode-vi=\%=0==send-keys\ -X\ next-matching-bracket= +copy-mode-vi=\*=0==send-keys\ -FX\ search-forward\ --\ \"\#{copy_cursor_word}\"= +copy-mode-vi=,=0==send-keys\ -X\ jump-reverse= +copy-mode-vi=/=0==command-prompt\ -T\ search\ -p\ \"\(search\ down\)\"\ {\ send-keys\ -X\ search-forward\ --\ \"\%\%\"\ }= +copy-mode-vi=0=0==send-keys\ -X\ start-of-line= +copy-mode-vi=1=0==command-prompt\ -N\ -I\ 1\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=2=0==command-prompt\ -N\ -I\ 2\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=3=0==command-prompt\ -N\ -I\ 3\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=4=0==command-prompt\ -N\ -I\ 4\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=5=0==command-prompt\ -N\ -I\ 5\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=6=0==command-prompt\ -N\ -I\ 6\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=7=0==command-prompt\ -N\ -I\ 7\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=8=0==command-prompt\ -N\ -I\ 8\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=9=0==command-prompt\ -N\ -I\ 9\ -p\ \(repeat\)\ {\ send-keys\ -N\ \"\%\%\"\ }= +copy-mode-vi=:=0==command-prompt\ -p\ \"\(goto\ line\)\"\ {\ send-keys\ -X\ goto-line\ --\ \"\%\%\"\ }= +copy-mode-vi=\;=0==send-keys\ -X\ jump-again= +copy-mode-vi=\?=0==command-prompt\ -T\ search\ -p\ \"\(search\ up\)\"\ {\ send-keys\ -X\ search-backward\ --\ \"\%\%\"\ }= +copy-mode-vi=A=0==send-keys\ -X\ append-selection-and-cancel= +copy-mode-vi=B=0==send-keys\ -X\ previous-space= +copy-mode-vi=D=0==send-keys\ -X\ copy-pipe-end-of-line-and-cancel= +copy-mode-vi=E=0==send-keys\ -X\ next-space-end= +copy-mode-vi=F=0==command-prompt\ -1\ -p\ \"\(jump\ backward\)\"\ {\ send-keys\ -X\ jump-backward\ --\ \"\%\%\"\ }= +copy-mode-vi=G=0==send-keys\ -X\ history-bottom= +copy-mode-vi=H=0==send-keys\ -X\ top-line= +copy-mode-vi=J=0==send-keys\ -X\ scroll-down= +copy-mode-vi=K=0==send-keys\ -X\ scroll-up= +copy-mode-vi=L=0==send-keys\ -X\ bottom-line= +copy-mode-vi=M=0==send-keys\ -X\ middle-line= +copy-mode-vi=N=0==send-keys\ -X\ search-reverse= +copy-mode-vi=P=0==send-keys\ -X\ toggle-position= +copy-mode-vi=T=0==command-prompt\ -1\ -p\ \"\(jump\ to\ backward\)\"\ {\ send-keys\ -X\ jump-to-backward\ --\ \"\%\%\"\ }= +copy-mode-vi=V=0==send-keys\ -X\ select-line= +copy-mode-vi=W=0==send-keys\ -X\ next-space= +copy-mode-vi=X=0==send-keys\ -X\ set-mark= +copy-mode-vi=^=0==send-keys\ -X\ back-to-indentation= +copy-mode-vi=b=0==send-keys\ -X\ previous-word= +copy-mode-vi=e=0==send-keys\ -X\ next-word-end= +copy-mode-vi=f=0==command-prompt\ -1\ -p\ \"\(jump\ forward\)\"\ {\ send-keys\ -X\ jump-forward\ --\ \"\%\%\"\ }= +copy-mode-vi=g=0==send-keys\ -X\ history-top= +copy-mode-vi=h=0==send-keys\ -X\ cursor-left= +copy-mode-vi=j=0==send-keys\ -X\ cursor-down= +copy-mode-vi=k=0==send-keys\ -X\ cursor-up= +copy-mode-vi=l=0==send-keys\ -X\ cursor-right= +copy-mode-vi=n=0==send-keys\ -X\ search-again= +copy-mode-vi=o=0==send-keys\ -X\ other-end= +copy-mode-vi=q=0==send-keys\ -X\ cancel= +copy-mode-vi=r=0==send-keys\ -X\ refresh-from-pane= +copy-mode-vi=t=0==command-prompt\ -1\ -p\ \"\(jump\ to\ forward\)\"\ {\ send-keys\ -X\ jump-to-forward\ --\ \"\%\%\"\ }= +copy-mode-vi=v=0==send-keys\ -X\ rectangle-toggle= +copy-mode-vi=w=0==send-keys\ -X\ next-word= +copy-mode-vi=z=0==send-keys\ -X\ scroll-middle= +copy-mode-vi={=0==send-keys\ -X\ previous-paragraph= +copy-mode-vi=}=0==send-keys\ -X\ next-paragraph= +copy-mode-vi=BSpace=0==send-keys\ -X\ cursor-left= +copy-mode-vi=Home=0==send-keys\ -X\ start-of-line= +copy-mode-vi=End=0==send-keys\ -X\ end-of-line= +copy-mode-vi=NPage=0==send-keys\ -X\ page-down= +copy-mode-vi=PPage=0==send-keys\ -X\ page-up= +copy-mode-vi=Up=0==send-keys\ -X\ cursor-up= +copy-mode-vi=Down=0==send-keys\ -X\ cursor-down= +copy-mode-vi=Left=0==send-keys\ -X\ cursor-left= +copy-mode-vi=Right=0==send-keys\ -X\ cursor-right= +copy-mode-vi=MouseDown1Pane=0==select-pane= +copy-mode-vi=MouseDrag1Pane=0==select-pane\ \\\;\ send-keys\ -X\ begin-selection= +copy-mode-vi=MouseDragEnd1Pane=0==send-keys\ -X\ copy-pipe-and-cancel= +copy-mode-vi=WheelDownPane=0==select-pane\ \\\;\ send-keys\ -X\ -N\ 5\ scroll-down= +copy-mode-vi=WheelUpPane=0==select-pane\ \\\;\ send-keys\ -X\ -N\ 5\ scroll-up= +copy-mode-vi=DoubleClick1Pane=0==select-pane\ \\\;\ send-keys\ -X\ select-word\ \\\;\ run-shell\ -d\ 0.3\ \\\;\ send-keys\ -X\ copy-pipe-and-cancel= +copy-mode-vi=TripleClick1Pane=0==select-pane\ \\\;\ send-keys\ -X\ select-line\ \\\;\ run-shell\ -d\ 0.3\ \\\;\ send-keys\ -X\ copy-pipe-and-cancel= +copy-mode-vi=M-x=0==send-keys\ -X\ jump-to-mark= +copy-mode-vi=C-\[=0==send-keys\ -X\ clear-selection= +copy-mode-vi=C-b=0==send-keys\ -X\ page-up= +copy-mode-vi=C-c=0==send-keys\ -X\ cancel= +copy-mode-vi=C-d=0==send-keys\ -X\ halfpage-down= +copy-mode-vi=C-e=0==send-keys\ -X\ scroll-down= +copy-mode-vi=C-f=0==send-keys\ -X\ page-down= +copy-mode-vi=C-h=0==send-keys\ -X\ cursor-left= +copy-mode-vi=C-j=0==send-keys\ -X\ copy-pipe-and-cancel= +copy-mode-vi=C-u=0==send-keys\ -X\ halfpage-up= +copy-mode-vi=C-v=0==send-keys\ -X\ rectangle-toggle= +copy-mode-vi=C-y=0==send-keys\ -X\ scroll-up= +copy-mode-vi=C-Up=0==send-keys\ -X\ scroll-up= +copy-mode-vi=C-Down=0==send-keys\ -X\ scroll-down= +prefix=Space=0=Select\ next\ layout=next-layout= +prefix=!=0=Break\ pane\ to\ a\ new\ window=break-pane= +prefix=\"=0=Split\ window\ vertically=split-window= +prefix=\#=0=List\ all\ paste\ buffers=list-buffers= +prefix=\$=0=Rename\ current\ session=command-prompt\ -I\ \"\#S\"\ {\ rename-session\ \"\%\%\"\ }= +prefix=\%=0=Split\ window\ horizontally=split-window\ -h= +prefix=\&=0=Kill\ current\ window=confirm-before\ -p\ \"kill-window\ \#W\?\ \(y/n\)\"\ kill-window= +prefix=\'=0=Prompt\ for\ window\ index\ to\ select=command-prompt\ -T\ window-target\ -p\ index\ {\ select-window\ -t\ \":\%\%\"\ }= +prefix=\(=0=Switch\ to\ previous\ client=switch-client\ -p= +prefix=\)=0=Switch\ to\ next\ client=switch-client\ -n= +prefix=\*=0=New\ floating\ pane=new-pane= +prefix=,=0=Rename\ current\ window=command-prompt\ -I\ \"\#W\"\ {\ rename-window\ \"\%\%\"\ }= +prefix=-=0=Delete\ the\ most\ recent\ paste\ buffer=delete-buffer= +prefix=.=0=Move\ the\ current\ window=command-prompt\ -T\ target\ {\ move-window\ -t\ \"\%\%\"\ }= +prefix=/=0=Describe\ key\ binding=command-prompt\ -k\ -p\ key\ {\ list-keys\ -1N\ \"\%\%\"\ }= +prefix=0=0=Select\ window\ 0=select-window\ -t\ :\=0= +prefix=1=0=Select\ window\ 1=select-window\ -t\ :\=1= +prefix=2=0=Select\ window\ 2=select-window\ -t\ :\=2= +prefix=3=0=Select\ window\ 3=select-window\ -t\ :\=3= +prefix=4=0=Select\ window\ 4=select-window\ -t\ :\=4= +prefix=5=0=Select\ window\ 5=select-window\ -t\ :\=5= +prefix=6=0=Select\ window\ 6=select-window\ -t\ :\=6= +prefix=7=0=Select\ window\ 7=select-window\ -t\ :\=7= +prefix=8=0=Select\ window\ 8=select-window\ -t\ :\=8= +prefix=9=0=Select\ window\ 9=select-window\ -t\ :\=9= +prefix=:=0=Prompt\ for\ a\ command=command-prompt= +prefix=\;=0=Move\ to\ the\ previously\ active\ pane=last-pane= +prefix=\<=0=Display\ window\ menu=display-menu\ -T\ \"\#\[align\=centre]\#{window_index}:\#{window_name}\"\ -x\ W\ -y\ W\ \"\#{\?\#{\>:\#{session_windows},1},,-}Swap\ Left\"\ l\ {\ swap-window\ -t\ :-1\ }\ \"\#{\?\#{\>:\#{session_windows},1},,-}Swap\ Right\"\ r\ {\ swap-window\ -t\ :+1\ }\ \"\#{\?pane_marked_set,,-}Swap\ Marked\"\ s\ {\ swap-window\ }\ \'\'\ Kill\ X\ {\ kill-window\ }\ Respawn\ R\ {\ respawn-window\ -k\ }\ \"\#{\?pane_marked,Unmark,Mark}\"\ m\ {\ select-pane\ -m\ }\ Rename\ n\ {\ command-prompt\ -F\ -I\ \"\#W\"\ {\ rename-window\ -t\ \"\#{window_id}\"\ \"\%\%\"\ }\ }\ \'\'\ \"New\ After\"\ w\ {\ new-window\ -a\ }\ \"New\ At\ End\"\ W\ {\ new-window\ }= +prefix=\==0=Choose\ a\ paste\ buffer\ from\ a\ list=choose-buffer\ -Z= +prefix=\>=0=Display\ pane\ menu=display-menu\ -T\ \"\#\[align\=centre]\#{pane_index}\ \(\#{pane_id}\)\"\ -x\ P\ -y\ P\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},Go\ To\ Top,}\"\ \<\ {\ send-keys\ -X\ history-top\ }\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},Go\ To\ Bottom,}\"\ \>\ {\ send-keys\ -X\ history-bottom\ }\ \'\'\ \"\#{\?\#{\&\&:\#{buffer_size},\#{!:\#{pane_in_mode}}},Paste\ \#\[underscore]\#{\=/9/...:buffer_sample},}\"\ p\ {\ paste-buffer\ }\ \'\'\ \"\#{\?mouse_word,Search\ For\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ C-r\ {\ if-shell\ -F\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},0,1}\"\ \"copy-mode\ -t\=\"\ \;\ send-keys\ -X\ -t\ \=\ search-backward\ --\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_word,Type\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ C-y\ {\ copy-mode\ -q\ \;\ send-keys\ -l\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_word,Copy\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ c\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_line,Copy\ Line,}\"\ l\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_line}\"\ }\ \'\'\ \"\#{\?mouse_hyperlink,Type\ \#\[underscore]\#{\=/9/...:mouse_hyperlink},}\"\ C-h\ {\ copy-mode\ -q\ \;\ send-keys\ -l\ \"\#{q:mouse_hyperlink}\"\ }\ \"\#{\?mouse_hyperlink,Copy\ \#\[underscore]\#{\=/9/...:mouse_hyperlink},}\"\ h\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_hyperlink}\"\ }\ \'\'\ \"\#{\?\#{!:\#{pane_floating_flag}},Horizontal\ Split,}\"\ h\ {\ split-window\ -h\ }\ \"\#{\?\#{!:\#{pane_floating_flag}},Vertical\ Split,}\"\ v\ {\ split-window\ -v\ }\ \'\'\ \"\#{\?\#{\&\&:\#{!:\#{pane_floating_flag}},\#{\>:\#{window_panes},1}},Swap\ Up,}\"\ u\ {\ swap-pane\ -U\ }\ \"\#{\?\#{\&\&:\#{!:\#{pane_floating_flag}},\#{\>:\#{window_panes},1}},Swap\ Down,}\"\ d\ {\ swap-pane\ -D\ }\ \"\#{\?pane_marked_set,,-}Swap\ Marked\"\ s\ {\ swap-pane\ }\ \'\'\ Kill\ X\ {\ kill-pane\ }\ Respawn\ R\ {\ respawn-pane\ -k\ }\ \"\#{\?pane_marked,Unmark,Mark}\"\ m\ {\ select-pane\ -m\ }\ \"\#{\?\#{\>:\#{window_panes},1},,-}\#{\?window_zoomed_flag,Unzoom,Zoom}\"\ z\ {\ resize-pane\ -Z\ }= +prefix=\?=0=List\ key\ bindings=list-keys\ -N= +prefix=C=0=Customize\ options=customize-mode\ -Z= +prefix=D=0=Choose\ and\ detach\ a\ client\ from\ a\ list=choose-client\ -Z= +prefix=E=0=Spread\ panes\ out\ evenly=select-layout\ -E= +prefix=L=0=Switch\ to\ the\ last\ client=switch-client\ -l= +prefix=M=0=Clear\ the\ marked\ pane=select-pane\ -M= +prefix=\[=0=Enter\ copy\ mode=copy-mode= +prefix=]=0=Paste\ the\ most\ recent\ paste\ buffer=paste-buffer\ -p= +prefix=c=0=Create\ a\ new\ window=new-window= +prefix=d=0=Detach\ the\ current\ client=detach-client= +prefix=f=0=Search\ for\ a\ pane=command-prompt\ {\ find-window\ -Z\ \"\%\%\"\ }= +prefix=i=0=Display\ window\ information=display-message= +prefix=l=0=Select\ the\ previously\ current\ window=last-window= +prefix=m=0=Toggle\ the\ marked\ pane=select-pane\ -m= +prefix=n=0=Select\ the\ next\ window=next-window= +prefix=o=0=Select\ the\ next\ pane=select-pane\ -t\ :.+= +prefix=p=0=Select\ the\ previous\ window=previous-window= +prefix=q=0=Display\ pane\ numbers=display-panes= +prefix=r=0=Redraw\ the\ current\ client=refresh-client= +prefix=s=0=Choose\ a\ session\ from\ a\ list=choose-tree\ -Zs= +prefix=t=0=Show\ a\ clock=clock-mode= +prefix=w=0=Choose\ a\ window\ from\ a\ list=choose-tree\ -Zw= +prefix=x=0=Kill\ the\ active\ pane=confirm-before\ -p\ \"kill-pane\ \#P\?\ \(y/n\)\"\ kill-pane= +prefix=z=0=Zoom\ the\ active\ pane=resize-pane\ -Z= +prefix={=0=Swap\ the\ active\ pane\ with\ the\ pane\ above=swap-pane\ -U= +prefix=}=0=Swap\ the\ active\ pane\ with\ the\ pane\ below=swap-pane\ -D= +prefix=~=0=Show\ messages=show-messages= +prefix=DC=1=Reset\ so\ the\ visible\ part\ of\ the\ window\ follows\ the\ cursor=refresh-client\ -c= +prefix=PPage=0=Enter\ copy\ mode\ and\ scroll\ up=copy-mode\ -u= +prefix=Up=1=Select\ the\ pane\ above\ the\ active\ pane=select-pane\ -U= +prefix=Down=1=Select\ the\ pane\ below\ the\ active\ pane=select-pane\ -D= +prefix=Left=1=Select\ the\ pane\ to\ the\ left\ of\ the\ active\ pane=select-pane\ -L= +prefix=Right=1=Select\ the\ pane\ to\ the\ right\ of\ the\ active\ pane=select-pane\ -R= +prefix=M-1=0=Set\ the\ even-horizontal\ layout=select-layout\ even-horizontal= +prefix=M-2=0=Set\ the\ even-vertical\ layout=select-layout\ even-vertical= +prefix=M-3=0=Set\ the\ main-horizontal\ layout=select-layout\ main-horizontal= +prefix=M-4=0=Set\ the\ main-vertical\ layout=select-layout\ main-vertical= +prefix=M-5=0=Select\ the\ tiled\ layout=select-layout\ tiled= +prefix=M-6=0=Set\ the\ main-horizontal-mirrored\ layout=select-layout\ main-horizontal-mirrored= +prefix=M-7=0=Set\ the\ main-vertical-mirrored\ layout=select-layout\ main-vertical-mirrored= +prefix=M-n=0=Select\ the\ next\ window\ with\ an\ alert=next-window\ -a= +prefix=M-o=0=Rotate\ through\ the\ panes\ in\ reverse=rotate-window\ -D= +prefix=M-p=0=Select\ the\ previous\ window\ with\ an\ alert=previous-window\ -a= +prefix=M-Up=1=Resize\ the\ pane\ up\ by\ 5=resize-pane\ -U\ 5= +prefix=M-Down=1=Resize\ the\ pane\ down\ by\ 5=resize-pane\ -D\ 5= +prefix=M-Left=1=Resize\ the\ pane\ left\ by\ 5=resize-pane\ -L\ 5= +prefix=M-Right=1=Resize\ the\ pane\ right\ by\ 5=resize-pane\ -R\ 5= +prefix=C-b=0=Send\ the\ prefix\ key=send-prefix= +prefix=C-o=0=Rotate\ through\ the\ panes=rotate-window= +prefix=C-z=0=Suspend\ the\ current\ client=suspend-client= +prefix=C-Up=1=Resize\ the\ pane\ up=resize-pane\ -U= +prefix=C-Down=1=Resize\ the\ pane\ down=resize-pane\ -D= +prefix=C-Left=1=Resize\ the\ pane\ left=resize-pane\ -L= +prefix=C-Right=1=Resize\ the\ pane\ right=resize-pane\ -R= +prefix=S-Up=1=Move\ the\ visible\ part\ of\ the\ window\ up=refresh-client\ -U\ 10= +prefix=S-Down=1=Move\ the\ visible\ part\ of\ the\ window\ down=refresh-client\ -D\ 10= +prefix=S-Left=1=Move\ the\ visible\ part\ of\ the\ window\ left=refresh-client\ -L\ 10= +prefix=S-Right=1=Move\ the\ visible\ part\ of\ the\ window\ right=refresh-client\ -R\ 10= +root=MouseDown1Pane=0==select-pane\ -t\ \=\ \\\;\ send-keys\ -M= +root=MouseDown1Status=0==switch-client\ -t\ \== +root=MouseDown1Border=0==select-pane\ -M= +root=MouseDown1ScrollbarUp=0==if-shell\ -F\ -t\ \=\ \"\#{pane_in_mode}\"\ {\ send-keys\ -X\ page-up\ }\ {\ copy-mode\ -u\ }= +root=MouseDown1ScrollbarDown=0==if-shell\ -F\ -t\ \=\ \"\#{pane_in_mode}\"\ {\ send-keys\ -X\ page-down\ }\ {\ copy-mode\ -d\ }= +root=MouseDown1Control8=0==resize-pane\ -Z= +root=MouseDown1Control9=0==display-menu\ -O\ -T\ \"Kill\ pane\ \#{pane_index}\?\"\ -t\ \=\ -x\ M\ -y\ M\ Yes\ y\ {\ kill-pane\ -t\ \=\ }\ No\ n\ {\ \ }= +root=MouseDown2Pane=0==select-pane\ -t\ \=\ \\\;\ if-shell\ -F\ \"\#{\|\|:\#{pane_in_mode},\#{mouse_any_flag}}\"\ {\ send-keys\ -M\ }\ {\ paste-buffer\ -p\ }= +root=MouseDown3Pane=0==if-shell\ -F\ -t\ \=\ \"\#{\|\|:\#{mouse_any_flag},\#{\&\&:\#{pane_in_mode},\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},0,1}}}\"\ {\ select-pane\ -t\ \=\ \;\ send-keys\ -M\ }\ {\ display-menu\ -T\ \"\#\[align\=centre]\#{pane_index}\ \(\#{pane_id}\)\"\ -t\ \=\ -x\ M\ -y\ M\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},Go\ To\ Top,}\"\ \<\ {\ send-keys\ -X\ history-top\ }\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},Go\ To\ Bottom,}\"\ \>\ {\ send-keys\ -X\ history-bottom\ }\ \'\'\ \"\#{\?\#{\&\&:\#{buffer_size},\#{!:\#{pane_in_mode}}},Paste\ \#\[underscore]\#{\=/9/...:buffer_sample},}\"\ p\ {\ paste-buffer\ }\ \'\'\ \"\#{\?mouse_word,Search\ For\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ C-r\ {\ if-shell\ -F\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},0,1}\"\ \"copy-mode\ -t\=\"\ \;\ send-keys\ -X\ -t\ \=\ search-backward\ --\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_word,Type\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ C-y\ {\ copy-mode\ -q\ \;\ send-keys\ -l\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_word,Copy\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ c\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_line,Copy\ Line,}\"\ l\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_line}\"\ }\ \'\'\ \"\#{\?mouse_hyperlink,Type\ \#\[underscore]\#{\=/9/...:mouse_hyperlink},}\"\ C-h\ {\ copy-mode\ -q\ \;\ send-keys\ -l\ \"\#{q:mouse_hyperlink}\"\ }\ \"\#{\?mouse_hyperlink,Copy\ \#\[underscore]\#{\=/9/...:mouse_hyperlink},}\"\ h\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_hyperlink}\"\ }\ \'\'\ \"\#{\?\#{!:\#{pane_floating_flag}},Horizontal\ Split,}\"\ h\ {\ split-window\ -h\ }\ \"\#{\?\#{!:\#{pane_floating_flag}},Vertical\ Split,}\"\ v\ {\ split-window\ -v\ }\ \'\'\ \"\#{\?\#{\&\&:\#{!:\#{pane_floating_flag}},\#{\>:\#{window_panes},1}},Swap\ Up,}\"\ u\ {\ swap-pane\ -U\ }\ \"\#{\?\#{\&\&:\#{!:\#{pane_floating_flag}},\#{\>:\#{window_panes},1}},Swap\ Down,}\"\ d\ {\ swap-pane\ -D\ }\ \"\#{\?pane_marked_set,,-}Swap\ Marked\"\ s\ {\ swap-pane\ }\ \'\'\ Kill\ X\ {\ kill-pane\ }\ Respawn\ R\ {\ respawn-pane\ -k\ }\ \"\#{\?pane_marked,Unmark,Mark}\"\ m\ {\ select-pane\ -m\ }\ \"\#{\?\#{\>:\#{window_panes},1},,-}\#{\?window_zoomed_flag,Unzoom,Zoom}\"\ z\ {\ resize-pane\ -Z\ }\ }= +root=MouseDown3Status=0==display-menu\ -T\ \"\#\[align\=centre]\#{window_index}:\#{window_name}\"\ -t\ \=\ -x\ W\ -y\ W\ \"\#{\?\#{\>:\#{session_windows},1},,-}Swap\ Left\"\ l\ {\ swap-window\ -t\ :-1\ }\ \"\#{\?\#{\>:\#{session_windows},1},,-}Swap\ Right\"\ r\ {\ swap-window\ -t\ :+1\ }\ \"\#{\?pane_marked_set,,-}Swap\ Marked\"\ s\ {\ swap-window\ }\ \'\'\ Kill\ X\ {\ kill-window\ }\ Respawn\ R\ {\ respawn-window\ -k\ }\ \"\#{\?pane_marked,Unmark,Mark}\"\ m\ {\ select-pane\ -m\ }\ Rename\ n\ {\ command-prompt\ -F\ -I\ \"\#W\"\ {\ rename-window\ -t\ \"\#{window_id}\"\ \"\%\%\"\ }\ }\ \'\'\ \"New\ After\"\ w\ {\ new-window\ -a\ }\ \"New\ At\ End\"\ W\ {\ new-window\ }= +root=MouseDown3StatusLeft=0==display-menu\ -T\ \"\#\[align\=centre]\#{session_name}\"\ -t\ \=\ -x\ M\ -y\ W\ Next\ n\ {\ switch-client\ -n\ }\ Previous\ p\ {\ switch-client\ -p\ }\ \'\'\ Renumber\ N\ {\ move-window\ -r\ }\ Rename\ r\ {\ command-prompt\ -I\ \"\#S\"\ {\ rename-session\ \"\%\%\"\ }\ }\ Detach\ d\ {\ detach-client\ }\ \'\'\ \"New\ Session\"\ s\ {\ new-session\ }\ \"New\ Window\"\ w\ {\ new-window\ }= +root=MouseDrag1Pane=0==if-shell\ -F\ \"\#{\|\|:\#{pane_in_mode},\#{mouse_any_flag}}\"\ {\ send-keys\ -M\ }\ {\ copy-mode\ -M\ }= +root=MouseDrag1Border=0==resize-pane\ -M= +root=MouseDrag1ScrollbarSlider=0==if-shell\ -F\ -t\ \=\ \"\#{pane_in_mode}\"\ {\ send-keys\ -X\ scroll-to-mouse\ }\ {\ copy-mode\ -S\ }= +root=WheelDownStatus=0==next-window= +root=WheelUpPane=0==if-shell\ -F\ \"\#{\|\|:\#{alternate_on},\#{pane_in_mode},\#{mouse_any_flag}}\"\ {\ send-keys\ -M\ }\ {\ copy-mode\ -e\ }= +root=WheelUpStatus=0==previous-window= +root=DoubleClick1Pane=0==select-pane\ -t\ \=\ \\\;\ if-shell\ -F\ \"\#{\|\|:\#{pane_in_mode},\#{mouse_any_flag}}\"\ {\ send-keys\ -M\ }\ {\ copy-mode\ -H\ \;\ send-keys\ -X\ select-word\ \;\ run-shell\ -d\ 0.3\ \;\ send-keys\ -X\ copy-pipe-and-cancel\ }= +root=TripleClick1Pane=0==select-pane\ -t\ \=\ \\\;\ if-shell\ -F\ \"\#{\|\|:\#{pane_in_mode},\#{mouse_any_flag}}\"\ {\ send-keys\ -M\ }\ {\ copy-mode\ -H\ \;\ send-keys\ -X\ select-line\ \;\ run-shell\ -d\ 0.3\ \;\ send-keys\ -X\ copy-pipe-and-cancel\ }= +root=M-MouseDown3Pane=0==display-menu\ -T\ \"\#\[align\=centre]\#{pane_index}\ \(\#{pane_id}\)\"\ -t\ \=\ -x\ M\ -y\ M\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},Go\ To\ Top,}\"\ \<\ {\ send-keys\ -X\ history-top\ }\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},Go\ To\ Bottom,}\"\ \>\ {\ send-keys\ -X\ history-bottom\ }\ \'\'\ \"\#{\?\#{\&\&:\#{buffer_size},\#{!:\#{pane_in_mode}}},Paste\ \#\[underscore]\#{\=/9/...:buffer_sample},}\"\ p\ {\ paste-buffer\ }\ \'\'\ \"\#{\?mouse_word,Search\ For\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ C-r\ {\ if-shell\ -F\ \"\#{\?\#{m/r:\(copy\|view\)-mode,\#{pane_mode}},0,1}\"\ \"copy-mode\ -t\=\"\ \;\ send-keys\ -X\ -t\ \=\ search-backward\ --\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_word,Type\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ C-y\ {\ copy-mode\ -q\ \;\ send-keys\ -l\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_word,Copy\ \#\[underscore]\#{\=/9/...:mouse_word},}\"\ c\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_word}\"\ }\ \"\#{\?mouse_line,Copy\ Line,}\"\ l\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_line}\"\ }\ \'\'\ \"\#{\?mouse_hyperlink,Type\ \#\[underscore]\#{\=/9/...:mouse_hyperlink},}\"\ C-h\ {\ copy-mode\ -q\ \;\ send-keys\ -l\ \"\#{q:mouse_hyperlink}\"\ }\ \"\#{\?mouse_hyperlink,Copy\ \#\[underscore]\#{\=/9/...:mouse_hyperlink},}\"\ h\ {\ copy-mode\ -q\ \;\ set-buffer\ \"\#{q:mouse_hyperlink}\"\ }\ \'\'\ \"\#{\?\#{!:\#{pane_floating_flag}},Horizontal\ Split,}\"\ h\ {\ split-window\ -h\ }\ \"\#{\?\#{!:\#{pane_floating_flag}},Vertical\ Split,}\"\ v\ {\ split-window\ -v\ }\ \'\'\ \"\#{\?\#{\&\&:\#{!:\#{pane_floating_flag}},\#{\>:\#{window_panes},1}},Swap\ Up,}\"\ u\ {\ swap-pane\ -U\ }\ \"\#{\?\#{\&\&:\#{!:\#{pane_floating_flag}},\#{\>:\#{window_panes},1}},Swap\ Down,}\"\ d\ {\ swap-pane\ -D\ }\ \"\#{\?pane_marked_set,,-}Swap\ Marked\"\ s\ {\ swap-pane\ }\ \'\'\ Kill\ X\ {\ kill-pane\ }\ Respawn\ R\ {\ respawn-pane\ -k\ }\ \"\#{\?pane_marked,Unmark,Mark}\"\ m\ {\ select-pane\ -m\ }\ \"\#{\?\#{\>:\#{window_panes},1},,-}\#{\?window_zoomed_flag,Unzoom,Zoom}\"\ z\ {\ resize-pane\ -Z\ }= +root=M-MouseDown3Status=0==display-menu\ -T\ \"\#\[align\=centre]\#{window_index}:\#{window_name}\"\ -t\ \=\ -x\ W\ -y\ W\ \"\#{\?\#{\>:\#{session_windows},1},,-}Swap\ Left\"\ l\ {\ swap-window\ -t\ :-1\ }\ \"\#{\?\#{\>:\#{session_windows},1},,-}Swap\ Right\"\ r\ {\ swap-window\ -t\ :+1\ }\ \"\#{\?pane_marked_set,,-}Swap\ Marked\"\ s\ {\ swap-window\ }\ \'\'\ Kill\ X\ {\ kill-window\ }\ Respawn\ R\ {\ respawn-window\ -k\ }\ \"\#{\?pane_marked,Unmark,Mark}\"\ m\ {\ select-pane\ -m\ }\ Rename\ n\ {\ command-prompt\ -F\ -I\ \"\#W\"\ {\ rename-window\ -t\ \"\#{window_id}\"\ \"\%\%\"\ }\ }\ \'\'\ \"New\ After\"\ w\ {\ new-window\ -a\ }\ \"New\ At\ End\"\ W\ {\ new-window\ }= +root=M-MouseDown3StatusLeft=0==display-menu\ -T\ \"\#\[align\=centre]\#{session_name}\"\ -t\ \=\ -x\ M\ -y\ W\ Next\ n\ {\ switch-client\ -n\ }\ Previous\ p\ {\ switch-client\ -p\ }\ \'\'\ Renumber\ N\ {\ move-window\ -r\ }\ Rename\ r\ {\ command-prompt\ -I\ \"\#S\"\ {\ rename-session\ \"\%\%\"\ }\ }\ Detach\ d\ {\ detach-client\ }\ \'\'\ \"New\ Session\"\ s\ {\ new-session\ }\ \"New\ Window\"\ w\ {\ new-window\ }= +root=C-MouseDown1Pane=0==swap-pane\ -s\ @= +root=C-MouseDown1Status=0==swap-window\ -t\ @= diff --git a/fuzz/seeds/list_keys/escapes-and-newline b/fuzz/seeds/list_keys/escapes-and-newline new file mode 100644 index 00000000..65081adb --- /dev/null +++ b/fuzz/seeds/list_keys/escapes-and-newline @@ -0,0 +1,3 @@ +solo=M-\'=0==display-message\ a\=b\ \\\;\ display-message\ c= +sp\ ace=\"=1=a +note=display-message\ \"two\ words\"= From 7a1d0dd6925de5464a331f595133eec2efdd844a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 03:48:06 -0500 Subject: [PATCH 085/117] Test(fix[env]): Stop handing the fixture's tmux the caller's environment tmux gives its own environment to every session and every pane, and `show-environment` reads it back. `TestServer` removed `TMUX` and set `TERM` and inherited the rest, so a fixture that isolates the socket and the config left the environment wide open: any test that listed it printed whatever the developer exported. Twice this round that was a live API key in a transcript, once from an assessment and once from a deliberate test break. The fixture now passes `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, the locale variables and `TMUX_TMPDIR`, sets `TERM`, and drops everything else. A test that needs another variable sets it on the pane, where it belongs. Downstream crates use this guard too, so this is theirs as well. The test asserts on presence, never on a value, and plants nothing: `CARGO_MANIFEST_DIR` is set in every `cargo test` process and is not on the allowlist, so it is the control. Shown capable of failing by restoring the inheritance -- "the fixture's tmux inherited this process's environment". Every pane-driving suite still passes: mutations, hierarchy, control, commands, test_server, tmux-mcp's agent tests and the doctests. --- crates/libtmux/README.md | 5 +++- crates/libtmux/src/test.rs | 32 ++++++++++++++++++++++--- crates/libtmux/tests/test_server.rs | 36 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 097c1410..ee2b21c0 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -618,7 +618,10 @@ libtmux = { version = "0.1.0-alpha.11", features = ["test-support"] } ``` Each guard owns a tmux child on a private socket with an empty config, so tests -cannot reach your real server or each other. `shutdown().await` closes escaped +cannot reach your real server or each other, and it is given only the +environment tmux and a pane's shell need -- `PATH`, `HOME`, `USER`, `LOGNAME`, +`SHELL`, the locale variables and `TMUX_TMPDIR` -- so a test that reads the +environment cannot read yours. `shutdown().await` closes escaped clients, waits the daemon, and reports cleanup failures; `Drop` forces best-effort cleanup even after the runtime has ended. On Linux, cleanup also sweeps processes by an exact environment marker through pidfds, so PID reuse diff --git a/crates/libtmux/src/test.rs b/crates/libtmux/src/test.rs index 3471d249..95591a40 100644 --- a/crates/libtmux/src/test.rs +++ b/crates/libtmux/src/test.rs @@ -323,6 +323,22 @@ pub struct TestServerBuilder { control_client_limits: ControlClientLimits, } +/// What the fixture's tmux keeps of the test process's environment. +/// +/// `TMUX` and `TMUX_PANE` are deliberately absent: a fixture started from +/// inside tmux must not read as nested. `TERM` is set rather than inherited. +const INHERITED_ENVIRONMENT: &[&str] = &[ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TMUX_TMPDIR", +]; + /// The tmux to run, unless a caller names one. /// /// `LIBTMUX_TEST_TMUX` first, then `tmux` resolved through `PATH`. The @@ -483,10 +499,20 @@ impl TestServerBuilder { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .env_remove("TMUX") - .env_remove("TMUX_PANE") - .env("TERM", TERM) .process_group(0); + // tmux hands its own environment to every session and every pane, and + // `show-environment` reads it back, so a fixture that isolates the + // socket and the config and not the environment is isolated only + // halfway: a test that lists the environment prints whatever the + // developer exported, secrets included. Only what tmux and a pane's + // shell need is passed through. + command.env_clear(); + for name in INHERITED_ENVIRONMENT { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + command.env("TERM", TERM); files.containment.configure(&mut command); let child = match command.spawn() { Ok(child) => child, diff --git a/crates/libtmux/tests/test_server.rs b/crates/libtmux/tests/test_server.rs index 196908a4..02133db9 100644 --- a/crates/libtmux/tests/test_server.rs +++ b/crates/libtmux/tests/test_server.rs @@ -2181,3 +2181,39 @@ async fn a_replacement_daemon_on_the_same_socket_is_a_different_generation() { ); assert!(error.is_object_gone(), "{error}"); } + +/// The fixture's tmux must not inherit the test process's environment. +/// +/// tmux hands its own environment to every session and pane, and +/// `show-environment` reads it back, so whatever a developer exported -- +/// tokens included -- was one tool call away from a test's output. This +/// asserts on presence only, and never formats an environment value into a +/// message. +#[tokio::test] +async fn the_fixture_does_not_hand_tmux_the_test_environment() { + // `cargo test` sets this in the test process, so it is a variable known to + // be inheritable, without planting one (`set_var` is unsafe, and this + // crate forbids unsafe code). + assert!( + std::env::var_os("CARGO_MANIFEST_DIR").is_some(), + "the control variable is set in this process", + ); + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let environment = guard + .server() + .environment_all() + .await + .expect("the server environment reads"); + + assert!( + !environment.contains_key("CARGO_MANIFEST_DIR"), + "the fixture's tmux inherited this process's environment", + ); + assert!( + environment.contains_key("PATH"), + "the allowlist still passes what tmux needs", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From 8e7aa58e2f52b8f2e59e4693831227afcfcf94ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:11:24 -0500 Subject: [PATCH 086/117] Channels(fix[wait-for]): Keep the client tmux cannot take back why: tmux queues a `wait-for` client on the channel and offers no way to withdraw it. `cmd_wait_for_signal` releases every waiter it finds and keeps the signal only when it finds none; `cmd_wait_for_unlock` grants the lock to the first client queued for it; `server_client_lost` removes neither, and the queued item still holds the reference `cmdq_append` took, so the entry stays in the channel's list and can never act. Killing the client for running out of time therefore took the channel's next signal, or its lock, with it: `wait_for_channel` returned TimedOut for a signal that had already arrived, and `lock_channel` wedged the channel forever. Identical 3.2a through 3.8-rc; 3.8-rc's `wait-for -l` lists the entry by name after the client is gone. Bakeoff: repairing the channel afterwards with one `wait-for -S` clears the entry in one command, and forges a release for any other waiter on the channel -- measured: a second waiter exits 0 having never been signalled. Replaying the signal into tmux has the same fault in the other direction, latching one nobody sent. Parking the client costs one idle process per channel that timed out and is not signalled yet, and forges nothing, so it wins. what: - Add internal::wait_for: one client per channel per handle, a caller that runs out of time leaves it parked, a later wait joins it, and a release with no caller left to hear it is kept for the next wait - Add CommandRequest::without_deadline, the one request shape the subprocess executor runs with no deadline of its own; it holds no admission permit, since signalling a channel is itself a dispatch - lock_channel bounds the caller rather than the client: a lock granted after its caller gave up unlocks at once, and with_channel_lock inherits it - Record the defect and both limits in design.md: a signal spent on a parked client is not seen by another process, and shutdown kills what is parked --- crates/libtmux/README.md | 6 +- crates/libtmux/docs/design.md | 32 ++ crates/libtmux/src/command.rs | 22 ++ crates/libtmux/src/internal/core.rs | 40 +++ crates/libtmux/src/internal/listing.rs | 2 +- crates/libtmux/src/internal/mod.rs | 1 + crates/libtmux/src/internal/subprocess.rs | 30 +- crates/libtmux/src/internal/wait_for.rs | 368 ++++++++++++++++++++++ crates/libtmux/src/limits.rs | 4 + crates/libtmux/src/server/channels.rs | 128 +++----- crates/libtmux/tests/server_command.rs | 348 ++++++++++++++++++++ 11 files changed, 892 insertions(+), 89 deletions(-) create mode 100644 crates/libtmux/src/internal/wait_for.rs diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index ee2b21c0..2a7bbb11 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -148,8 +148,10 @@ async fn main() -> Result<(), Box> { ``` tmux keeps a signal nobody is waiting on, so the job finishing first does not -lose the race, and nothing polls. `examples/orchestrate.rs` runs three jobs -this way. +lose the race, and nothing polls. A wait that runs out of time keeps its +client on the channel rather than killing it, so the signal is still there for +the next wait: tmux cannot take a waiter back out, and a killed one would eat +the signal instead. `examples/orchestrate.rs` runs three jobs this way. For a pane that was *not* written to announce itself, `Pane::wait_for_text` does the same job without a channel: diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 18058796..23109077 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -1376,6 +1376,38 @@ The source of 3.7 and 3.7c has the condition and 3.8-rc does not. Measured: 3.7c prints nothing for a one-binding table and 3.7d prints it. `real_tmux_compat_key_bindings_read_as_fields` binds two one-binding tables, and fails on 3.7c when `-T` is sent. +### A `wait-for` client cannot be taken back out + +tmux queues a `wait-for` client on the channel and offers nothing to withdraw +it. `cmd_wait_for_signal` releases every waiter it finds and keeps the signal +only when it finds none; `cmd_wait_for_unlock` grants the lock to the first +client queued for it. `server_client_lost` frees every other structure a lost +client owns -- its files, its overlay, its prompt, `input_cancel_requests` -- +and never touches `wait_channels`. The client leaves the `clients` list, so +`cmdq_next` never runs its queue again, and the queued item still holds the +reference `cmdq_append` took, so nothing is freed and nothing dangles: one +client struct and one queue item leak, and the channel's list keeps an entry +that can never act. Identical from 3.2a through 3.8-rc; 3.8-rc's new +`wait-for -l` lists the entry by name after the client is gone, which is the +one-line proof. + +So a client killed for running out of time takes the channel's next signal, or +its next lock, with it. `internal::wait_for` answers by never killing one: the +dispatch runs without a deadline of its own (`CommandRequest::without_deadline`, +the only request that does), and the caller's deadline ends the *wait* rather +than the client. A lock granted after its caller gave up unlocks at once. A +wait's client stays parked on the channel, at most one per channel per handle +so that a second wait joins it rather than adding a second waiter, and the +signal that releases it is kept in `ChannelWaits` when no caller is left -- +tmux keeps a signal nobody is waiting on, and cannot see that a parked client +is nobody. + +Two things this does not reach. A process other than this one waiting on the +channel afterwards does not see that signal: it was spent in tmux, and the +only way to put it back is `wait-for -S`, which would release somebody else's +wait. And `Server::shutdown` kills what is parked, because a shutdown that +waits on tmux is not a shutdown, which leaves the tmux defect behind on a +channel that was still parked. ### Two shapes that make a test flaky under load diff --git a/crates/libtmux/src/command.rs b/crates/libtmux/src/command.rs index d9550b24..3ceb20e5 100644 --- a/crates/libtmux/src/command.rs +++ b/crates/libtmux/src/command.rs @@ -741,6 +741,15 @@ pub(crate) struct CommandRequest { /// it with when none fails. #[cfg(feature = "control-mode")] command_count: usize, + /// Whether this request runs without a deadline of its own. + /// + /// A dispatch is bounded so a caller cannot wait on tmux forever. The + /// exception is a `wait-for` client, which tmux holds until something + /// signals or unlocks the channel and cannot withdraw once it is queued: + /// killing it leaves tmux an entry with no client behind it, which eats + /// the channel's next signal or its next lock. Such a request ends when + /// tmux answers or the executor shuts down. + unbounded: bool, } impl CommandRequest { @@ -773,6 +782,7 @@ impl CommandRequest { control_line: None, #[cfg(feature = "control-mode")] command_count: 1, + unbounded: false, } } @@ -795,9 +805,21 @@ impl CommandRequest { control_line: None, #[cfg(feature = "control-mode")] command_count, + unbounded: false, } } + /// Run this request without a deadline of its own. + pub(crate) const fn without_deadline(mut self) -> Self { + self.unbounded = true; + self + } + + /// Whether this request runs without a deadline of its own. + pub(crate) const fn is_unbounded(&self) -> bool { + self.unbounded + } + /// Attach the control-mode rendering of the command this request carries. #[cfg(feature = "control-mode")] pub(crate) fn with_control_line(mut self, line: Option) -> Self { diff --git a/crates/libtmux/src/internal/core.rs b/crates/libtmux/src/internal/core.rs index 9e19890d..f1917dc7 100644 --- a/crates/libtmux/src/internal/core.rs +++ b/crates/libtmux/src/internal/core.rs @@ -16,6 +16,7 @@ use crate::internal::process::LaunchContext; #[cfg(feature = "control-mode")] use crate::internal::process::{PersistentChild, PersistentClients}; use crate::internal::subprocess::SubprocessExecutor; +use crate::internal::wait_for::ChannelWaits; #[cfg(feature = "control-mode")] use crate::limits::ControlClientLimits; use crate::limits::{DispatchLimits, OutputLimits}; @@ -320,6 +321,11 @@ pub(crate) struct Core { executor: Arc, capabilities: OnceCell, next_request_id: AtomicU64, + /// The `wait-for` channels this handle has a client on, or a signal for. + /// + /// Shared by every clone of a [`crate::Server`], because the clients are + /// this process's and a channel is one name on the tmux server. + channel_waits: ChannelWaits, #[cfg(feature = "control-mode")] persistent_clients: PersistentClients, /// PIDs of control clients this process itself spawned with @@ -357,6 +363,7 @@ impl Core { executor, capabilities: OnceCell::new(), next_request_id: AtomicU64::new(1), + channel_waits: ChannelWaits::default(), #[cfg(feature = "control-mode")] persistent_clients: PersistentClients::new(control_client_limits), #[cfg(feature = "control-mode")] @@ -382,6 +389,7 @@ impl Core { executor: Arc::new(executor), capabilities: OnceCell::new_with(Some(capabilities)), next_request_id: AtomicU64::new(1), + channel_waits: ChannelWaits::default(), persistent_clients: PersistentClients::new(self.configuration.control_client_limits), // Shared with the parent: a control client this process spawned is // the same process whichever handle dispatches through it. @@ -433,6 +441,38 @@ impl Core { self.executor.execute(request).await } + /// Start a dispatch with no deadline of its own, and name it. + /// + /// For `wait-for` only, where a client killed part-way stays in tmux's + /// waiter or locker list: this one ends when tmux answers or the executor + /// shuts down. The caller bounds its own wait and names this dispatch when + /// it gives up, so the id comes back with the future. + pub(crate) fn dispatch_without_deadline( + &self, + command: Command, + ) -> (RequestId, crate::internal::executor::DispatchFuture) { + #[cfg(feature = "control-mode")] + let control_line = self + .executor + .renders_control_line() + .then(|| command.control_mode_line()); + let request = CommandRequest::with_global_argv( + self.next_request_id(), + &self.configuration.global_argv, + command, + ) + .without_deadline(); + #[cfg(feature = "control-mode")] + let request = request.with_control_line(control_line.flatten()); + let request_id = request.request_id(); + (request_id, self.executor.execute(request)) + } + + /// The `wait-for` channels this handle has a client on, or a signal for. + pub(crate) const fn channel_waits(&self) -> &ChannelWaits { + &self.channel_waits + } + pub(crate) async fn execute_chain(&self, chain: CommandChain) -> Result { #[cfg(feature = "control-mode")] let control_line = self diff --git a/crates/libtmux/src/internal/listing.rs b/crates/libtmux/src/internal/listing.rs index 2e11d6dc..43bae131 100644 --- a/crates/libtmux/src/internal/listing.rs +++ b/crates/libtmux/src/internal/listing.rs @@ -463,7 +463,7 @@ pub(crate) async fn mutate( Err(mutation_failure(command_name, &result, target.as_deref())) } -fn mutation_failure( +pub(crate) fn mutation_failure( command_name: &'static str, result: &crate::CommandResult, target: Option<&OsStr>, diff --git a/crates/libtmux/src/internal/mod.rs b/crates/libtmux/src/internal/mod.rs index 596408d2..469a582a 100644 --- a/crates/libtmux/src/internal/mod.rs +++ b/crates/libtmux/src/internal/mod.rs @@ -8,3 +8,4 @@ pub(crate) mod options; pub(crate) mod process; pub(crate) mod scoped; pub(crate) mod subprocess; +pub(crate) mod wait_for; diff --git a/crates/libtmux/src/internal/subprocess.rs b/crates/libtmux/src/internal/subprocess.rs index de659f38..f519fa91 100644 --- a/crates/libtmux/src/internal/subprocess.rs +++ b/crates/libtmux/src/internal/subprocess.rs @@ -209,15 +209,28 @@ impl SubprocessExecutor { request_id, command: request.summary().clone(), timeout: self.configuration.timeout, - deadline: Instant::now().checked_add(self.configuration.timeout), + deadline: if request.is_unbounded() { + None + } else { + Instant::now().checked_add(self.configuration.timeout) + }, }; trace_requested(&context); - let permit = match self.acquire_permit(&context).await { - Ok(permit) => permit, - Err(error) => { - trace_failed(&context, &error); - return Err(error); + // A request with no deadline is parked on a tmux channel rather than + // working, and holds its client until something signals or unlocks + // that channel -- which takes a dispatch of its own. Counting it + // against the ceiling would let enough parked clients block the very + // commands that release them. + let permit = if request.is_unbounded() { + None + } else { + match self.acquire_permit(&context).await { + Ok(permit) => Some(permit), + Err(error) => { + trace_failed(&context, &error); + return Err(error); + } } }; @@ -398,8 +411,9 @@ struct ChildOwnership { readers: ReaderTasks, #[cfg(feature = "test-support")] synchronous_reap_on_drop: bool, - // Admission follows the process and readers into supervisor cleanup. - _permit: OwnedSemaphorePermit, + // Admission follows the process and readers into supervisor cleanup, and + // a parked client holds none. + _permit: Option, // This must remain last so registry removal follows child and reader cleanup. #[allow( dead_code, diff --git a/crates/libtmux/src/internal/wait_for.rs b/crates/libtmux/src/internal/wait_for.rs new file mode 100644 index 00000000..d59c3f22 --- /dev/null +++ b/crates/libtmux/src/internal/wait_for.rs @@ -0,0 +1,368 @@ +//! Waiting on and locking a `wait-for` channel, and the state that takes. +//! +//! tmux queues a `wait-for` client on the channel and has no way to withdraw +//! it: `cmd_wait_for_signal` releases every waiter it finds and keeps the +//! signal only when it finds none, `cmd_wait_for_unlock` hands the lock to the +//! first queued locker, and `server_client_lost` removes neither. A client +//! killed for running out of time therefore stays in tmux's list, where it +//! eats the channel's next signal or takes its lock and never gives it back. +//! +//! So a client is never killed for running out of time. It is parked instead: +//! the caller stops waiting, the client stays until tmux releases it, and what +//! tmux released it with is accounted for here -- a signal with no caller left +//! to hear it is kept for the next wait, and a lock with no caller left to +//! hold it is released at once. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use tokio::sync::{oneshot, watch}; +use tokio::time::Instant; + +#[cfg(feature = "tracing")] +use tracing::instrument::{Instrument as _, WithSubscriber as _}; + +use crate::internal::core::Core; +use crate::internal::listing; +use crate::{ChannelWait, Command, CommandResult, Error}; + +/// The `wait-for` channels this process has a client on, or a signal for. +#[derive(Default)] +pub(crate) struct ChannelWaits { + channels: Mutex>, +} + +struct Channel { + /// A signal that released a parked client with no caller left to hear it. + /// + /// tmux keeps a signal nobody is waiting on, and cannot see that a parked + /// client is nobody, so the next wait reads the signal from here. + kept: bool, + /// Callers waiting on this channel right now. + waiting: usize, + /// Whether a client of this process is on the channel. + resident: bool, + /// Why the channel's client ended, for the first caller that asks. + failure: Option, + /// Releases seen on this channel, which is how a caller that ran out of + /// time at the same moment still reads the signal it was released by. + releases: watch::Sender, +} + +impl Default for Channel { + fn default() -> Self { + Self { + kept: false, + waiting: 0, + resident: false, + failure: None, + releases: watch::channel(0).0, + } + } +} + +impl Channel { + /// Whether nothing about this channel is left to remember. + fn is_idle(&self) -> bool { + !self.kept && !self.resident && self.waiting == 0 && self.failure.is_none() + } +} + +impl ChannelWaits { + fn channels(&self) -> MutexGuard<'_, HashMap> { + self.channels.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Take a kept signal, or join the queue behind the channel's client. + fn join(&self, channel: &str) -> Join<'_> { + let mut channels = self.channels(); + let state = channels.entry(channel.to_owned()).or_default(); + if state.kept { + state.kept = false; + let idle = state.is_idle(); + if idle { + channels.remove(channel); + } + return Join::Kept; + } + + state.waiting += 1; + let opening = !state.resident; + if opening { + state.resident = true; + state.failure = None; + } + Join::Waiting(Waiter { + waits: self, + channel: channel.to_owned(), + seen: *state.releases.borrow(), + released: state.releases.subscribe(), + opening, + settled: false, + }) + } + + /// Record how the channel's client ended. + fn finish(&self, channel: &str, outcome: Result<(), Error>) { + let mut channels = self.channels(); + let Some(state) = channels.get_mut(channel) else { + return; + }; + state.resident = false; + match outcome { + Ok(()) => { + state.releases.send_modify(|count| *count += 1); + state.kept = state.waiting == 0; + } + Err(error) => { + state.failure = Some(error); + state.releases.send_modify(|_| ()); + } + } + if state.is_idle() { + channels.remove(channel); + } + } +} + +enum Join<'a> { + /// A signal arrived while this process had nobody waiting. + Kept, + /// This caller is queued behind the channel's client. + Waiting(Waiter<'a>), +} + +/// One caller's place on a channel, given up however the caller leaves. +struct Waiter<'a> { + waits: &'a ChannelWaits, + channel: String, + seen: u64, + released: watch::Receiver, + /// Whether this caller is the one that opens the channel's client. + opening: bool, + settled: bool, +} + +/// What a caller leaves a channel with. +enum Settled { + Signalled, + /// The client ended without a signal, and this caller takes the reason. + Failed(Error), + /// The client ended without a signal, and another caller took the reason. + Gone, + TimedOut, +} + +impl Waiter<'_> { + /// Leave the channel, saying what this caller takes with it. + fn settle(&mut self) -> Settled { + self.settled = true; + let mut channels = self.waits.channels(); + let Some(state) = channels.get_mut(&self.channel) else { + return Settled::TimedOut; + }; + state.waiting -= 1; + + let settled = if *state.releases.borrow() > self.seen { + Settled::Signalled + } else if state.resident { + Settled::TimedOut + } else { + state.failure.take().map_or(Settled::Gone, Settled::Failed) + }; + if state.is_idle() { + channels.remove(&self.channel); + } + settled + } +} + +impl Drop for Waiter<'_> { + fn drop(&mut self) { + if self.settled { + return; + } + let mut channels = self.waits.channels(); + let Some(state) = channels.get_mut(&self.channel) else { + return; + }; + state.waiting -= 1; + // A signal this caller was released by and then dropped is nobody's, + // so it goes back to being kept for the next wait. + state.kept = *state.releases.borrow() > self.seen && state.waiting == 0; + if state.is_idle() { + channels.remove(&self.channel); + } + } +} + +/// Wait for `channel` to be signalled, for at most `budget`. +/// +/// The client outlives the wait: a caller that runs out of time leaves it +/// parked, a later wait on the same channel joins it rather than opening a +/// second, and a signal that releases it with nobody waiting is kept. +pub(crate) async fn wait( + core: &Arc, + channel: &str, + budget: Duration, +) -> Result { + let deadline = Instant::now().checked_add(budget); + loop { + let mut waiter = match core.channel_waits().join(channel) { + Join::Kept => return Ok(ChannelWait::Signalled), + Join::Waiting(waiter) => waiter, + }; + if waiter.opening { + park(core, channel); + } + + tokio::select! { + result = waiter.released.changed() => { + let _ = result; + } + () = elapsed(deadline) => {} + } + + match waiter.settle() { + Settled::Signalled => return Ok(ChannelWait::Signalled), + Settled::Failed(error) => return Err(error), + // The client ended with neither a signal nor a reason left to + // report, so this caller opens one of its own with the time it + // has left. + Settled::Gone if !out_of_time(deadline) => {} + Settled::Gone | Settled::TimedOut => return Ok(ChannelWait::TimedOut), + } + } +} + +/// Open the channel's client, which stays until tmux releases it. +fn park(core: &Arc, channel: &str) { + let core = Arc::clone(core); + let channel = channel.to_owned(); + spawn(async move { + let (_, dispatch) = core.dispatch_without_deadline(channel_command(None, &channel)); + let outcome = mutated(dispatch.await); + core.channel_waits().finish(&channel, outcome); + }); +} + +/// Lock `channel`, giving up after `budget` without leaving it wedged. +/// +/// tmux grants a released lock to the first client queued for it, dead or +/// alive, so the client stays queued after the caller gives up and unlocks as +/// soon as it is granted. +pub(crate) async fn lock(core: &Arc, channel: &str, budget: Duration) -> Result<(), Error> { + let command = channel_command(Some("-L"), channel); + let summary = command.summary(); + let (request_id, dispatch) = core.dispatch_without_deadline(command); + + let (granted, taken) = oneshot::channel(); + let holder = Arc::clone(core); + let name = channel.to_owned(); + spawn(async move { + let outcome = mutated(dispatch.await); + if let Err(outcome) = granted.send(outcome) { + // The caller is gone, and tmux grants this client the lock all the + // same: a lock nobody holds wedges every later locker. + if outcome.is_ok() { + let _ = unlock(&holder, &name).await; + } + } + }); + + let deadline = Instant::now().checked_add(budget); + tokio::select! { + outcome = taken => match outcome { + Ok(outcome) => outcome, + Err(_) => Err(Error::supervisor_lost(request_id.get(), summary)), + }, + () = elapsed(deadline) => Err(Error::timeout(request_id.get(), summary, budget)), + } +} + +/// Release `channel`, whoever holds it. +pub(crate) async fn unlock(core: &Arc, channel: &str) -> Result<(), Error> { + listing::mutate(core, "wait-for", channel_command(Some("-U"), channel)).await +} + +/// Signal `channel`, releasing everything waiting on it. +pub(crate) async fn signal(core: &Arc, channel: &str) -> Result<(), Error> { + listing::mutate(core, "wait-for", channel_command(Some("-S"), channel)).await +} + +fn channel_command(flag: Option<&'static str>, channel: &str) -> Command { + let command = Command::new("wait-for"); + let command = match flag { + Some(flag) => command.arg(flag), + None => command, + }; + command.arg("--").arg(OsString::from(channel)) +} + +fn mutated(result: Result) -> Result<(), Error> { + let result = result?; + if result.success() { + Ok(()) + } else { + Err(listing::mutation_failure("wait-for", &result, None)) + } +} + +async fn elapsed(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } +} + +fn out_of_time(deadline: Option) -> bool { + deadline.is_some_and(|deadline| Instant::now() >= deadline) +} + +fn spawn(task: impl Future + Send + 'static) { + #[cfg(feature = "tracing")] + tokio::spawn(task.in_current_span().with_current_subscriber()); + #[cfg(not(feature = "tracing"))] + tokio::spawn(task); +} + +#[cfg(test)] +mod tests { + use super::{ChannelWaits, Join, Settled}; + + /// What a release does depends on who is left to hear it, and the three + /// answers are what keeps a signal from being lost or heard twice. + #[test] + fn a_release_is_kept_only_when_no_caller_is_left_to_hear_it() { + let waits = ChannelWaits::default(); + + // The caller gave up and left the client parked: the release is kept, + // and the next caller takes it once. + let Join::Waiting(gave_up) = waits.join("build") else { + panic!("the channel starts with nothing kept"); + }; + drop(gave_up); + waits.finish("build", Ok(())); + assert!(matches!(waits.join("build"), Join::Kept)); + + // A caller is waiting: the release is theirs, and nothing is kept. + let Join::Waiting(mut waiting) = waits.join("build") else { + panic!("a kept release is one-shot"); + }; + waits.finish("build", Ok(())); + assert!(matches!(waiting.settle(), Settled::Signalled)); + assert!(matches!(waits.join("build"), Join::Waiting(_))); + + // Released, then dropped before settling: the release is nobody's + // again rather than lost. + let Join::Waiting(raced) = waits.join("build") else { + panic!("the release went to the caller that was waiting"); + }; + waits.finish("build", Ok(())); + drop(raced); + assert!(matches!(waits.join("build"), Join::Kept)); + } +} diff --git a/crates/libtmux/src/limits.rs b/crates/libtmux/src/limits.rs index badc4808..f128bd3a 100644 --- a/crates/libtmux/src/limits.rs +++ b/crates/libtmux/src/limits.rs @@ -93,6 +93,10 @@ impl Default for OutputLimits { /// into process, descriptor, and memory pressure on the machine, and tmux /// itself serializes on the far side regardless. /// +/// The client [`Server::wait_for_channel`](crate::Server::wait_for_channel) +/// parks on a channel is the exception, and is not counted: it is waiting +/// rather than working, and what releases it is a dispatch of its own. +/// /// # Examples /// /// ``` diff --git a/crates/libtmux/src/server/channels.rs b/crates/libtmux/src/server/channels.rs index 59821952..d42d98e8 100644 --- a/crates/libtmux/src/server/channels.rs +++ b/crates/libtmux/src/server/channels.rs @@ -1,9 +1,8 @@ -use std::ffi::OsString; use std::time::Duration; use super::{ChannelWait, Server}; -use crate::internal::listing; -use crate::{Command, Error}; +use crate::Error; +use crate::internal::wait_for; impl Server { /// Signal a `wait-for` channel, releasing anything waiting on it. @@ -24,15 +23,7 @@ impl Server { /// /// Returns an error when tmux refuses the channel name. pub async fn signal_channel(&self, channel: &str) -> Result<(), Error> { - listing::mutate( - &self.core, - "wait-for", - Command::new("wait-for") - .arg("-S") - .arg("--") - .arg(OsString::from(channel)), - ) - .await + wait_for::signal(&self.core, channel).await } /// Hold a `wait-for` channel for the length of an operation. @@ -43,16 +34,15 @@ impl Server { /// /// # Errors /// - /// [`crate::ScopeError::Creation`] when tmux refuses the lock, - /// `Operation` when the body fails, and `Cleanup` when the unlock fails - /// after the body succeeded. + /// [`crate::ScopeError::Creation`] when tmux refuses the lock or the lock + /// runs out of time, `Operation` when the body fails, and `Cleanup` when + /// the unlock fails after the body succeeded. /// /// # Cancel safety /// /// Nothing is left held by a drop. The lock is taken and released in tasks /// of their own, so a scope dropped while its lock is still queued unlocks - /// once tmux grants it. A lock still queued when the server's default - /// timeout elapses wedges the channel as [`Self::lock_channel`] describes. + /// once tmux grants it, as [`Self::lock_channel`] describes. /// /// # Examples /// @@ -101,50 +91,43 @@ impl Server { /// Lock a `wait-for` channel, blocking later lock attempts on it. /// /// [`Self::with_channel_lock`] pairs this with the unlock, which is - /// usually what a caller wants. + /// usually what a caller wants. A lock another locker holds waits for its + /// turn, up to [`Server::default_timeout`]. /// /// # Errors /// - /// Returns an error when tmux refuses the channel name. + /// Returns an error when tmux refuses the channel name, and + /// [`crate::ErrorKind::Timeout`] when the channel is still held after + /// [`Server::default_timeout`]. /// /// # Cancel safety /// - /// Something is left held, and no one can release it. Dropped, or timed - /// out, while queued behind another locker, this leaves tmux a queue - /// entry with no client behind it; `cmd_wait_for_unlock` hands the lock - /// to that entry, and every later lock on the channel blocks forever. A - /// tmux defect (`cmd-wait-for.c`), measured on 3.2a, 3.7c and master. + /// Nothing is left held. The lock is taken in a task of its own, so a lock + /// that is dropped -- or that runs out of time -- while queued behind + /// another locker is not withdrawn from tmux, which cannot withdraw one: + /// it is granted in turn and released at once. Between those two, later + /// lockers wait as they would for any other holder. + /// + /// Shutting the server down while such a lock is queued is the exception, + /// because it kills the client: tmux then grants the lock to a client that + /// is gone and every later lock on the channel blocks forever. A tmux + /// defect (`cmd_wait_for_unlock` in `cmd-wait-for.c`), measured on 3.2a, + /// 3.7d and 3.8-rc. pub async fn lock_channel(&self, channel: &str) -> Result<(), Error> { - listing::mutate( - &self.core, - "wait-for", - Command::new("wait-for") - .arg("-L") - .arg("--") - .arg(OsString::from(channel)), - ) - .await + wait_for::lock(&self.core, channel, self.default_timeout()).await } /// Unlock a `wait-for` channel. /// /// Always call this from whatever locked with [`Self::lock_channel`], /// including on an error path: a locker that ends without unlocking can - /// wedge the channel for everyone else. See that method's hazard note. + /// wedge the channel for everyone else. /// /// # Errors /// /// Returns an error when tmux refuses the channel name. pub async fn unlock_channel(&self, channel: &str) -> Result<(), Error> { - listing::mutate( - &self.core, - "wait-for", - Command::new("wait-for") - .arg("-U") - .arg("--") - .arg(OsString::from(channel)), - ) - .await + wait_for::unlock(&self.core, channel).await } /// Wait for a `wait-for` channel to be signalled. @@ -167,11 +150,20 @@ impl Server { /// between 3.5a and 3.7c, and the only changes since 3.2a are an argument /// table gaining a field, an accessor replacing a direct index, and a /// local being renamed -- none of them near the flag the latch is kept in. - /// Measured directly on 3.5a and 3.7c. + /// Measured directly on 3.2a, 3.5a, 3.7c and 3.7d. + /// + /// A wait that runs out of time leaves its client on the channel, because + /// tmux cannot withdraw one and a killed client would eat the channel's + /// next signal. Another wait on the same channel joins that client rather + /// than opening a second, and a signal that releases it with nobody + /// waiting is kept for the next wait, which is where the latch above + /// survives a wait that gave up. The client is this process's, so it ends + /// with [`Server::shutdown`]; it does not count against + /// [`crate::DispatchLimits`], because signalling the channel is itself a + /// dispatch. /// - /// `within` is capped at [`Server::default_timeout`], because a dispatch - /// is bounded and this is one: ask for longer by building the server with - /// a longer timeout. + /// `within` is capped at [`Server::default_timeout`]: ask for longer by + /// building the server with a longer timeout. /// /// # Errors /// @@ -182,11 +174,16 @@ impl Server { /// /// # Cancel safety /// - /// Something is left held. A wait that is dropped, or that returns - /// [`ChannelWait::TimedOut`], leaves tmux a waiter with no client behind - /// it, and the channel's next signal releases that waiter instead of - /// latching, so a wait begun after the signal misses it. Measured on 3.7d; - /// see `cmd_wait_for_signal` in `cmd-wait-for.c`. + /// Nothing is lost by a drop, and nothing is left for the next caller to + /// find: the client stays on the channel exactly as it does after + /// [`ChannelWait::TimedOut`], and a signal that releases it is kept for + /// the next wait on this server handle or any clone of it. + /// + /// A process outside this one is the exception. The signal that releases + /// the parked client is spent in tmux, so a *different* process waiting on + /// the same channel afterwards does not see it; tmux offers no way to take + /// a waiter back out, and forging a replacement signal would release + /// somebody else's wait. /// /// # Examples /// @@ -218,31 +215,6 @@ impl Server { channel: &str, within: Duration, ) -> Result { - let budget = within.min(self.default_timeout()); - let waited = tokio::time::timeout( - budget, - listing::mutate( - &self.core, - "wait-for", - Command::new("wait-for") - .arg("--") - .arg(OsString::from(channel)), - ), - ) - .await; - - match waited { - Ok(Ok(())) => Ok(ChannelWait::Signalled), - // The dispatch reaching its own bound first is the same event, so - // it is reported the same way rather than as two outcomes a - // caller would have to unify. - Ok(Err(error)) if error.kind() == crate::ErrorKind::Timeout => { - Ok(ChannelWait::TimedOut) - } - Ok(Err(error)) => Err(error), - // Dropping the dispatch kills the waiting client but leaves its - // entry in tmux's waiter list: see the cancel-safety section. - Err(_elapsed) => Ok(ChannelWait::TimedOut), - } + wait_for::wait(&self.core, channel, within.min(self.default_timeout())).await } } diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index 8f97e9c2..9ae5b1ab 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -16,6 +16,10 @@ use std::path::{Path, PathBuf}; use std::process; use std::time::{Duration, Instant}; +#[cfg(feature = "test-support")] +use libtmux::ChannelWait; +#[cfg(feature = "test-support")] +use libtmux::test::scaled; use libtmux::{ Command, CommandResult, EngineCapabilities, Error, Server, ServerBuilder, ServerConfigurationErrorKind, ServerIdentity, @@ -1211,6 +1215,350 @@ async fn a_channel_lock_is_released_when_the_body_fails() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A handle that gives up sooner than the fixture's, on the same socket. +#[cfg(feature = "test-support")] +fn impatient_handle(guard: &libtmux::test::TestServer, within: Duration) -> Server { + Server::builder() + .socket_path(guard.socket_path()) + .default_timeout(within) + .build() + .expect("a second handle on the fixture's socket") +} + +/// Every process on this machine whose command line names `channel`. +#[cfg(feature = "test-support")] +fn processes_naming(channel: &str) -> Vec { + let listing = process::Command::new("ps") + .arg("-eo") + .arg("args=") + .output() + .expect("ps lists processes"); + // `ps` itself is never a match: its own arguments do not name the channel. + String::from_utf8_lossy(&listing.stdout) + .lines() + .filter(|line| line.contains(channel)) + .map(ToOwned::to_owned) + .collect() +} + +/// tmux releases a signal to whatever is waiting and keeps it only when +/// nothing is, so a wait that gave up must not be counted as waiting. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_signal_after_a_wait_ran_out_of_time_reaches_the_next_wait() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let channel = libtmux::test::unique_name("timed-out"); + + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_millis(200))) + .await + .expect("the wait runs"), + ChannelWait::TimedOut, + "nothing signalled the channel", + ); + + server + .signal_channel(&channel) + .await + .expect("the channel is signalled"); + + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_secs(5))) + .await + .expect("the wait runs"), + ChannelWait::Signalled, + "the signal arrived before this wait started, so the wait returns at once", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A dropped wait is a wait that gave up, and gives up the same way. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_signal_after_a_dropped_wait_reaches_the_next_wait() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let channel = libtmux::test::unique_name("dropped"); + + let dropped = server.wait_for_channel(&channel, scaled(Duration::from_secs(30))); + assert!( + tokio::time::timeout(scaled(Duration::from_millis(200)), dropped) + .await + .is_err(), + "nothing signalled the channel", + ); + + server + .signal_channel(&channel) + .await + .expect("the channel is signalled"); + + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_secs(5))) + .await + .expect("the wait runs"), + ChannelWait::Signalled, + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// One signal releases every waiter, so one client can carry every wait this +/// process has on the channel -- and must, or the second would keep a signal +/// the first already answered. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn waits_on_one_channel_share_one_client() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let channel = libtmux::test::unique_name("shared"); + + for _ in 0..2 { + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_millis(200))) + .await + .expect("the wait runs"), + ChannelWait::TimedOut, + ); + let clients = processes_naming(&channel); + assert_eq!(clients.len(), 1, "clients on the channel: {clients:?}"); + } + + server + .signal_channel(&channel) + .await + .expect("the channel is signalled"); + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_secs(5))) + .await + .expect("the wait runs"), + ChannelWait::Signalled, + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// Signalling a channel is what releases the client parked on it, so a parked +/// client must not be able to hold the dispatch that would do the releasing. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_parked_wait_does_not_hold_the_server_s_one_dispatch_slot() { + let guard = libtmux::test::TestServer::builder() + .dispatch_limits( + libtmux::DispatchLimits::default() + .max_in_flight(1) + .acquire_timeout(Some(scaled(Duration::from_secs(2)))), + ) + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let channel = libtmux::test::unique_name("crowded"); + + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_millis(200))) + .await + .expect("the wait runs"), + ChannelWait::TimedOut, + ); + + // The only slot is free, so the signal gets through and the wait it + // releases reads it. + server + .signal_channel(&channel) + .await + .expect("the channel is signalled"); + assert_eq!( + server + .wait_for_channel(&channel, scaled(Duration::from_secs(5))) + .await + .expect("the wait runs"), + ChannelWait::Signalled, + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// The wait's client is this process's to account for: after shutdown none of +/// it is left running. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_wait_that_ran_out_of_time_leaves_no_client_behind_after_shutdown() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let channel = libtmux::test::unique_name("parked"); + + assert_eq!( + guard + .server() + .wait_for_channel(&channel, scaled(Duration::from_millis(200))) + .await + .expect("the wait runs"), + ChannelWait::TimedOut, + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); + + let left = processes_naming(&channel); + assert!(left.is_empty(), "processes left running: {left:?}"); +} + +/// tmux hands a released lock to the first queued locker, dead or alive, so a +/// locker that gave up must not still be queued. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_lock_that_ran_out_of_time_while_queued_leaves_the_channel_lockable() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let impatient = impatient_handle(&guard, scaled(Duration::from_millis(400))); + let channel = libtmux::test::unique_name("queued"); + + server + .lock_channel(&channel) + .await + .expect("the channel locks"); + + let queued = impatient + .lock_channel(&channel) + .await + .expect_err("the second lock runs out of time while queued"); + assert_eq!(queued.kind(), libtmux::ErrorKind::Timeout); + + server + .unlock_channel(&channel) + .await + .expect("the holder releases the channel"); + + tokio::time::timeout( + scaled(Duration::from_secs(5)), + server.lock_channel(&channel), + ) + .await + .expect("the channel is not wedged") + .expect("the channel locks again"); + server + .unlock_channel(&channel) + .await + .expect("the channel unlocks"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// `with_channel_lock` takes the same lock, so it inherits the same hazard. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_lock_scope_that_ran_out_of_time_while_queued_leaves_the_channel_lockable() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let impatient = impatient_handle(&guard, scaled(Duration::from_millis(400))); + let channel = libtmux::test::unique_name("queued-scope"); + + server + .lock_channel(&channel) + .await + .expect("the channel locks"); + + let outcome: Result<(), _> = impatient + .with_channel_lock(&channel, async |_| Ok::<(), Error>(())) + .await; + assert!( + matches!( + &outcome, + Err(libtmux::ScopeError::Creation(error)) if error.kind() == libtmux::ErrorKind::Timeout + ), + "the scope never starts: {outcome:?}", + ); + + server + .unlock_channel(&channel) + .await + .expect("the holder releases the channel"); + + tokio::time::timeout( + scaled(Duration::from_secs(5)), + server.lock_channel(&channel), + ) + .await + .expect("the channel is not wedged") + .expect("the channel locks again"); + server + .unlock_channel(&channel) + .await + .expect("the channel unlocks"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// A lock scope dropped while its lock is still queued: the lock runs in a +/// task of its own, so the drop cannot strand it. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_lock_scope_dropped_while_queued_leaves_the_channel_lockable() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let channel = libtmux::test::unique_name("dropped-scope"); + + server + .lock_channel(&channel) + .await + .expect("the channel locks"); + + let scope = server.with_channel_lock(&channel, async |_| Ok::<(), Error>(())); + assert!( + tokio::time::timeout(scaled(Duration::from_millis(400)), scope) + .await + .is_err(), + "the scope is still queued behind the holder", + ); + + server + .unlock_channel(&channel) + .await + .expect("the holder releases the channel"); + + tokio::time::timeout( + scaled(Duration::from_secs(5)), + server.lock_channel(&channel), + ) + .await + .expect("the channel is not wedged") + .expect("the channel locks again"); + server + .unlock_channel(&channel) + .await + .expect("the channel unlocks"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[cfg(feature = "test-support")] #[tokio::test] async fn a_buffer_larger_than_an_argument_round_trips_through_files() { From 8c23ddb6b07fca927399210986e469212324c6f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:25:16 -0500 Subject: [PATCH 087/117] Channels(fix[routing]): Refuse a wait a connection cannot carry why: A connection runs one command at a time, and `cmdq_fire_command` writes a block's `end` guard as soon as `entry->exec` returns: `cmd_wait_for_wait` returns CMD_RETURN_WAIT with its item still queued, so tmux says the wait finished and then runs nothing else for that client until the channel releases it. Measured on 3.7d through a routed handle: `wait_for_channel` answered Signalled in 190us for a channel nobody had signalled, and the next routed call got no answer in two seconds. A silent wrong answer either way round, and the parking this branch added does not reach it -- the connection's client is not one this crate may park. A grant crossing the wire as the caller stops waiting is the same shape of hole in the lock, found reviewing this: the value would be dropped with the receiver, leaving the lock held by nobody. The receiver is closed first and read back, so a grant is either the caller's or released. what: - wait_for_channel, lock_channel and with_channel_lock refuse a handle from Server::over_control_mode with ControlModeErrorKind::Blocking- Command, a new kind classified as InvalidInput - signal_channel and unlock_channel are untouched: neither blocks, and routing them is what a routed handle is for - Close the lock's receiver before giving up on it, and release from the guard's drop what the caller never saw - Record the refusal in migration.md, since a routed wait used to "succeed" --- crates/libtmux/README.md | 5 +- crates/libtmux/docs/design.md | 11 +++ crates/libtmux/docs/public-api.txt | 1 + crates/libtmux/src/error.rs | 17 +++++ crates/libtmux/src/error/classification.rs | 3 +- crates/libtmux/src/internal/core.rs | 10 +++ crates/libtmux/src/internal/wait_for.rs | 70 +++++++++++++++++++- crates/libtmux/src/server/channels.rs | 9 ++- crates/libtmux/tests/control_mode_routing.rs | 60 +++++++++++++++++ crates/libtmux/tests/server_command.rs | 47 +++++++++++++ 10 files changed, 227 insertions(+), 6 deletions(-) diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 2a7bbb11..63116865 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -150,8 +150,9 @@ async fn main() -> Result<(), Box> { tmux keeps a signal nobody is waiting on, so the job finishing first does not lose the race, and nothing polls. A wait that runs out of time keeps its client on the channel rather than killing it, so the signal is still there for -the next wait: tmux cannot take a waiter back out, and a killed one would eat -the signal instead. `examples/orchestrate.rs` runs three jobs this way. +this process's next wait: tmux cannot take a waiter back out, and a killed one +would eat the signal instead. `examples/orchestrate.rs` runs three jobs this +way. For a pane that was *not* written to announce itself, `Pane::wait_for_text` does the same job without a channel: diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index 23109077..aa1b52dd 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -1409,6 +1409,17 @@ wait. And `Server::shutdown` kills what is parked, because a shutdown that waits on tmux is not a shutdown, which leaves the tmux defect behind on a channel that was still parked. +Routing a blocking `wait-for` is refused for a different reason. +`cmdq_fire_command` writes the block's `end` guard as soon as `entry->exec` +returns, and `cmd_wait_for_wait` returns `CMD_RETURN_WAIT` with its item still +on the queue, so a control client is told the command finished and then runs +nothing else until the channel releases it. Measured through the crate on 3.7d: a +routed `wait_for_channel` on a channel nobody signalled returned `Signalled` +in 190us, and the next routed call answered nothing within two seconds. So +`wait_for_channel` and `lock_channel` refuse a routed handle with +`ControlModeErrorKind::BlockingCommand`; `signal_channel` and +`unlock_channel` do not block and still route. + ### Two shapes that make a test flaky under load Both of these passed locally for a long time and failed in CI, which has fewer diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index f534ce6d..fc58c56a 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -2282,6 +2282,7 @@ variant libtmux::Chooser::Buffer variant libtmux::Chooser::Client variant libtmux::Chooser::Customize variant libtmux::Chooser::Tree +variant libtmux::ControlModeErrorKind::BlockingCommand variant libtmux::ControlModeErrorKind::Closed variant libtmux::ControlModeErrorKind::DispatchTimedOut variant libtmux::ControlModeErrorKind::InvalidSubscriptionName diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index c25d9b95..75d652af 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -163,6 +163,14 @@ pub enum ControlModeErrorKind { /// the rest of the request with it. Refused here rather than sent, /// because tmux accepts the result and reports no error. InvalidSubscriptionName, + /// The command would hold the connection's one command queue. + /// + /// A connection runs one command at a time, and tmux closes a blocking + /// `wait-for` the moment it queues it: routed, the call would report + /// success without waiting, and nothing else would be answered on that + /// connection until the channel released it. Run it from a handle that + /// starts its own clients. + BlockingCommand, } /// What tmux says when it holds no session to resolve a target against. @@ -1134,6 +1142,15 @@ impl Error { } } + /// A command would hold a connection's one command queue. + #[cfg(feature = "control-mode")] + pub(crate) const fn control_mode_blocking() -> Self { + Self::ControlMode { + kind: ControlModeErrorKind::BlockingCommand, + source: None, + } + } + /// A protocol frame ran past its budget. /// /// Not recoverable in place: the parser is mid-frame and cannot know where diff --git a/crates/libtmux/src/error/classification.rs b/crates/libtmux/src/error/classification.rs index 002c00c8..92d8be32 100644 --- a/crates/libtmux/src/error/classification.rs +++ b/crates/libtmux/src/error/classification.rs @@ -116,7 +116,8 @@ impl Error { #[cfg(feature = "control-mode")] Self::ControlMode { kind, .. } => match kind { ControlModeErrorKind::UnrepresentableCommand - | ControlModeErrorKind::InvalidSubscriptionName => ErrorKind::InvalidInput, + | ControlModeErrorKind::InvalidSubscriptionName + | ControlModeErrorKind::BlockingCommand => ErrorKind::InvalidInput, // A limit was reached and the command was not carried out, // which is what `Refused` says. The connection is fine. ControlModeErrorKind::Unread => ErrorKind::Refused, diff --git a/crates/libtmux/src/internal/core.rs b/crates/libtmux/src/internal/core.rs index f1917dc7..15bf3c96 100644 --- a/crates/libtmux/src/internal/core.rs +++ b/crates/libtmux/src/internal/core.rs @@ -473,6 +473,16 @@ impl Core { &self.channel_waits } + /// Whether this handle dispatches over a connection someone else holds. + /// + /// A connection runs one command at a time, so a command that blocks in + /// tmux blocks the connection; the callers that would are the ones that + /// ask. + #[cfg(feature = "control-mode")] + pub(crate) fn routes_over_control_mode(&self) -> bool { + self.executor.renders_control_line() + } + pub(crate) async fn execute_chain(&self, chain: CommandChain) -> Result { #[cfg(feature = "control-mode")] let control_line = self diff --git a/crates/libtmux/src/internal/wait_for.rs b/crates/libtmux/src/internal/wait_for.rs index d59c3f22..c9358e8e 100644 --- a/crates/libtmux/src/internal/wait_for.rs +++ b/crates/libtmux/src/internal/wait_for.rs @@ -209,6 +209,8 @@ pub(crate) async fn wait( channel: &str, budget: Duration, ) -> Result { + #[cfg(feature = "control-mode")] + refuse_if_routed(core)?; let deadline = Instant::now().checked_add(budget); loop { let mut waiter = match core.channel_waits().join(channel) { @@ -255,6 +257,8 @@ fn park(core: &Arc, channel: &str) { /// alive, so the client stays queued after the caller gives up and unlocks as /// soon as it is granted. pub(crate) async fn lock(core: &Arc, channel: &str, budget: Duration) -> Result<(), Error> { + #[cfg(feature = "control-mode")] + refuse_if_routed(core)?; let command = channel_command(Some("-L"), channel); let summary = command.summary(); let (request_id, dispatch) = core.dispatch_without_deadline(command); @@ -273,13 +277,62 @@ pub(crate) async fn lock(core: &Arc, channel: &str, budget: Duration) -> R } }); + let mut grant = Grant { + taken, + core: Arc::clone(core), + channel: channel.to_owned(), + settled: false, + }; let deadline = Instant::now().checked_add(budget); tokio::select! { - outcome = taken => match outcome { + outcome = &mut grant.taken => match outcome { Ok(outcome) => outcome, Err(_) => Err(Error::supervisor_lost(request_id.get(), summary)), }, - () = elapsed(deadline) => Err(Error::timeout(request_id.get(), summary, budget)), + () = elapsed(deadline) => grant + .close() + .unwrap_or_else(|| Err(Error::timeout(request_id.get(), summary, budget))), + } +} + +/// The caller's end of a queued lock, which gives back a grant it cannot take. +/// +/// A grant that crosses the channel as the caller stops waiting would +/// otherwise be dropped with the receiver, leaving the lock held by nobody: +/// closing the receiver first makes the task's send fail, and anything that +/// arrived before the close is read back here. +struct Grant { + taken: oneshot::Receiver>, + core: Arc, + channel: String, + settled: bool, +} + +impl Grant { + /// Stop the lock task sending, and take what it already sent. + fn close(&mut self) -> Option> { + self.settled = true; + self.taken.close(); + self.taken.try_recv().ok() + } +} + +impl Drop for Grant { + fn drop(&mut self) { + if self.settled || !matches!(self.close(), Some(Ok(()))) { + return; + } + // The caller was dropped holding a lock it never saw. Without a + // runtime there is nothing left to release it with, and the client + // holding it is going with the process. + if tokio::runtime::Handle::try_current().is_err() { + return; + } + let core = Arc::clone(&self.core); + let channel = std::mem::take(&mut self.channel); + spawn(async move { + let _ = unlock(&core, &channel).await; + }); } } @@ -293,6 +346,19 @@ pub(crate) async fn signal(core: &Arc, channel: &str) -> Result<(), Error> listing::mutate(core, "wait-for", channel_command(Some("-S"), channel)).await } +/// Refuse a command that would sit on a connection's one command queue. +/// +/// tmux closes a blocking `wait-for` as soon as it queues it, so over a +/// connection the call would report success without waiting and the +/// connection would answer nothing else until the channel released it. +#[cfg(feature = "control-mode")] +fn refuse_if_routed(core: &Arc) -> Result<(), Error> { + if core.routes_over_control_mode() { + return Err(Error::control_mode_blocking()); + } + Ok(()) +} + fn channel_command(flag: Option<&'static str>, channel: &str) -> Command { let command = Command::new("wait-for"); let command = match flag { diff --git a/crates/libtmux/src/server/channels.rs b/crates/libtmux/src/server/channels.rs index d42d98e8..a60cb9e9 100644 --- a/crates/libtmux/src/server/channels.rs +++ b/crates/libtmux/src/server/channels.rs @@ -98,7 +98,8 @@ impl Server { /// /// Returns an error when tmux refuses the channel name, and /// [`crate::ErrorKind::Timeout`] when the channel is still held after - /// [`Server::default_timeout`]. + /// [`Server::default_timeout`]. A handle from `Server::over_control_mode` + /// is refused, for the reason [`Self::wait_for_channel`] gives. /// /// # Cancel safety /// @@ -172,6 +173,12 @@ impl Server { /// an error, so "nothing signalled it" stays distinct from "the command /// did not get through" -- the caller retries only one of those. /// + /// A handle from `Server::over_control_mode` is refused with + /// [`crate::ControlModeErrorKind::BlockingCommand`]: a connection runs one + /// command at a time, and tmux closes a blocking `wait-for` the moment it + /// queues it, so the wait would neither wait nor let anything else + /// through. `Server::signal_channel` routes as usual. + /// /// # Cancel safety /// /// Nothing is lost by a drop, and nothing is left for the next caller to diff --git a/crates/libtmux/tests/control_mode_routing.rs b/crates/libtmux/tests/control_mode_routing.rs index 0014cacc..bb301750 100644 --- a/crates/libtmux/tests/control_mode_routing.rs +++ b/crates/libtmux/tests/control_mode_routing.rs @@ -249,6 +249,66 @@ async fn typed_calls_route_over_the_connection_and_spawn_nothing() { guard.shutdown().await.expect("the fixture stops"); } +/// A connection runs one command at a time, and tmux closes a blocking +/// `wait-for` as soon as it queues it: routed, the wait would report a signal +/// nobody sent and the connection would answer nothing else until the channel +/// was signalled. +#[tokio::test] +async fn a_blocking_channel_call_is_refused_rather_than_routed() { + let guard = TestServer::new().await.expect("a private tmux starts"); + let real = guard.server().clone(); + let session = real + .new_session("routed") + .await + .expect("the fixture holds a session"); + + let control = ControlMode::attach(&real, session.id()) + .await + .expect("a control client attaches"); + let (sender, events) = control.split(); + let routed = real + .over_control_mode(&sender) + .await + .expect("the connection reaches the same server"); + + for refused in [ + routed + .wait_for_channel("never-signalled", std::time::Duration::from_secs(5)) + .await + .err(), + routed.lock_channel("never-signalled").await.err(), + ] { + let refused = refused.expect("a blocking channel call is refused"); + assert!( + matches!( + refused, + libtmux::Error::ControlMode { + kind: libtmux::ControlModeErrorKind::BlockingCommand, + .. + } + ), + "refused for blocking the connection: {refused:?}", + ); + } + + // The connection still answers, and the half that does not block routes. + routed + .signal_channel("never-signalled") + .await + .expect("signalling does not block, so it routes"); + assert_eq!( + routed + .sessions() + .await + .expect("the connection still answers") + .len(), + 1, + ); + + events.shutdown().await.expect("the connection closes"); + guard.shutdown().await.expect("the fixture stops"); +} + #[tokio::test] async fn a_sender_for_another_server_is_refused() { let first = TestServer::new().await.expect("a private tmux starts"); diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index 9ae5b1ab..4af13aa8 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -1514,6 +1514,53 @@ async fn a_lock_scope_that_ran_out_of_time_while_queued_leaves_the_channel_locka guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A lock dropped while still queued is granted in turn and released, rather +/// than held by a caller that is no longer there. +#[cfg(feature = "test-support")] +#[tokio::test] +async fn a_lock_dropped_while_queued_leaves_the_channel_lockable() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let channel = libtmux::test::unique_name("dropped-lock"); + + server + .lock_channel(&channel) + .await + .expect("the channel locks"); + + assert!( + tokio::time::timeout( + scaled(Duration::from_millis(400)), + server.lock_channel(&channel), + ) + .await + .is_err(), + "the second lock is still queued behind the holder", + ); + + server + .unlock_channel(&channel) + .await + .expect("the holder releases the channel"); + + tokio::time::timeout( + scaled(Duration::from_secs(5)), + server.lock_channel(&channel), + ) + .await + .expect("the channel is not wedged") + .expect("the channel locks again"); + server + .unlock_channel(&channel) + .await + .expect("the channel unlocks"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// A lock scope dropped while its lock is still queued: the lock runs in a /// task of its own, so the drop cannot strand it. #[cfg(feature = "test-support")] From da01c81398fe504d630fb39808d9683724815c6c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:28:00 -0500 Subject: [PATCH 088/117] tmux-mcp(fix[docs]): Split a two-sentence summary the gate refuses `just doc-blocks` reads a summary that runs into a second sentence as a doc block split across two items. One blank line settles it. --- crates/tmux-mcp/src/model.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tmux-mcp/src/model.rs b/crates/tmux-mcp/src/model.rs index 882ded6d..c4fffc56 100644 --- a/crates/tmux-mcp/src/model.rs +++ b/crates/tmux-mcp/src/model.rs @@ -84,6 +84,7 @@ pub struct RunCommandArgs { #[serde(deny_unknown_fields)] pub struct ShowEnvironmentArgs { /// The session whose environment to read, by `$`-prefixed id or name. + /// /// Omit for the server's own. pub session: Option, } From 28cb27ee69c9555bbfdba50d7664ac1ed6e41aae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:34:54 -0500 Subject: [PATCH 089/117] Options(fix[hooks]): Clear and write a replaced hook in one invocation A replacing `set_hooks` sent the clear (`set-hook -u`) on its own and then chained the entries, so a caller who dropped the call between the two -- or a failure there -- left the hook cleared and unwritten. That is the one state nobody asked for: not the old hook, not the new one. The clear now travels with the entries. The cost is attribution, and it is why merging still sends its first entry alone: there the first command is an entry rather than a clear, and an existing test pins that tmux refusing it is reported as a refusal of that entry rather than as a partial effect. Counted rather than asserted in prose: `replacing_hooks_clears_and_writes _in_one_command` uses the command counter and fails with `left: 2, right: 1` when the clear is split back out. --- crates/libtmux/src/internal/options.rs | 29 ++++++++++------- crates/libtmux/src/server/settings.rs | 10 +++--- crates/libtmux/src/session/settings.rs | 10 +++--- crates/libtmux/src/window/settings.rs | 10 +++--- crates/libtmux/tests/command_budget.rs | 45 ++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/crates/libtmux/src/internal/options.rs b/crates/libtmux/src/internal/options.rs index 73000d37..0b5288c6 100644 --- a/crates/libtmux/src/internal/options.rs +++ b/crates/libtmux/src/internal/options.rs @@ -607,18 +607,25 @@ pub(crate) async fn set_hooks( return run(core, "set-hook", None, first).await; }; - run(core, "set-hook", None, first).await?; - let result = match commands.next() { - None => core.execute(second).await, - Some(third) => { - let mut chain = CommandChain::new(second).then(third); - for command in commands { - chain = chain.then(command); - } - core.execute_chain(chain).await - } + // Under `Replace` the clear travels with the entries rather than ahead of + // them: sent on its own, a caller who dropped this future between the two + // left the hook cleared and unwritten, the one state nobody asked for. + // The cost is attribution, so `Merge`, whose first command is an entry + // rather than a clear, still sends it alone and can name it when tmux + // refuses it. + let mut chain = if replace == ReplaceMode::Replace { + CommandChain::new(first).then(second) + } else { + run(core, "set-hook", None, first).await?; + CommandChain::new(second) }; - let result = result.map_err(|error| error.after_effect("set-hooks"))?; + for command in commands { + chain = chain.then(command); + } + let result = core + .execute_chain(chain) + .await + .map_err(|error| error.after_effect("set-hooks"))?; if result.success() { return Ok(()); } diff --git a/crates/libtmux/src/server/settings.rs b/crates/libtmux/src/server/settings.rs index 5d4b4df4..50a46d59 100644 --- a/crates/libtmux/src/server/settings.rs +++ b/crates/libtmux/src/server/settings.rs @@ -493,10 +493,12 @@ impl Server { /// /// # Cancel safety /// - /// The effect can be partial: dropped between the two invocations, the - /// hook is left cleared under [`ReplaceMode::Replace`], or with only the - /// first of its new entries written under [`ReplaceMode::Merge`]. Calling - /// again with the same arguments finishes it. + /// Under [`ReplaceMode::Replace`] the clear and the entries are one + /// invocation, so a drop leaves the hook as it was or fully written, + /// never cleared and unwritten; tmux reports one status for that group, + /// so a refusal there cannot name which command drew it. Under + /// [`ReplaceMode::Merge`] the first entry is sent on its own, so a drop + /// after it leaves that entry written and the rest not. /// /// # Examples /// diff --git a/crates/libtmux/src/session/settings.rs b/crates/libtmux/src/session/settings.rs index 05a0c89b..ba90d827 100644 --- a/crates/libtmux/src/session/settings.rs +++ b/crates/libtmux/src/session/settings.rs @@ -231,10 +231,12 @@ impl Session { /// /// # Cancel safety /// - /// The effect can be partial: dropped between the two invocations, the - /// hook is left cleared under [`ReplaceMode::Replace`], or with only the - /// first of its new entries written under [`ReplaceMode::Merge`]. Calling - /// again with the same arguments finishes it. + /// Under [`ReplaceMode::Replace`] the clear and the entries are one + /// invocation, so a drop leaves the hook as it was or fully written, + /// never cleared and unwritten; tmux reports one status for that group, + /// so a refusal there cannot name which command drew it. Under + /// [`ReplaceMode::Merge`] the first entry is sent on its own, so a drop + /// after it leaves that entry written and the rest not. /// /// # Examples /// diff --git a/crates/libtmux/src/window/settings.rs b/crates/libtmux/src/window/settings.rs index b6b15e3f..d3f5d435 100644 --- a/crates/libtmux/src/window/settings.rs +++ b/crates/libtmux/src/window/settings.rs @@ -214,10 +214,12 @@ impl Window { /// /// # Cancel safety /// - /// The effect can be partial: dropped between the two invocations, the - /// hook is left cleared under [`ReplaceMode::Replace`], or with only the - /// first of its new entries written under [`ReplaceMode::Merge`]. Calling - /// again with the same arguments finishes it. + /// Under [`ReplaceMode::Replace`] the clear and the entries are one + /// invocation, so a drop leaves the hook as it was or fully written, + /// never cleared and unwritten; tmux reports one status for that group, + /// so a refusal there cannot name which command drew it. Under + /// [`ReplaceMode::Merge`] the first entry is sent on its own, so a drop + /// after it leaves that entry written and the rest not. /// /// # Examples /// diff --git a/crates/libtmux/tests/command_budget.rs b/crates/libtmux/tests/command_budget.rs index 82bb0b0f..8c3a7f46 100644 --- a/crates/libtmux/tests/command_budget.rs +++ b/crates/libtmux/tests/command_budget.rs @@ -306,3 +306,48 @@ async fn reading_a_listed_pane_field_costs_no_command() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// A replacing hook write is one invocation, so no drop can land between the +/// clear and the entries and leave the hook empty. +#[tokio::test] +async fn replacing_hooks_clears_and_writes_in_one_command() { + use libtmux::{IndexedHooks, ReplaceMode, TmuxText}; + + let counter = CommandCounter::default(); + let subscriber = tracing_subscriber::registry().with(counter.clone()); + let _guard = tracing::subscriber::set_default(subscriber); + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let session = guard + .server() + .new_session("hooks") + .await + .expect("the session is created"); + let mut entries = std::collections::BTreeMap::new(); + entries.insert(0, TmuxText::from(b"display-message one".to_vec())); + entries.insert(1, TmuxText::from(b"display-message two".to_vec())); + let written = IndexedHooks::from(entries); + // The scope check reads before writing; count only the write. + session + .set_hooks("alert-bell", &written, ReplaceMode::Replace) + .await + .expect("the hooks are written"); + + counter.reset(); + session + .set_hooks("alert-bell", &written, ReplaceMode::Replace) + .await + .expect("the hooks are written"); + + assert_eq!( + counter.commands(), + 1, + "the clear and both entries travel together", + ); + let read = session + .hook("alert-bell") + .await + .expect("the hook reads") + .expect("the hook is set"); + assert_eq!(read.len(), 2); +} From e6e2c477f1d7082d2a09eaf75ae88d8929edd6a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:20:19 -0500 Subject: [PATCH 090/117] tmux-mcp(fix[hints]): Derive annotations from rows why: Every tool sent readOnlyHint false, destructiveHint true, idempotentHint false and openWorldHint true -- the spec's assumption for a tool with no annotations -- while the README tells clients to decide approval from them. list_sessions looked as dangerous as kill_pane. what: - Compute the four hints from the capability row: observe-only with no process reach is read-only; a delete effect or pane input is destructive; reads and declared rows are idempotent; process reach or terminal content is open-world - Replace the CONSERVATIVE constant with an idempotent fact on the row, declared for teardown and for changes that set a named value - Test that a tool outside inspect, or with any non-observe effect, never claims read-only, and that delete and pane input are destructive - Regenerate TOOLS.md; say how the hints derive in the README wait_for_text stays non-read-only: its row records the client it attaches while waiting, while capture_since's retained observer was reclassified as observe in 4e07853. The derivation follows the rows rather than special-casing either. --- crates/tmux-mcp/README.md | 8 +++ crates/tmux-mcp/TOOLS.md | 80 +++++++++++++-------------- crates/tmux-mcp/src/manifest.rs | 69 +++++++++++++++++++---- crates/tmux-mcp/src/policy.rs | 47 +++++++++++++++- crates/tmux-mcp/src/tools/contract.rs | 13 +++-- crates/tmux-mcp/src/tools/control.rs | 10 ++-- 6 files changed, 164 insertions(+), 63 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 2397b198..276f2bd2 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -384,6 +384,14 @@ Clients can use each tool's four MCP annotations to decide whether to ask a person before a whole call. Tool selection shapes the advertised interface; it does not reduce the tmux user's authority. +The annotations derive from each tool's capability row. A tool that only +observes tmux is `readOnlyHint: true`. Deleting tmux state or sending pane +input is `destructiveHint: true`, because the receiving shell runs whatever +arrives. Reads, teardown, and changes that set a named value, such as a +rename, are `idempotentHint: true`. Starting or driving a process, or +returning terminal text, is `openWorldHint: true`. `wait_for_text` is not +read-only: it attaches a client while it waits. + When launched from tmux, the process inherits a pane ID, session number, server PID, and socket. Pane listings mark that pane `caller: "self"` only when the socket matches the selected server. Pane-input and teardown tools additionally diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 997fbd94..9015f19a 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -59,7 +59,7 @@ Call a serial batch of at most sixteen enabled inspect tools. One approval for t - Output classes: `["tmux-metadata","terminal-content","process-environment","configured-command"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `["capture_pane","capture_since","find_pane_by_position","get_pane_info","get_server_info","get_session_info","get_tmux_variables","get_window_info","list_panes","list_sessions","list_windows","search_panes","show_environment","show_hooks","show_option","snapshot_pane"]` - Amplifies future input: `false` @@ -74,7 +74,7 @@ Read a pane's contents. Reads the visible screen by default; set history to reac - Output classes: `["tmux-metadata","terminal-content"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -89,7 +89,7 @@ Read what a pane wrote since the previous call. The first call, with no cursor, - Output classes: `["tmux-metadata","terminal-content"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -104,7 +104,7 @@ Discard a pane's scrollback, so the next capture_pane returns only what happens - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -119,7 +119,7 @@ Create a new detached tmux session Start a pane's configured process; accepts no - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` - Input literalization: `{"name":"double-hash-once","start_directory":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -134,7 +134,7 @@ Create a window running its configured process Start a pane's configured process - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` - Input literalization: `{"name":"double-hash-once","start_directory":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -149,7 +149,7 @@ Find the pane touching a named window corner Inspect tmux metadata; accepts no c - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -164,7 +164,7 @@ Return metadata for one pane Inspect tmux metadata; accepts no client-supplied e - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -179,7 +179,7 @@ Report every session with its windows and panes, in one call. Prefer this over c - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -194,7 +194,7 @@ Return metadata for one session Inspect tmux metadata; accepts no client-supplie - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -209,7 +209,7 @@ Read a bounded set of tmux variables against one pane. tmux reads a name it does - Output classes: `["tmux-metadata","configured-command"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{"names":"validated-variable-name"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -224,7 +224,7 @@ Return metadata for one window Inspect tmux metadata; accepts no client-supplied - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -239,7 +239,7 @@ Kill a pane. Killing a window's last pane closes the window Delete tmux state; a - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -254,7 +254,7 @@ Kill a tmux session and everything in it Delete tmux state; accepts no command p - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -269,7 +269,7 @@ Kill a window, closing it in every session that links it Delete tmux state; acce - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -284,7 +284,7 @@ List every pane on the server Inspect tmux metadata; accepts no client-supplied - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -299,7 +299,7 @@ List every tmux session on the server Inspect tmux metadata; accepts no client-s - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -314,7 +314,7 @@ List every window on the server. A window linked into several sessions appears o - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -329,7 +329,7 @@ Move one window to a session and index Change tmux state; no client-supplied exe - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -359,7 +359,7 @@ Rename one session Change tmux state; no client-supplied executable input. - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{"name":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -374,7 +374,7 @@ Rename one window Change tmux state; no client-supplied executable input. - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{"name":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -389,7 +389,7 @@ Move one edge of a pane by a number of rows or columns Change tmux state; no cli - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -404,7 +404,7 @@ Resize one window to exact cell dimensions Change tmux state; no client-supplied - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -449,7 +449,7 @@ Search what panes are displaying with Rust's linear-time regex engine. Accept at - Output classes: `["tmux-metadata","terminal-content"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -464,7 +464,7 @@ Rearrange a window's panes into a named layout, or into a layout string tmux gav - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -479,7 +479,7 @@ Select a pane, making it its window's active pane. Give a direction to move rela - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -494,7 +494,7 @@ Select a window, making it its session's active window. Give a direction to move - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -539,7 +539,7 @@ Set the scrollback history limit for a session or its global default Change tmux - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -554,7 +554,7 @@ Set mouse handling for a session or the global session default Change tmux state - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -569,7 +569,7 @@ Set one pane's title Change tmux state; no client-supplied executable input. - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{"title":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -584,7 +584,7 @@ Set the window default for synchronized pane input. Individual pane overrides st - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `true` @@ -599,7 +599,7 @@ List the variables tmux hands to processes it starts, for the server or for one - Output classes: `["process-environment"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -614,7 +614,7 @@ List the hooks tmux runs when something happens on the server, such as a pane ex - Output classes: `["configured-command"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -629,7 +629,7 @@ Read a tmux option, such as history-limit or a user option like @theme. Name the - Output classes: `["tmux-metadata","configured-command"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}` - Input literalization: `{"name":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -644,7 +644,7 @@ Signal a tmux wait-for channel, releasing every current waiter. With no waiter, - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -659,7 +659,7 @@ Read pane content with cursor position, mode state, and scroll position in one r - Output classes: `["tmux-metadata","terminal-content"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -674,7 +674,7 @@ Split a window and start the configured process with no command payload Start a - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` - Input literalization: `{"start_directory":"double-hash-once"}` - Nested authority: `[]` - Amplifies future input: `false` @@ -689,7 +689,7 @@ Swap the positions of two panes Change tmux state; no client-supplied executable - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -704,7 +704,7 @@ Block until something signals a tmux wait-for channel. A pending signal is consu - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` @@ -719,7 +719,7 @@ Wait until a pane writes matching text. Reads the pane's live output stream, so - Output classes: `["tmux-metadata","terminal-content"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` diff --git a/crates/tmux-mcp/src/manifest.rs b/crates/tmux-mcp/src/manifest.rs index e1a1d686..d721a2e7 100644 --- a/crates/tmux-mcp/src/manifest.rs +++ b/crates/tmux-mcp/src/manifest.rs @@ -112,13 +112,6 @@ pub(crate) struct Annotations { } impl Annotations { - pub(crate) const CONSERVATIVE: Self = Self { - read_only_hint: false, - destructive_hint: true, - idempotent_hint: false, - open_world_hint: true, - }; - fn render(self, title: Option) -> ToolAnnotations { ToolAnnotations::from_raw( title, @@ -143,7 +136,11 @@ pub(crate) struct Capability { pub(crate) output_classes: BTreeSet, pub(crate) may_expose_secrets: bool, pub(crate) may_return_untrusted_content: bool, - pub(crate) annotations: Annotations, + /// Whether repeating a call with the same arguments changes nothing more. + /// + /// Declared only for a tool that changes tmux: a read-only tool is + /// idempotent by derivation. + pub(crate) idempotent: bool, pub(crate) input_sinks: BTreeMap>, pub(crate) input_literalization: BTreeMap, pub(crate) nested_authority: BTreeSet, @@ -178,7 +175,7 @@ impl From<&Capability> for PublishedCapability { output_classes: definition.output_classes.clone(), may_expose_secrets: definition.may_expose_secrets, may_return_untrusted_content: definition.may_return_untrusted_content, - annotations: definition.annotations, + annotations: definition.annotations(), input_literalization: definition.input_literalization.clone(), nested_authority: definition.nested_authority.clone(), amplifies_future_input: definition.amplifies_future_input, @@ -190,6 +187,30 @@ impl Capability { pub(crate) fn controlled_opener(&self) -> &'static str { controlled_opener(self.toolset, self.process_reach, &self.output_classes) } + + /// The four MCP hints, derived from this row so they cannot drift from it. + /// + /// Pane input is destructive because the receiving shell runs whatever + /// arrives. The open-world hint follows process reach and terminal + /// content, not `may_return_untrusted_content`, which every row sets. + pub(crate) fn annotations(&self) -> Annotations { + let read_only = self.process_reach == ProcessReach::None + && self + .tmux_effects + .iter() + .all(|effect| *effect == TmuxEffect::Observe); + let drives_a_shell = matches!( + self.process_reach, + ProcessReach::PaneInput | ProcessReach::PaneCommand + ); + Annotations { + read_only_hint: read_only, + destructive_hint: self.tmux_effects.contains(&TmuxEffect::Delete) || drives_a_shell, + idempotent_hint: read_only || self.idempotent, + open_world_hint: self.process_reach != ProcessReach::None + || self.output_classes.contains(&OutputClass::TerminalContent), + } + } } fn controlled_opener( @@ -474,7 +495,7 @@ fn finish_route( } .into(), ); - route.attr.annotations = Some(row.annotations.render(route.attr.title.clone())); + route.attr.annotations = Some(row.annotations().render(route.attr.title.clone())); if row .input_sinks .values() @@ -779,6 +800,29 @@ fn validate_authority( /// Attach one typed capability row to an SDK-native tool route. #[macro_export] macro_rules! capability_meta { + (@idempotent) => { false }; + (@idempotent $idempotent:expr) => { $idempotent }; + ( + $toolset:ident, $reach:ident, [$($effect:ident),+ $(,)?], + [$($output:ident),* $(,)?], $secrets:expr, $untrusted:expr, + {$($input:literal => [$($sink:ident),+ $(,)?]),* $(,)?}; + idempotent + ) => { + $crate::capability_meta!( + $toolset, $reach, + effects = [$($effect),+], + outputs = [$($output),*], + secrets = $secrets, + untrusted = $untrusted, + sinks = {$($input => [$($sink),+]),*}, + literalized = [], + format_validated = [], + nested = [], + idempotent = true, + self_bounded = false, + always_load = false, + ) + }; ( $toolset:ident, $reach:ident, [$($effect:ident),+ $(,)?], [$($output:ident),* $(,)?], $secrets:expr, $untrusted:expr, @@ -828,6 +872,7 @@ macro_rules! capability_meta { literalized = [$($literalized:literal),* $(,)?], $(format_validated = [$($validated:literal),* $(,)?],)? nested = [$($nested:literal),* $(,)?], + $(idempotent = $idempotent:expr,)? self_bounded = $self_bounded:expr, always_load = $always_load:expr $(,)? ) => { @@ -842,6 +887,7 @@ macro_rules! capability_meta { format_validated = [$($($validated),*)?], nested = [$($nested),*], amplifies_future_input = false, + $(idempotent = $idempotent,)? self_bounded = $self_bounded, always_load = $always_load, ) @@ -857,6 +903,7 @@ macro_rules! capability_meta { $(format_validated = [$($validated:literal),* $(,)?],)? nested = [$($nested:literal),* $(,)?], amplifies_future_input = $amplifies:expr, + $(idempotent = $idempotent:expr,)? self_bounded = $self_bounded:expr, always_load = $always_load:expr $(,)? ) => {{ @@ -872,7 +919,7 @@ macro_rules! capability_meta { .collect(), may_expose_secrets: $secrets, may_return_untrusted_content: $untrusted, - annotations: $crate::manifest::Annotations::CONSERVATIVE, + idempotent: $crate::capability_meta!(@idempotent $($idempotent)?), input_sinks: [$( ( $input.to_owned(), diff --git a/crates/tmux-mcp/src/policy.rs b/crates/tmux-mcp/src/policy.rs index 4cf0373e..bb08634e 100644 --- a/crates/tmux-mcp/src/policy.rs +++ b/crates/tmux-mcp/src/policy.rs @@ -567,7 +567,7 @@ impl TmuxTools { #[cfg(test)] mod tests { use super::{Selection, Toolset}; - use crate::manifest::OutputClass; + use crate::manifest::{OutputClass, ProcessReach, TmuxEffect}; use std::collections::BTreeSet; #[test] @@ -649,6 +649,51 @@ mod tests { ); } + /// Clients auto-approve or prompt from these hints, so a tool that + /// changes tmux must never claim to be read-only. + #[test] + fn annotations_follow_each_tools_capability_row() { + let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None) + .expect("selection"); + let resolved = crate::manifest::resolve(crate::tools::router(), &selection) + .expect("complete manifest"); + let mut distinct = BTreeSet::new(); + + for tool in resolved.router.list_all() { + let row = &resolved + .report + .tools + .iter() + .find(|row| row.name == tool.name) + .expect("report row") + .capability; + let hints = tool.annotations.as_ref().expect("annotations"); + let read_only = hints.read_only_hint.expect("readOnlyHint"); + let destructive = hints.destructive_hint.expect("destructiveHint"); + let changes_tmux = row.toolset != Toolset::Inspect + || row.process_reach != ProcessReach::None + || row + .tmux_effects + .iter() + .any(|effect| *effect != TmuxEffect::Observe); + let can_destroy = row.tmux_effects.contains(&TmuxEffect::Delete) + || matches!( + row.process_reach, + ProcessReach::PaneInput | ProcessReach::PaneCommand + ); + + assert_eq!(read_only, !changes_tmux, "{} readOnlyHint", tool.name); + assert_eq!(destructive, can_destroy, "{} destructiveHint", tool.name); + distinct.insert(( + read_only, + destructive, + hints.idempotent_hint.expect("idempotentHint"), + hints.open_world_hint.expect("openWorldHint"), + )); + } + assert!(distinct.len() > 3, "hints barely vary: {distinct:?}"); + } + #[test] fn configured_value_reads_disclose_configured_command_output() { let selection = Selection::parse(Some("inspect"), None, None).expect("selection"); diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index a2257098..40d0d462 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -671,7 +671,7 @@ impl TmuxTools { Manage, None, effects = [Change], outputs = [TmuxMetadata], secrets = true, untrusted = true, sinks = {"session" => [TmuxLookup], "name" => [TmuxState, TmuxFormat]}, - literalized = ["name"], nested = [], self_bounded = false, + literalized = ["name"], nested = [], idempotent = true, self_bounded = false, always_load = false, ) )] @@ -703,7 +703,7 @@ impl TmuxTools { Manage, None, effects = [Change], outputs = [TmuxMetadata], secrets = true, untrusted = true, sinks = {"window" => [TmuxLookup], "name" => [TmuxState, TmuxFormat]}, - literalized = ["name"], nested = [], self_bounded = false, + literalized = ["name"], nested = [], idempotent = true, self_bounded = false, always_load = false, ) )] @@ -724,7 +724,7 @@ impl TmuxTools { title = "Resize Window", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "window" => [TmuxLookup], "width" => [TmuxState], "height" => [TmuxState] - }) + }; idempotent) )] pub async fn resize_window( &self, @@ -799,7 +799,7 @@ impl TmuxTools { Manage, None, effects = [Change], outputs = [TmuxMetadata], secrets = true, untrusted = true, sinks = {"pane" => [TmuxLookup], "title" => [TmuxState, TmuxFormat]}, - literalized = ["title"], nested = [], self_bounded = false, + literalized = ["title"], nested = [], idempotent = true, self_bounded = false, always_load = false, ) )] @@ -820,7 +820,7 @@ impl TmuxTools { title = "Set Mouse Enabled", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "session" => [TmuxLookup], "enabled" => [TmuxState] - }) + }; idempotent) )] pub async fn set_mouse_enabled( &self, @@ -851,7 +851,7 @@ impl TmuxTools { title = "Set History Limit", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "session" => [TmuxLookup], "limit" => [TmuxState] - }) + }; idempotent) )] pub async fn set_history_limit( &self, @@ -1006,6 +1006,7 @@ impl TmuxTools { sinks = {"window" => [TmuxLookup], "enabled" => [TmuxState]}, literalized = [], nested = [], amplifies_future_input = true, + idempotent = true, self_bounded = false, always_load = false, ) )] diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index 61d077f6..3e94468a 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -186,7 +186,7 @@ impl TmuxTools { title = "Kill Window", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "window" => [TmuxLookup] - }) + }; idempotent) )] pub async fn kill_window( &self, @@ -206,7 +206,7 @@ impl TmuxTools { title = "Kill Pane", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "pane" => [TmuxLookup] - }) + }; idempotent) )] pub async fn kill_pane( &self, @@ -278,7 +278,7 @@ impl TmuxTools { title = "Kill Session", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "session" => [TmuxLookup] - }) + }; idempotent) )] pub async fn kill_session( &self, @@ -530,7 +530,7 @@ impl TmuxTools { meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "window" => [TmuxLookup], "layout" => [TmuxState] - }) + }; idempotent) )] pub async fn select_layout( &self, @@ -563,7 +563,7 @@ impl TmuxTools { title = "Clear Pane History", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "pane" => [TmuxLookup] - }) + }; idempotent) )] pub async fn clear_pane( &self, From bfa0372ada3f41eaf13eecc1655d6fcd856cd76f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:27:42 -0500 Subject: [PATCH 091/117] tmux-mcp(fix[schema]): Describe every input property why: 32 of 93 top-level input properties carried no description, among them every parameter of split_window, swap_pane and respawn_pane, and 22 tool summaries had no closing period, so the generated safety sentence ran into them: "List every tmux session on the server Inspect tmux metadata; ...". A round-4 spike once recorded full coverage; nothing gated it, so it regressed. what: - Document each bare field, the nested send_keys_batch row fields, and each call_read_tools_batch row's arguments; optional fields state their default - Make respawn_pane's kill_first optional, defaulting to false (respawn only a dead pane), as the handler already treats false - End every tool's own description with a period - Test that every input property, nested ones included, has a description, and that every raw tool description ends its sentence - Regenerate TOOLS.md move_window's destination_index stays required: making it optional needs a handler change, outside this metadata-only pass. --- crates/tmux-mcp/TOOLS.md | 44 ++++++++-------- crates/tmux-mcp/src/manifest.rs | 5 +- crates/tmux-mcp/src/policy.rs | 22 ++++++++ crates/tmux-mcp/src/tools/contract.rs | 72 +++++++++++++++++++++------ crates/tmux-mcp/src/tools/control.rs | 10 ++-- crates/tmux-mcp/src/tools/inspect.rs | 4 +- crates/tmux-mcp/tests/schema.rs | 49 ++++++++++++++++++ 7 files changed, 161 insertions(+), 45 deletions(-) diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 9015f19a..81bfa5ab 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -111,7 +111,7 @@ Discard a pane's scrollback, so the next capture_pane returns only what happens ## `create_session` -Create a new detached tmux session Start a pane's configured process; accepts no command payload. +Create a new detached tmux session. Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -126,7 +126,7 @@ Create a new detached tmux session Start a pane's configured process; accepts no ## `create_window` -Create a window running its configured process Start a pane's configured process; accepts no command payload. +Create a window running its configured process. Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -141,7 +141,7 @@ Create a window running its configured process Start a pane's configured process ## `find_pane_by_position` -Find the pane touching a named window corner Inspect tmux metadata; accepts no client-supplied executable input. +Find the pane touching a named window corner. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -156,7 +156,7 @@ Find the pane touching a named window corner Inspect tmux metadata; accepts no c ## `get_pane_info` -Return metadata for one pane Inspect tmux metadata; accepts no client-supplied executable input. +Return metadata for one pane. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -186,7 +186,7 @@ Report every session with its windows and panes, in one call. Prefer this over c ## `get_session_info` -Return metadata for one session Inspect tmux metadata; accepts no client-supplied executable input. +Return metadata for one session. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -216,7 +216,7 @@ Read a bounded set of tmux variables against one pane. tmux reads a name it does ## `get_window_info` -Return metadata for one window Inspect tmux metadata; accepts no client-supplied executable input. +Return metadata for one window. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -231,7 +231,7 @@ Return metadata for one window Inspect tmux metadata; accepts no client-supplied ## `kill_pane` -Kill a pane. Killing a window's last pane closes the window Delete tmux state; accepts no command payload. +Kill a pane. Killing a window's last pane closes the window. Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -246,7 +246,7 @@ Kill a pane. Killing a window's last pane closes the window Delete tmux state; a ## `kill_session` -Kill a tmux session and everything in it Delete tmux state; accepts no command payload. +Kill a tmux session and everything in it. Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -261,7 +261,7 @@ Kill a tmux session and everything in it Delete tmux state; accepts no command p ## `kill_window` -Kill a window, closing it in every session that links it Delete tmux state; accepts no command payload. +Kill a window, closing it in every session that links it. Delete tmux state; accepts no command payload. - Toolset: `teardown` - Process reach: `none` @@ -276,7 +276,7 @@ Kill a window, closing it in every session that links it Delete tmux state; acce ## `list_panes` -List every pane on the server Inspect tmux metadata; accepts no client-supplied executable input. +List every pane on the server. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -291,7 +291,7 @@ List every pane on the server Inspect tmux metadata; accepts no client-supplied ## `list_sessions` -List every tmux session on the server Inspect tmux metadata; accepts no client-supplied executable input. +List every tmux session on the server. Inspect tmux metadata; accepts no client-supplied executable input. - Toolset: `inspect` - Process reach: `none` @@ -321,7 +321,7 @@ List every window on the server. A window linked into several sessions appears o ## `move_window` -Move one window to a session and index Change tmux state; no client-supplied executable input. +Move one window to a session and index. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -351,7 +351,7 @@ Put text into a pane through a tmux paste buffer instead of typing it key by key ## `rename_session` -Rename one session Change tmux state; no client-supplied executable input. +Rename one session. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -366,7 +366,7 @@ Rename one session Change tmux state; no client-supplied executable input. ## `rename_window` -Rename one window Change tmux state; no client-supplied executable input. +Rename one window. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -381,7 +381,7 @@ Rename one window Change tmux state; no client-supplied executable input. ## `resize_pane` -Move one edge of a pane by a number of rows or columns Change tmux state; no client-supplied executable input. +Move one edge of a pane by a number of rows or columns. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -396,7 +396,7 @@ Move one edge of a pane by a number of rows or columns Change tmux state; no cli ## `resize_window` -Resize one window to exact cell dimensions Change tmux state; no client-supplied executable input. +Resize one window to exact cell dimensions. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -411,7 +411,7 @@ Resize one window to exact cell dimensions Change tmux state; no client-supplied ## `respawn_pane` -Restart a pane's configured process with no command payload Start a pane's configured process; accepts no command payload. +Restart a pane's configured process with no command payload. Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -531,7 +531,7 @@ Send an ordered batch of input operations to panes. Each executed row repeats se ## `set_history_limit` -Set the scrollback history limit for a session or its global default Change tmux state; no client-supplied executable input. +Set the scrollback history limit for a session or its global default. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -546,7 +546,7 @@ Set the scrollback history limit for a session or its global default Change tmux ## `set_mouse_enabled` -Set mouse handling for a session or the global session default Change tmux state; no client-supplied executable input. +Set mouse handling for a session or the global session default. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -561,7 +561,7 @@ Set mouse handling for a session or the global session default Change tmux state ## `set_pane_title` -Set one pane's title Change tmux state; no client-supplied executable input. +Set one pane's title. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` @@ -666,7 +666,7 @@ Read pane content with cursor position, mode state, and scroll position in one r ## `split_window` -Split a window and start the configured process with no command payload Start a pane's configured process; accepts no command payload. +Split a window and start the configured process with no command payload. Start a pane's configured process; accepts no command payload. - Toolset: `execute` - Process reach: `configured-process` @@ -681,7 +681,7 @@ Split a window and start the configured process with no command payload Start a ## `swap_pane` -Swap the positions of two panes Change tmux state; no client-supplied executable input. +Swap the positions of two panes. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` diff --git a/crates/tmux-mcp/src/manifest.rs b/crates/tmux-mcp/src/manifest.rs index d721a2e7..a2cde1be 100644 --- a/crates/tmux-mcp/src/manifest.rs +++ b/crates/tmux-mcp/src/manifest.rs @@ -604,9 +604,12 @@ fn set_nested_operation_schemas( let route = nested.map.get(nested_name.as_str()).ok_or_else(|| { SurfaceError::new(format!("unknown nested tool {nested_name:?}")) })?; - let input = inline_local_references(serde_json::Value::Object( + let mut input = inline_local_references(serde_json::Value::Object( (*route.attr.input_schema).clone(), ))?; + if let Some(input) = input.as_object_mut() { + input.insert("description".to_owned(), "That tool's arguments.".into()); + } Ok(serde_json::json!({ "type": "object", "properties": { diff --git a/crates/tmux-mcp/src/policy.rs b/crates/tmux-mcp/src/policy.rs index bb08634e..2f4a689e 100644 --- a/crates/tmux-mcp/src/policy.rs +++ b/crates/tmux-mcp/src/policy.rs @@ -649,6 +649,28 @@ mod tests { ); } + /// The generated safety sentence follows a tool's own text, so a summary + /// with no closing period ran into it: "List every tmux session on the + /// server Inspect tmux metadata; ...". + #[test] + fn every_tools_own_description_ends_its_sentence() { + let unfinished: Vec<_> = crate::tools::router() + .list_all() + .into_iter() + .filter(|tool| { + !tool + .description + .as_deref() + .unwrap_or_default() + .trim_end() + .ends_with(['.', '!', '?']) + }) + .map(|tool| tool.name.into_owned()) + .collect(); + + assert!(unfinished.is_empty(), "no closing period: {unfinished:?}"); + } + /// Clients auto-approve or prompt from these hints, so a tool that /// changes tmux must never claim to be read-only. #[test] diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 40d0d462..9a01001d 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -30,62 +30,79 @@ const READ_BATCH_TRUNCATED_ERROR: &str = pub(crate) struct RenameSessionArgs { /// The session to rename, by `$`-prefixed id or name. pub(crate) session: String, + /// The new name. tmux refuses one another session has. pub(crate) name: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RenameWindowArgs { + /// The `@`-prefixed window id. pub(crate) window: String, + /// The new name. tmux then stops renaming the window automatically. pub(crate) name: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct WindowSizeArgs { + /// The `@`-prefixed window id. pub(crate) window: String, + /// Width in columns. pub(crate) width: u32, + /// Height in rows. pub(crate) height: u32, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct MoveWindowArgs { + /// The `@`-prefixed window id to move. pub(crate) window: String, /// The session to move the window into, by `$`-prefixed id or name. pub(crate) destination_session: String, + /// The window index there. tmux refuses one already in use. pub(crate) destination_index: i32, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SwapPaneArgs { + /// The `%`-prefixed pane id to move; the result describes it. pub(crate) source_pane: String, + /// The `%`-prefixed pane id it trades places with. pub(crate) target_pane: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct PaneTitleArgs { + /// The `%`-prefixed pane id. pub(crate) pane: String, + /// The new title, stored as given. pub(crate) title: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct PositionArgs { + /// The `@`-prefixed window id. pub(crate) window: String, + /// `top-left`, `top-right`, `bottom-left`, or `bottom-right`. pub(crate) corner: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct VariablesArgs { + /// tmux format variable names, such as `pane_current_path`. #[schemars( length(min = 1, max = 32), inner(regex(pattern = "^[A-Za-z][A-Za-z0-9_]*$")) )] pub names: Vec, + /// The `%`-prefixed pane to read them against. Omit to let tmux pick + /// its current pane. pub pane: Option, } @@ -100,6 +117,7 @@ pub(crate) struct SessionFlagArgs { /// The session to change, by `$`-prefixed id or name. Omit to change the /// global default. pub(crate) session: Option, + /// `true` turns mouse handling on; `false` turns it off. pub(crate) enabled: bool, } @@ -109,13 +127,16 @@ pub(crate) struct HistoryLimitArgs { /// The session to change, by `$`-prefixed id or name. Omit to change the /// global default. pub(crate) session: Option, + /// Lines of scrollback to keep per pane. pub(crate) limit: u32, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct WindowFlagArgs { + /// The `@`-prefixed window id. pub(crate) window: String, + /// `true` turns synchronized input on; `false` turns it off. pub(crate) enabled: bool, } @@ -131,40 +152,58 @@ pub(crate) struct SettingChanged { pub(crate) struct CreateWindowArgs { /// The session to create the window in, by `$`-prefixed id or name. pub(crate) session: String, + /// The window name. Omit to let tmux name it after its running command. pub(crate) name: Option, + /// The window's working directory. Omit for tmux's default. pub(crate) start_directory: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SplitWindowArgs { + /// The `%`-prefixed pane to split. pub(crate) pane: String, + /// Where the new pane goes relative to `pane`. Defaults to `below`. #[schemars(with = "Option")] pub(crate) direction: Option, + /// The new pane's share of the split, 1 to 100. Defaults to half. pub(crate) percent: Option, + /// The new pane's working directory. Omit for tmux's default, which is + /// not `pane`'s current directory. pub(crate) start_directory: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RespawnArgs { + /// The `%`-prefixed pane id. pub(crate) pane: String, + /// Kill a running process first. Defaults to `false`, which respawns + /// only a dead pane. + #[serde(default)] pub(crate) kill_first: bool, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SendOperation { + /// The `%`-prefixed pane id. pub(crate) pane: String, + /// Text typed literally. Key names are not interpreted. pub(crate) text: Option, + /// tmux key names to press, in order, after any text, such as `C-c`. pub(crate) keys: Option>, + /// Whether to press Enter afterwards. pub(crate) enter: bool, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct SendBatchArgs { + /// The rows to send, in order, 1 to 64. pub(crate) operations: Vec, + /// `stop`, the default, ends at the first refused row; `continue` sends + /// the rest. #[serde(default)] pub(crate) on_error: OnError, } @@ -420,8 +459,11 @@ pub(crate) struct ReadOperation { #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct ReadBatchArgs { + /// The inspect calls to make, in order. #[schemars(length(min = 1, max = 16))] pub(crate) operations: Vec, + /// `stop`, the default, ends at the first failed call; `continue` makes + /// the rest. #[serde(default)] pub(crate) on_error: OnError, } @@ -507,7 +549,7 @@ impl TmuxTools { #[tool_router(router = contract_router, vis = "pub(super)")] impl TmuxTools { #[tool( - description = "Return metadata for one session", + description = "Return metadata for one session.", title = "Get Session Info", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, { "session" => [TmuxLookup] @@ -531,7 +573,7 @@ impl TmuxTools { } #[tool( - description = "Return metadata for one window", + description = "Return metadata for one window.", title = "Get Window Info", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, { "window" => [TmuxLookup] @@ -545,7 +587,7 @@ impl TmuxTools { } #[tool( - description = "Return metadata for one pane", + description = "Return metadata for one pane.", title = "Get Pane Info", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, { "pane" => [TmuxLookup] @@ -561,7 +603,7 @@ impl TmuxTools { } #[tool( - description = "Find the pane touching a named window corner", + description = "Find the pane touching a named window corner.", title = "Find Pane By Position", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, { "window" => [TmuxLookup], @@ -665,7 +707,7 @@ impl TmuxTools { } #[tool( - description = "Rename one session", + description = "Rename one session.", title = "Rename Session", meta = crate::capability_meta!( Manage, None, @@ -697,7 +739,7 @@ impl TmuxTools { } #[tool( - description = "Rename one window", + description = "Rename one window.", title = "Rename Window", meta = crate::capability_meta!( Manage, None, @@ -720,7 +762,7 @@ impl TmuxTools { } #[tool( - description = "Resize one window to exact cell dimensions", + description = "Resize one window to exact cell dimensions.", title = "Resize Window", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "window" => [TmuxLookup], "width" => [TmuxState], "height" => [TmuxState] @@ -743,7 +785,7 @@ impl TmuxTools { } #[tool( - description = "Move one window to a session and index", + description = "Move one window to a session and index.", title = "Move Window", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "window" => [TmuxLookup], @@ -769,7 +811,7 @@ impl TmuxTools { } #[tool( - description = "Swap the positions of two panes", + description = "Swap the positions of two panes.", title = "Swap Panes", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "source_pane" => [TmuxLookup], "target_pane" => [TmuxLookup] @@ -793,7 +835,7 @@ impl TmuxTools { } #[tool( - description = "Set one pane's title", + description = "Set one pane's title.", title = "Set Pane Title", meta = crate::capability_meta!( Manage, None, @@ -816,7 +858,7 @@ impl TmuxTools { } #[tool( - description = "Set mouse handling for a session or the global session default", + description = "Set mouse handling for a session or the global session default.", title = "Set Mouse Enabled", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "session" => [TmuxLookup], "enabled" => [TmuxState] @@ -847,7 +889,7 @@ impl TmuxTools { } #[tool( - description = "Set the scrollback history limit for a session or its global default", + description = "Set the scrollback history limit for a session or its global default.", title = "Set History Limit", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "session" => [TmuxLookup], "limit" => [TmuxState] @@ -877,7 +919,7 @@ impl TmuxTools { } #[tool( - description = "Create a window running its configured process", + description = "Create a window running its configured process.", title = "Create Window", meta = crate::capability_meta!( Execute, ConfiguredProcess, @@ -914,7 +956,7 @@ impl TmuxTools { } #[tool( - description = "Split a window and start the configured process with no command payload", + description = "Split a window and start the configured process with no command payload.", title = "Split Window", meta = crate::capability_meta!( Execute, ConfiguredProcess, @@ -971,7 +1013,7 @@ impl TmuxTools { #[tool( name = "respawn_pane", - description = "Restart a pane's configured process with no command payload", + description = "Restart a pane's configured process with no command payload.", title = "Respawn Pane", meta = crate::capability_meta!(Execute, ConfiguredProcess, [Change, Delete], [TmuxMetadata], true, true, { "pane" => [TmuxLookup], "kill_first" => [None] diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index 3e94468a..c4ec3f56 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -182,7 +182,7 @@ impl TmuxTools { impl TmuxTools { /// Kill one window. #[tool( - description = "Kill a window, closing it in every session that links it", + description = "Kill a window, closing it in every session that links it.", title = "Kill Window", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "window" => [TmuxLookup] @@ -202,7 +202,7 @@ impl TmuxTools { /// Kill one pane. #[tool( - description = "Kill a pane. Killing a window's last pane closes the window", + description = "Kill a pane. Killing a window's last pane closes the window.", title = "Kill Pane", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "pane" => [TmuxLookup] @@ -224,7 +224,7 @@ impl TmuxTools { /// Create a detached session. #[tool( - description = "Create a new detached tmux session", + description = "Create a new detached tmux session.", title = "Create Session", meta = crate::capability_meta!( Execute, ConfiguredProcess, @@ -274,7 +274,7 @@ impl TmuxTools { /// Kill a session and everything in it. #[tool( - description = "Kill a tmux session and everything in it", + description = "Kill a tmux session and everything in it.", title = "Kill Session", meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, { "session" => [TmuxLookup] @@ -294,7 +294,7 @@ impl TmuxTools { /// Move one edge of a pane. #[tool( - description = "Move one edge of a pane by a number of rows or columns", + description = "Move one edge of a pane by a number of rows or columns.", title = "Resize Pane", meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { "pane" => [TmuxLookup], diff --git a/crates/tmux-mcp/src/tools/inspect.rs b/crates/tmux-mcp/src/tools/inspect.rs index 83e669ad..1b08957a 100644 --- a/crates/tmux-mcp/src/tools/inspect.rs +++ b/crates/tmux-mcp/src/tools/inspect.rs @@ -108,7 +108,7 @@ impl SearchBudget { impl TmuxTools { /// List every session on the server. #[tool( - description = "List every tmux session on the server", + description = "List every tmux session on the server.", title = "List Sessions", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}) )] @@ -135,7 +135,7 @@ impl TmuxTools { /// List every pane on the server. #[tool( - description = "List every pane on the server", + description = "List every pane on the server.", title = "List Panes", meta = crate::capability_meta!(Inspect, None, [Observe], [TmuxMetadata], true, true, {}; always_load) )] diff --git a/crates/tmux-mcp/tests/schema.rs b/crates/tmux-mcp/tests/schema.rs index 5bfdc95f..4129919d 100644 --- a/crates/tmux-mcp/tests/schema.rs +++ b/crates/tmux-mcp/tests/schema.rs @@ -120,6 +120,55 @@ fn every_advertised_schema_is_valid_and_closed() -> TestResult { Ok(()) } +/// Collect every property below `schema` that has no description. +/// +/// A `const` property is exempt: its one legal value says all there is. +fn undocumented(schema: &serde_json::Value, path: &str, missing: &mut Vec) { + match schema { + serde_json::Value::Object(object) => { + if let Some(properties) = object + .get("properties") + .and_then(serde_json::Value::as_object) + { + for (name, property) in properties { + let described = property + .get("description") + .and_then(serde_json::Value::as_str) + .is_some_and(|text| !text.trim().is_empty()); + if !described && property.get("const").is_none() { + missing.push(format!("{path}.{name}")); + } + } + } + for (key, value) in object { + undocumented(value, &format!("{path}/{key}"), missing); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + undocumented(item, &format!("{path}/{index}"), missing); + } + } + _ => {} + } +} + +#[test] +fn every_input_property_carries_a_description() -> TestResult { + let mut missing = Vec::new(); + for tool in tools("inspect,manage,execute,teardown")?.offered() { + let input = serde_json::Value::Object((*tool.input_schema).clone()); + undocumented(&input, &tool.name, &mut missing); + } + + assert!( + missing.is_empty(), + "{} undocumented input properties: {missing:#?}", + missing.len() + ); + Ok(()) +} + #[test] fn configured_process_routes_have_no_executable_payload() -> TestResult { let tools = tools("execute")?; From b2bc9d55618efb5e56f0abdddd3f869b4b0af64a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:30:53 -0500 Subject: [PATCH 092/117] tmux-mcp(fix[hints]): Mark set_history_limit destructive why: tmux 3.7 applies a changed history-limit to existing panes, and a lower one discards their scrollback past it. On 3.7d a pane went from 1,977 retained lines to 100. The row declared only a change, so the derived annotations called the tool non-destructive, and its description said nothing about the loss. what: - Declare a delete effect on set_history_limit's capability row - Say in its description what a lowered limit does from tmux 3.7 - Pin the tool as destructive in the annotation test - Regenerate TOOLS.md; name it in the README's hint summary The tool stays in the manage toolset, although it can now discard scrollback that clear_pane_scrollback needs teardown for. Moving it changes the default surface, which is the owner's call. --- crates/tmux-mcp/README.md | 4 +++- crates/tmux-mcp/TOOLS.md | 6 +++--- crates/tmux-mcp/src/policy.rs | 7 +++++++ crates/tmux-mcp/src/tools/contract.rs | 6 ++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 276f2bd2..10604832 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -390,7 +390,9 @@ input is `destructiveHint: true`, because the receiving shell runs whatever arrives. Reads, teardown, and changes that set a named value, such as a rename, are `idempotentHint: true`. Starting or driving a process, or returning terminal text, is `openWorldHint: true`. `wait_for_text` is not -read-only: it attaches a client while it waits. +read-only: it attaches a client while it waits. `set_history_limit` is +destructive: from tmux 3.7 a lower limit discards existing panes' scrollback +past it. When launched from tmux, the process inherits a pane ID, session number, server PID, and socket. Pane listings mark that pane `caller: "self"` only when the diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 81bfa5ab..56f46a71 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -531,15 +531,15 @@ Send an ordered batch of input operations to panes. Each executed row repeats se ## `set_history_limit` -Set the scrollback history limit for a session or its global default. Change tmux state; no client-supplied executable input. +Set the scrollback history limit for a session or its global default. From tmux 3.7 existing panes take it too, and lowering it discards their scrollback past the new limit. Change tmux state; no client-supplied executable input. - Toolset: `manage` - Process reach: `none` -- Tmux effects: `["change"]` +- Tmux effects: `["change","delete"]` - Output classes: `["tmux-metadata"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` diff --git a/crates/tmux-mcp/src/policy.rs b/crates/tmux-mcp/src/policy.rs index 2f4a689e..da2e2e8d 100644 --- a/crates/tmux-mcp/src/policy.rs +++ b/crates/tmux-mcp/src/policy.rs @@ -680,6 +680,7 @@ mod tests { let resolved = crate::manifest::resolve(crate::tools::router(), &selection) .expect("complete manifest"); let mut distinct = BTreeSet::new(); + let mut destructive_tools = BTreeSet::new(); for tool in resolved.router.list_all() { let row = &resolved @@ -706,6 +707,9 @@ mod tests { assert_eq!(read_only, !changes_tmux, "{} readOnlyHint", tool.name); assert_eq!(destructive, can_destroy, "{} destructiveHint", tool.name); + if destructive { + destructive_tools.insert(tool.name.to_string()); + } distinct.insert(( read_only, destructive, @@ -714,6 +718,9 @@ mod tests { )); } assert!(distinct.len() > 3, "hints barely vary: {distinct:?}"); + // tmux 3.7 applies a lowered history limit to existing panes, + // discarding their scrollback. + assert!(destructive_tools.contains("set_history_limit")); } #[test] diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 9a01001d..918363f1 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -889,9 +889,11 @@ impl TmuxTools { } #[tool( - description = "Set the scrollback history limit for a session or its global default.", + description = "Set the scrollback history limit for a session or its global default. \ + From tmux 3.7 existing panes take it too, and lowering it discards \ + their scrollback past the new limit.", title = "Set History Limit", - meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, { + meta = crate::capability_meta!(Manage, None, [Change, Delete], [TmuxMetadata], true, true, { "session" => [TmuxLookup], "limit" => [TmuxState] }; idempotent) )] From db6fa54e7a9ce39c161a56cba8cb5cc727240d5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:34:10 -0500 Subject: [PATCH 093/117] tmux-mcp(docs[streams]): Say streamed text is not the screen why: wait_for_text, capture_since and run_shell_command return the pane's output stream with escapes removed. A line editor's redraws and a progress bar's rewrites repeat in it, so one typed command came back as "printf 'x\033]y'printf 'x\033]y'" while capture_pane showed a clean line, and nothing an agent read said why. what: - Say so in the three tool descriptions and on the text and output fields of their output schemas, pointing to capture_pane for the screen - Test that all six places carry it - Regenerate TOOLS.md; add the caveat beside the README's usage notes --- crates/tmux-mcp/README.md | 5 +++++ crates/tmux-mcp/TOOLS.md | 6 +++--- crates/tmux-mcp/src/exec.rs | 8 +++++++ crates/tmux-mcp/src/tools/observe.rs | 14 +++++++++---- crates/tmux-mcp/src/views.rs | 4 ++++ crates/tmux-mcp/tests/schema.rs | 31 ++++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 10604832..59f00b10 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -543,6 +543,11 @@ line that scrolls past between looks is still seen. new since the cursor it gave you last time, and says `missed: true` if anything was dropped. +`run_shell_command`, `wait_for_text`, and `capture_since` return the output +stream with escape sequences removed, not the rendered screen. A line redrawn in place -- a line editor's +echo, a progress bar -- repeats once per redraw. `capture_pane` shows what the +screen displays. + **Finding where something is.** `search_panes` matches across every pane at once and reports the pane and line. The listing tools will not: they read names and commands, not what a terminal is showing. diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 56f46a71..7646708b 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -81,7 +81,7 @@ Read a pane's contents. Reads the visible screen by default; set history to reac ## `capture_since` -Read what a pane wrote since the previous call. The first call, with no cursor, starts watching and returns a cursor; later calls pass it back and receive only what is new. Use this to follow a pane over several turns without re-reading the whole screen. The answer says missed=true if the cursor no longer names retained output, including when the pane outran the buffer, its live tail was evicted, or the server restarted. Starting a tail owns a retained observer until the tail is evicted or the server stops. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. +Read what a pane wrote since the previous call. The first call, with no cursor, starts watching and returns a cursor; later calls pass it back and receive only what is new, as the raw output stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Use this to follow a pane over several turns without re-reading the whole screen. The answer says missed=true if the cursor no longer names retained output, including when the pane outran the buffer, its live tail was evicted, or the server restarted. Starting a tail owns a retained observer until the tail is evicted or the server stops. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` @@ -426,7 +426,7 @@ Restart a pane's configured process with no command payload. Start a pane's conf ## `run_shell_command` -Run a shell command in a pane, wait for it to finish, and report its exit status with everything it wrote. This is the tool for "run this and tell me if it worked". Output is read from the pane's live stream, so nothing is missed and the shell prompt is not included. The command runs in a subshell, so cd and export do not persist and invalid syntax completes with a nonzero status. Valid inherited Bash and zsh ERR and DEBUG traps remain visible to the command while parent-shell traps and options remain unchanged. It requires one configured input recipient and observes its mode, liveness, input-off state, attended-client state, cohort, inherited-caller relation, known POSIX shell, and resolved route before watcher setup and again before dispatch. A process-wide endpoint-and-pane reservation blocks other MCP pane input until the completion marker or pane closure is proved. The resolved tmux executable and socket path must contain no ASCII terminal-control bytes. The reservation serializes this MCP's input, but tmux observations can still race with dispatch. The pane shell, tmux server, and configuration must be trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops this request while its watcher keeps the reservation until completion is proved. To stop the command, send_keys with keys ["C-c"] alone passes the reservation, and the command reports completion when it ends; respawn_pane with kill_first replaces a program that ignores C-c and C-\. Run a shell command in a pane with your user's permissions. +Run a shell command in a pane, wait for it to finish, and report its exit status with everything it wrote. This is the tool for "run this and tell me if it worked". Output is the pane's raw output stream, not the rendered screen: nothing is missed, the shell prompt is not included, and a line redrawn in place repeats; capture_pane shows the screen. The command runs in a subshell, so cd and export do not persist and invalid syntax completes with a nonzero status. Valid inherited Bash and zsh ERR and DEBUG traps remain visible to the command while parent-shell traps and options remain unchanged. It requires one configured input recipient and observes its mode, liveness, input-off state, attended-client state, cohort, inherited-caller relation, known POSIX shell, and resolved route before watcher setup and again before dispatch. A process-wide endpoint-and-pane reservation blocks other MCP pane input until the completion marker or pane closure is proved. The resolved tmux executable and socket path must contain no ASCII terminal-control bytes. The reservation serializes this MCP's input, but tmux observations can still race with dispatch. The pane shell, tmux server, and configuration must be trusted. Reaching the deadline, cancelling, or an uncertain dispatch stops this request while its watcher keeps the reservation until completion is proved. To stop the command, send_keys with keys ["C-c"] alone passes the reservation, and the command reports completion when it ends; respawn_pane with kill_first replaces a program that ignores C-c and C-\. Run a shell command in a pane with your user's permissions. - Toolset: `execute` - Process reach: `pane-command` @@ -711,7 +711,7 @@ Block until something signals a tmux wait-for channel. A pending signal is consu ## `wait_for_text` -Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. +Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index ba38e9b4..bd68bc66 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -121,6 +121,10 @@ pub struct RunView { pub exit_status: Option, /// Everything the command wrote, stdout and stderr interleaved in the /// order the program wrote them. + /// + /// This is the raw output stream with escape sequences removed, not the + /// rendered screen: a line redrawn in place, such as a progress bar, + /// repeats. `capture_pane` shows the screen. pub output: String, /// How many bytes that was, before any truncation. pub bytes: usize, @@ -140,6 +144,10 @@ pub struct WaitView { /// The pattern that matched, as it was given. pub matched_pattern: Option, /// What the pane wrote, with escape sequences removed. + /// + /// This is the raw output stream, not the rendered screen: a line redrawn + /// in place, such as a line editor's echo, repeats. `capture_pane` shows + /// the screen. pub text: String, /// How many bytes arrived, before filtering or truncation. pub bytes: usize, diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index ea67840d..801e877b 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -209,8 +209,10 @@ impl TmuxTools { name = "run_shell_command", description = "Run a shell command in a pane, wait for it to finish, and report its \ exit status with everything it wrote. This is the tool for \"run this \ - and tell me if it worked\". Output is read from the pane's live stream, \ - so nothing is missed and the shell prompt is not included. The command \ + and tell me if it worked\". Output is the pane's raw output stream, not \ + the rendered screen: nothing is missed, the shell prompt is not \ + included, and a line redrawn in place repeats; capture_pane shows the \ + screen. The command \ runs in a subshell, so cd and export do not persist and invalid syntax \ completes with a nonzero status. Valid inherited Bash and zsh ERR and \ DEBUG traps remain visible to the command while parent-shell traps and \ @@ -340,7 +342,9 @@ impl TmuxTools { /// Wait until a pane writes something a caller is looking for. #[tool( description = "Wait until a pane writes matching text. Reads the pane's live output \ - stream, so text that scrolls past between checks is still seen. Prefer \ + stream, so text that scrolls past between checks is still seen. The \ + returned text is that raw stream, not the rendered screen: a line \ + redrawn in place repeats; capture_pane shows the screen. Prefer \ run_shell_command for commands you are sending yourself: it reports an exit \ status instead of guessing from output. Use this for output you did \ not author, such as a server logging that it is ready. A pattern that is a \ @@ -408,7 +412,9 @@ impl TmuxTools { #[tool( description = "Read what a pane wrote since the previous call. The first call, with no \ cursor, starts watching and returns a cursor; later calls pass it back \ - and receive only what is new. Use this to follow a pane over several \ + and receive only what is new, as the raw output stream, not the rendered \ + screen: a line redrawn in place repeats; capture_pane shows the screen. \ + Use this to follow a pane over several \ turns without re-reading the whole screen. The answer says missed=true \ if the cursor no longer names retained output, including when the pane \ outran the buffer, its live tail was evicted, or the server restarted. \ diff --git a/crates/tmux-mcp/src/views.rs b/crates/tmux-mcp/src/views.rs index 785d101e..aabfa95f 100644 --- a/crates/tmux-mcp/src/views.rs +++ b/crates/tmux-mcp/src/views.rs @@ -230,6 +230,10 @@ pub struct Since { /// The pane that was read. pub pane: String, /// The text, with escape sequences removed. + /// + /// After the first answer this is the raw output stream, not the rendered + /// screen: a line redrawn in place repeats. `capture_pane` shows the + /// screen. pub text: String, /// The cursor to pass back next time. pub cursor: String, diff --git a/crates/tmux-mcp/tests/schema.rs b/crates/tmux-mcp/tests/schema.rs index 4129919d..7b77be47 100644 --- a/crates/tmux-mcp/tests/schema.rs +++ b/crates/tmux-mcp/tests/schema.rs @@ -169,6 +169,37 @@ fn every_input_property_carries_a_description() -> TestResult { Ok(()) } +/// Streamed text repeats every redraw a line editor makes, so an agent +/// reading it has to know the screen is elsewhere. +#[test] +fn streamed_text_says_it_is_not_the_screen() -> TestResult { + let offered = tools("inspect,execute")?.offered(); + for (name, field) in [ + ("wait_for_text", "text"), + ("capture_since", "text"), + ("run_shell_command", "output"), + ] { + let tool = offered + .iter() + .find(|tool| tool.name == name) + .expect("streaming tool"); + let description = tool.description.as_deref().expect("description"); + let output = + serde_json::Value::Object((**tool.output_schema.as_ref().expect("output")).clone()); + let field = output["properties"][field]["description"] + .as_str() + .expect("field description"); + + for text in [description, field] { + // Rustdoc line breaks reach the wire inside a field description. + let text = text.split_whitespace().collect::>().join(" "); + assert!(text.contains("not the rendered screen"), "{name}: {text}"); + assert!(text.contains("capture_pane"), "{name}: {text}"); + } + } + Ok(()) +} + #[test] fn configured_process_routes_have_no_executable_payload() -> TestResult { let tools = tools("execute")?; From 0f03807f7d4817bc40ad0551ea78bc32fe9a1680 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:38:35 -0500 Subject: [PATCH 094/117] tmux-mcp(fix[meta]): Stop repeating each tool in its _meta why: Each tool's _meta capability row repeated its name, title, description, annotations and both schemas verbatim: 102,357 of the 194,080 bytes of tools/list for the 41 tools an operator-selected socket serves. A client pays for that before its first call, and nothing measured it. what: - Serialize only the published capability into _meta; move annotations from that row onto the report row, so tmux://capabilities still reports every field together - Test over the wire that _meta holds none of the tool's own fields and agrees with the report row, and that annotations match the tool's - Fail when the default 45-tool list passes 120,000 bytes; it measures 108,201, and the old copy took it to 201,403 - Read annotations from the tool when generating TOOLS.md (unchanged) - Correct the README and the agent-parity plan on what _meta holds Measured over JSON-RPC against the built binary: tools/list for 41 tools falls from 194,080 to 103,943 bytes, _meta from 102,357 to 12,220. The remaining _meta is toolset, reach, effects, outputs, the secret and untrusted flags, literalization, nested authority and amplification -- none of it on the tool. outputSchema (41 KB) is now the largest part and is not a copy. --- crates/tmux-mcp/README.md | 14 +++++++----- crates/tmux-mcp/docs/plans/01-agent-parity.md | 5 +++-- crates/tmux-mcp/src/manifest.rs | 7 +++--- crates/tmux-mcp/tests/docs.rs | 4 +++- crates/tmux-mcp/tests/protocol.rs | 22 ++++++++++++++++++- crates/tmux-mcp/tests/schema.rs | 20 +++++++++++++++++ 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index 59f00b10..f9a8a505 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -249,12 +249,14 @@ invalid-request response before tool dispatch. `on_error` is either `stop` or `continue`. Its one client approval covers every nested name in its schema; inner tools do not receive separate approval. -The capability row is not a second hand-maintained catalog. The same row a -client receives under `_meta["com.git-pull.libtmux-mcp/capability"]` carries the -native input and output schemas, process reach, effect and output sets, -schema-keyed input literalization, annotations, and any nested authority. The -native definition also classifies every input sink, but that validation detail -is not duplicated on the wire. For example, `get_tmux_variables.names` is +The capability row is not a second hand-maintained catalog. A client receives +it under `_meta["com.git-pull.libtmux-mcp/capability"]`, holding only what the +tool does not already carry: toolset, process reach, effect and output sets, +the secret and untrusted-content flags, schema-keyed input literalization, any +nested authority, and future-input amplification. Name, title, description, +annotations, and schemas stay on the tool, and `tmux://capabilities` reports +them all together. The native definition also classifies every input sink, but +that validation detail is not duplicated on the wire. For example, `get_tmux_variables.names` is reported as `validated-variable-name`; it is not falsely described as escaped literal text. diff --git a/crates/tmux-mcp/docs/plans/01-agent-parity.md b/crates/tmux-mcp/docs/plans/01-agent-parity.md index 7b10c91f..4de8562c 100644 --- a/crates/tmux-mcp/docs/plans/01-agent-parity.md +++ b/crates/tmux-mcp/docs/plans/01-agent-parity.md @@ -192,8 +192,9 @@ output remain tool results. Because one process is pinned to one socket, no resource URI can select another server. That resource is also the check against registration drift. Every advertised -tool carries the same complete capability row in its `_meta`, including its -native input and output schemas. Listing, calling, documentation generation, +tool carries its capability row in its `_meta`, less the name, title, +description, annotations, and schemas the tool already carries; the resource +reports all of them together. Listing, calling, documentation generation, and reporting therefore cannot disagree without a registry test failing. Selection is frozen before the first tmux command. The four toolsets are an diff --git a/crates/tmux-mcp/src/manifest.rs b/crates/tmux-mcp/src/manifest.rs index a2cde1be..7d8dec56 100644 --- a/crates/tmux-mcp/src/manifest.rs +++ b/crates/tmux-mcp/src/manifest.rs @@ -160,7 +160,6 @@ pub(crate) struct PublishedCapability { pub(crate) output_classes: BTreeSet, pub(crate) may_expose_secrets: bool, pub(crate) may_return_untrusted_content: bool, - pub(crate) annotations: Annotations, pub(crate) input_literalization: BTreeMap, pub(crate) nested_authority: BTreeSet, pub(crate) amplifies_future_input: bool, @@ -175,7 +174,6 @@ impl From<&Capability> for PublishedCapability { output_classes: definition.output_classes.clone(), may_expose_secrets: definition.may_expose_secrets, may_return_untrusted_content: definition.may_return_untrusted_content, - annotations: definition.annotations(), input_literalization: definition.input_literalization.clone(), nested_authority: definition.nested_authority.clone(), amplifies_future_input: definition.amplifies_future_input, @@ -284,6 +282,8 @@ pub(crate) struct ReportTool { pub(crate) name: String, pub(crate) title: String, pub(crate) description: String, + pub(crate) annotations: Annotations, + /// The only part a tool's `_meta` carries: the rest is on the tool. #[serde(flatten)] pub(crate) capability: PublishedCapability, pub(crate) input_schema: serde_json::Value, @@ -529,6 +529,7 @@ fn finish_route( name, title, description, + annotations: row.annotations(), capability, input_schema: serde_json::Value::Object((*route.attr.input_schema).clone()), output_schema: serde_json::Value::Object((**output_schema).clone()), @@ -568,7 +569,7 @@ fn refresh_metadata( row: &ReportTool, ) -> Result<(), SurfaceError> { let meta = meta.ok_or_else(|| SurfaceError::new(format!("tool {name:?} has no metadata")))?; - let value = serde_json::to_value(row).map_err(|error| { + let value = serde_json::to_value(&row.capability).map_err(|error| { SurfaceError::new(format!( "tool {name:?} capability cannot serialize: {error}" )) diff --git a/crates/tmux-mcp/tests/docs.rs b/crates/tmux-mcp/tests/docs.rs index 1071ae39..85ed7e59 100644 --- a/crates/tmux-mcp/tests/docs.rs +++ b/crates/tmux-mcp/tests/docs.rs @@ -101,10 +101,12 @@ fn append_tools(output: &mut String) -> TestResult { "- May return untrusted content: `{}`", row["mayReturnUntrustedContent"].as_bool().unwrap() )?; + let mut hints = serde_json::to_value(tool.annotations.as_ref().expect("annotations"))?; + hints.as_object_mut().expect("hint object").remove("title"); writeln!( output, "- Whole-call annotations: `{}`", - serde_json::to_string(&row["annotations"])? + serde_json::to_string(&hints)? )?; writeln!( output, diff --git a/crates/tmux-mcp/tests/protocol.rs b/crates/tmux-mcp/tests/protocol.rs index 8bb30518..74251bff 100644 --- a/crates/tmux-mcp/tests/protocol.rs +++ b/crates/tmux-mcp/tests/protocol.rs @@ -357,8 +357,28 @@ async fn capabilities_resource_reports_the_effective_surface() { .meta .as_ref() .and_then(|meta| meta.0.get("com.git-pull.libtmux-mcp/capability")) + .and_then(Value::as_object) .unwrap_or_else(|| panic!("{} capability metadata", tool.name)); - assert_eq!(metadata, row, "{} metadata/report", tool.name); + // `_meta` carries only what the tool itself does not. + let on_the_tool = [ + "name", + "title", + "description", + "annotations", + "inputSchema", + "outputSchema", + ]; + for (key, value) in metadata { + assert!( + !on_the_tool.contains(&key.as_str()), + "{} repeats {key}", + tool.name + ); + assert_eq!(&row[key], value, "{} metadata/report {key}", tool.name); + } + let mut hints = serde_json::to_value(tool.annotations.as_ref().expect("hints")).unwrap(); + hints.as_object_mut().unwrap().remove("title"); + assert_eq!(row["annotations"], hints, "{} annotations", tool.name); assert_eq!(row["description"].as_str(), tool.description.as_deref()); assert_eq!( row["inputSchema"], diff --git a/crates/tmux-mcp/tests/schema.rs b/crates/tmux-mcp/tests/schema.rs index 7b77be47..0514805d 100644 --- a/crates/tmux-mcp/tests/schema.rs +++ b/crates/tmux-mcp/tests/schema.rs @@ -169,6 +169,26 @@ fn every_input_property_carries_a_description() -> TestResult { Ok(()) } +/// A client downloads `tools/list` before its first call, and a copy of +/// every tool in `_meta` once nearly doubled it unnoticed. +/// +/// The ceiling leaves room for new descriptions, not for a second copy of +/// any schema. +#[test] +fn default_tool_list_stays_under_its_byte_ceiling() -> TestResult { + const CEILING: usize = 120_000; + let tools = TmuxTools::builder(libtmux::Server::new()?) + .selection(Selection::parse_for_socket(None, None, None, true)?) + .build(); + let bytes = serde_json::to_string(&tools.offered())?.len(); + + assert!( + bytes <= CEILING, + "tools/list carries {bytes} bytes of tools, over its {CEILING}-byte ceiling" + ); + Ok(()) +} + /// Streamed text repeats every redraw a line editor makes, so an agent /// reading it has to know the screen is elsewhere. #[test] From fa0e3eaa33b2f548461ab5bf4a872022d0eff76f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:47:26 -0500 Subject: [PATCH 095/117] tmux-mcp(fix[schema]): State every optional input's default why: An omitted start_directory was described as "tmux's default", but tmux takes it from the command client's cwd -- the directory the MCP server started in, since libtmux spawns tmux there -- not the split pane's. Checked on tmux 3.7d from a client in another directory: both split-window and new-window started there. Eight other optional inputs named no default, and a send_keys_batch row required enter where send_keys defaults it. what: - Say where an omitted start_directory lands for create_session, create_window and split_window - State the default of capture_pane's start and end, search_panes' scope, send_keys' text and keys, show_option's target and wait_for_text's stop - Default a send_keys_batch row's enter to false, as send_keys does --- crates/tmux-mcp/src/model.rs | 24 ++++++++++++++++-------- crates/tmux-mcp/src/tools/contract.rs | 14 +++++++++----- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/tmux-mcp/src/model.rs b/crates/tmux-mcp/src/model.rs index c4fffc56..76176c01 100644 --- a/crates/tmux-mcp/src/model.rs +++ b/crates/tmux-mcp/src/model.rs @@ -21,7 +21,8 @@ pub struct SessionArgs { pub struct CreateSessionArgs { /// The name for the new session. It must not already exist. pub name: String, - /// An optional working directory for the session's first window. + /// The first window's working directory. Omit for the directory this MCP + /// server started in. pub start_directory: Option, } @@ -133,7 +134,8 @@ pub struct WaitForTextArgs { /// Text that ends the wait successfully. Omit to wait for any output. #[schemars(length(max = 32))] pub patterns: Option>, - /// Text that ends the wait as a failure, reported as `stopped`. + /// Text that ends the wait as a failure, reported as `stopped`. Omit for + /// none. /// /// Give the failure markers you already know — `error:`, `Traceback` — and /// a failed run returns at once instead of at the deadline. @@ -190,9 +192,11 @@ pub struct SearchPanesArgs { /// Search scrollback as well as the visible screen. #[serde(default)] pub history: bool, - /// Only search panes in this session, by `$`-prefixed id or name. + /// Only search panes in this session, by `$`-prefixed id or name. Omit + /// for every session. pub session: Option, - /// Only search panes in this window, by `@`-prefixed id. + /// Only search panes in this window, by `@`-prefixed id. Omit for every + /// window. pub window: Option, } @@ -209,7 +213,8 @@ pub struct OptionArgs { /// setting an option without a target means in tmux. #[schemars(with = "Option")] pub scope: Option, - /// The `$`, `@` or `%`-prefixed id, for the scopes that need one. + /// The `$`, `@` or `%`-prefixed id, for the scopes that need one. Omit + /// for the others. pub target: Option, } @@ -271,9 +276,10 @@ pub struct CapturePaneArgs { #[serde(default)] pub last_command: bool, /// Start at this line. Zero is the top of the screen, negative is - /// scrollback. + /// scrollback. Omit for the top of the screen, or of the scrollback with + /// `history`. pub start: Option, - /// End at this line. + /// End at this line. Omit for the bottom of the screen. pub end: Option, } @@ -283,13 +289,15 @@ pub struct CapturePaneArgs { pub struct SendKeysArgs { /// The `%`-prefixed pane id. pub pane: String, - /// Text typed literally into the pane. Key names are not interpreted. + /// Text typed literally into the pane. Key names are not interpreted. Omit + /// to type none. pub text: Option, /// tmux key names to press, in order, after any text. /// /// These are interpreted rather than typed, which is the only way to send /// a key that has no character: `C-c` to interrupt, `Escape`, `Up`, /// `C-d`. Sending `C-c` as `text` would type those three characters. + /// Omit to press none. pub keys: Option>, /// Whether to press Enter afterwards. #[serde(default)] diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index 918363f1..f61a7c2a 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -154,7 +154,8 @@ pub(crate) struct CreateWindowArgs { pub(crate) session: String, /// The window name. Omit to let tmux name it after its running command. pub(crate) name: Option, - /// The window's working directory. Omit for tmux's default. + /// The window's working directory. Omit for the directory this MCP + /// server started in. pub(crate) start_directory: Option, } @@ -168,8 +169,8 @@ pub(crate) struct SplitWindowArgs { pub(crate) direction: Option, /// The new pane's share of the split, 1 to 100. Defaults to half. pub(crate) percent: Option, - /// The new pane's working directory. Omit for tmux's default, which is - /// not `pane`'s current directory. + /// The new pane's working directory. Omit for the directory this MCP + /// server started in, not `pane`'s current directory. pub(crate) start_directory: Option, } @@ -189,11 +190,14 @@ pub(crate) struct RespawnArgs { pub(crate) struct SendOperation { /// The `%`-prefixed pane id. pub(crate) pane: String, - /// Text typed literally. Key names are not interpreted. + /// Text typed literally. Key names are not interpreted. Omit to type + /// none. pub(crate) text: Option, - /// tmux key names to press, in order, after any text, such as `C-c`. + /// tmux key names to press, in order, after any text, such as `C-c`. Omit + /// to press none. pub(crate) keys: Option>, /// Whether to press Enter afterwards. + #[serde(default)] pub(crate) enter: bool, } From 4524f1b0d9f50d9a0e1ac2b5d3d5fe9ed8d86a79 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:49:16 -0500 Subject: [PATCH 096/117] tmux-mcp(test[hints]): Pin hints to the toolset lists why: The annotation test derives what a tool may claim from the same capability row the hints derive from. Turning kill_pane's delete effect into a change passed it while the tool stopped claiming to be destructive. what: - Check the hints against schema.rs's hand-kept toolset lists: nothing in manage, execute or teardown is read-only, and all of teardown is destructive --- crates/tmux-mcp/tests/schema.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tmux-mcp/tests/schema.rs b/crates/tmux-mcp/tests/schema.rs index 0514805d..1b744d58 100644 --- a/crates/tmux-mcp/tests/schema.rs +++ b/crates/tmux-mcp/tests/schema.rs @@ -120,6 +120,26 @@ fn every_advertised_schema_is_valid_and_closed() -> TestResult { Ok(()) } +/// The hand-kept lists above, not the capability rows the hints derive from, +/// say which tools must never look read-only or harmless to a client. +#[test] +fn listed_mutating_tools_carry_mutating_hints() -> TestResult { + for tool in tools("inspect,manage,execute,teardown")?.offered() { + let name = tool.name.as_ref(); + let hints = tool.annotations.as_ref().expect("annotations"); + if [MANAGE, EXECUTE, TEARDOWN] + .iter() + .any(|names| names.contains(&name)) + { + assert_eq!(hints.read_only_hint, Some(false), "{name} readOnlyHint"); + } + if TEARDOWN.contains(&name) { + assert_eq!(hints.destructive_hint, Some(true), "{name} destructiveHint"); + } + } + Ok(()) +} + /// Collect every property below `schema` that has no description. /// /// A `const` property is exempt: its one legal value says all there is. From 191072b17b79a8ee9727183aeef3eddc3f0dfc71 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:57:50 -0500 Subject: [PATCH 097/117] tmux-mcp(fix[toolsets]): Move set_history_limit to teardown why: From tmux 3.7 a lowered history-limit trims every existing pane it covers: on 3.7d a pane holding 1,477 lines kept 100 after a global limit of 100, and 50 after a session limit of 50. That is deleting tmux state, which is what teardown means, and teardown is withheld by default on a socket the operator chose so that an agent cannot destroy the operator's state unasked. set_history_limit sat in manage, so the default surface on the operator's own server could discard scrollback across every pane, while clearing one pane's scrollback needed teardown. what: - Put set_history_limit's capability row in the teardown toolset - Move it between the manage and teardown lists the permutation test pins; an operator-selected socket now offers 40 tools, not 41 - Regenerate TOOLS.md; update the README's toolset table and say why the tool is there - Narrow the README's caller-identity sentence to the tools that run the check: pane input and the three kill tools. clear_pane_scrollback never did, and set_history_limit does not - Record the move and LIBTMUX_TOOLS=set_history_limit, which restores the tool alone, in the changelog entry that declared its delete effect - Say the _meta size figures were measured with 41 tools --- crates/tmux-mcp/README.md | 18 +++++++++--------- crates/tmux-mcp/TOOLS.md | 4 ++-- crates/tmux-mcp/src/tools/contract.rs | 2 +- crates/tmux-mcp/tests/binary.rs | 4 ++-- crates/tmux-mcp/tests/schema.rs | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index f9a8a505..ec023d2f 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -216,9 +216,9 @@ directly from that registry. | Toolset | Intent | Tools | |---|---|---| | `inspect` (18) | Read bounded tmux state and terminal output | `call_read_tools_batch`, `capture_pane`, `capture_since`, `find_pane_by_position`, `get_pane_info`, `get_server_info`, `get_session_info`, `get_tmux_variables`, `get_window_info`, `list_panes`, `list_sessions`, `list_windows`, `search_panes`, `show_environment`, `show_hooks`, `show_option`, `snapshot_pane`, `wait_for_text` | -| `manage` (14) | Change tmux objects without starting a process | `move_window`, `rename_session`, `rename_window`, `resize_pane`, `resize_window`, `select_layout`, `select_pane`, `select_window`, `set_history_limit`, `set_mouse_enabled`, `set_pane_title`, `signal_channel`, `swap_pane`, `wait_for_channel` | +| `manage` (13) | Change tmux objects without starting a process | `move_window`, `rename_session`, `rename_window`, `resize_pane`, `resize_window`, `select_layout`, `select_pane`, `select_window`, `set_mouse_enabled`, `set_pane_title`, `signal_channel`, `swap_pane`, `wait_for_channel` | | `execute` (9) | Start configured processes or drive pane programs | `create_session`, `create_window`, `paste_text`, `respawn_pane`, `run_shell_command`, `send_keys`, `send_keys_batch`, `set_synchronize_panes`, `split_window` | -| `teardown` (4) | Delete tmux state | `clear_pane_scrollback`, `kill_pane`, `kill_session`, `kill_window` | +| `teardown` (5) | Delete tmux state | `clear_pane_scrollback`, `kill_pane`, `kill_session`, `kill_window`, `set_history_limit` | `set_synchronize_panes` changes the window default; individual pane overrides determine the effective configured recipient cohort. `send_keys` observes that @@ -394,16 +394,16 @@ rename, are `idempotentHint: true`. Starting or driving a process, or returning terminal text, is `openWorldHint: true`. `wait_for_text` is not read-only: it attaches a client while it waits. `set_history_limit` is destructive: from tmux 3.7 a lower limit discards existing panes' scrollback -past it. +past it, so it is in `teardown` beside `clear_pane_scrollback`. When launched from tmux, the process inherits a pane ID, session number, server PID, and socket. Pane listings mark that pane `caller: "self"` only when the -socket matches the selected server. Pane-input and teardown tools additionally -resolve the complete identity against a fresh selected-daemon snapshot before -acting. A complete identity on another physical socket is foreign; malformed or -inconsistent context on the selected socket fails closed. The comparison weighs -the socket as well as the pane ID because `%1` names a different pane on every -tmux server. +socket matches the selected server. Pane-input tools and the three kill tools +additionally resolve the complete identity against a fresh selected-daemon +snapshot before acting. A complete identity on another physical socket is +foreign; malformed or inconsistent context on the selected socket fails closed. +The comparison weighs the socket as well as the pane ID because `%1` names a +different pane on every tmux server. Pane input also refuses a configured pane visible to a non-control tmux client. In an unzoomed window every visible pane is attended; in a zoomed window only diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 7646708b..41b080c5 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -531,9 +531,9 @@ Send an ordered batch of input operations to panes. Each executed row repeats se ## `set_history_limit` -Set the scrollback history limit for a session or its global default. From tmux 3.7 existing panes take it too, and lowering it discards their scrollback past the new limit. Change tmux state; no client-supplied executable input. +Set the scrollback history limit for a session or its global default. From tmux 3.7 existing panes take it too, and lowering it discards their scrollback past the new limit. Delete tmux state; accepts no command payload. -- Toolset: `manage` +- Toolset: `teardown` - Process reach: `none` - Tmux effects: `["change","delete"]` - Output classes: `["tmux-metadata"]` diff --git a/crates/tmux-mcp/src/tools/contract.rs b/crates/tmux-mcp/src/tools/contract.rs index f61a7c2a..741ac677 100644 --- a/crates/tmux-mcp/src/tools/contract.rs +++ b/crates/tmux-mcp/src/tools/contract.rs @@ -897,7 +897,7 @@ impl TmuxTools { From tmux 3.7 existing panes take it too, and lowering it discards \ their scrollback past the new limit.", title = "Set History Limit", - meta = crate::capability_meta!(Manage, None, [Change, Delete], [TmuxMetadata], true, true, { + meta = crate::capability_meta!(Teardown, None, [Change, Delete], [TmuxMetadata], true, true, { "session" => [TmuxLookup], "limit" => [TmuxState] }; idempotent) )] diff --git a/crates/tmux-mcp/tests/binary.rs b/crates/tmux-mcp/tests/binary.rs index 72621709..ce0962c9 100644 --- a/crates/tmux-mcp/tests/binary.rs +++ b/crates/tmux-mcp/tests/binary.rs @@ -219,7 +219,7 @@ fn explicit_existing_socket_defaults_without_teardown() { &json!({"name": "list_sessions", "arguments": {}}), ); - assert_eq!(names.len(), 41); + assert_eq!(names.len(), 40); assert!(!names.iter().any(|name| name == "kill_session")); assert_eq!( listed["result"]["structuredContent"]["sessions"][0]["name"], @@ -487,7 +487,7 @@ fn only_the_process_whose_config_marker_loaded_claims_minimal_provenance() { follower_report["socket"]["configurationProvenance"], "unknown" ); - assert_eq!(follower_report["toolCount"], 41); + assert_eq!(follower_report["toolCount"], 40); } /// Two clients on the default socket share one daemon, so the one that diff --git a/crates/tmux-mcp/tests/schema.rs b/crates/tmux-mcp/tests/schema.rs index 1b744d58..d4d85912 100644 --- a/crates/tmux-mcp/tests/schema.rs +++ b/crates/tmux-mcp/tests/schema.rs @@ -44,7 +44,6 @@ const MANAGE: &[&str] = &[ "wait_for_channel", "signal_channel", "set_mouse_enabled", - "set_history_limit", ]; const EXECUTE: &[&str] = &[ "create_session", @@ -59,6 +58,7 @@ const EXECUTE: &[&str] = &[ ]; const TEARDOWN: &[&str] = &[ "clear_pane_scrollback", + "set_history_limit", "kill_pane", "kill_window", "kill_session", From b5592ebf13bb32da250ac626f1ba0d2669bda3a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 05:01:48 -0500 Subject: [PATCH 098/117] tmux-mcp(fix[effects]): Class every inspect tool as read-only why: wait_for_text and capture_since both attach an observer client that only reads, capture_since for longer: its tail stays until evicted or the server stops, where wait_for_text's ends with the wait. Yet capture_since was observe-only, as 4e07853 ruled an attached observer belongs to the inspect effect model, while wait_for_text declared a change and so told clients it was not read-only. Two tools, one mechanism, opposite hints. what: - Declare wait_for_text's row observe-only, as capture_since's is - Say in its description that waiting owns an observer client until the wait ends, where it said the attach changed the session's attached-client state - Pin every tool in schema.rs's hand-kept inspect list as read-only; it failed on wait_for_text before the row changed - Regenerate TOOLS.md; say the rule in the README and the changelog --- crates/tmux-mcp/README.md | 10 ++++++---- crates/tmux-mcp/TOOLS.md | 6 +++--- crates/tmux-mcp/src/tools/observe.rs | 10 +++++----- crates/tmux-mcp/tests/schema.rs | 7 ++++++- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/tmux-mcp/README.md b/crates/tmux-mcp/README.md index ec023d2f..1ae3ad17 100644 --- a/crates/tmux-mcp/README.md +++ b/crates/tmux-mcp/README.md @@ -391,10 +391,12 @@ observes tmux is `readOnlyHint: true`. Deleting tmux state or sending pane input is `destructiveHint: true`, because the receiving shell runs whatever arrives. Reads, teardown, and changes that set a named value, such as a rename, are `idempotentHint: true`. Starting or driving a process, or -returning terminal text, is `openWorldHint: true`. `wait_for_text` is not -read-only: it attaches a client while it waits. `set_history_limit` is -destructive: from tmux 3.7 a lower limit discards existing panes' scrollback -past it, so it is in `teardown` beside `clear_pane_scrollback`. +returning terminal text, is `openWorldHint: true`. Every `inspect` tool is +read-only: the observer client that `wait_for_text` or `capture_since` attaches +only reads, and changes no session, window, pane, or option. +`set_history_limit` is destructive: from tmux 3.7 a lower limit discards +existing panes' scrollback past it, so it is in `teardown` beside +`clear_pane_scrollback`. When launched from tmux, the process inherits a pane ID, session number, server PID, and socket. Pane listings mark that pane `caller: "self"` only when the diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index 41b080c5..aef2a81b 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -711,15 +711,15 @@ Block until something signals a tmux wait-for channel. A pending signal is consu ## `wait_for_text` -Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. The live stream attaches a client while waiting, changing the session's attached-client state. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. +Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. Waiting owns an observer client until the wait ends. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` -- Tmux effects: `["observe","change"]` +- Tmux effects: `["observe"]` - Output classes: `["tmux-metadata","terminal-content"]` - May expose secrets: `true` - May return untrusted content: `true` -- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false}` +- Whole-call annotations: `{"destructiveHint":false,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true}` - Input literalization: `{}` - Nested authority: `[]` - Amplifies future input: `false` diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index 801e877b..23c8d392 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -350,14 +350,14 @@ impl TmuxTools { not author, such as a server logging that it is ready. A pattern that is a \ substring of a command you just sent with send_keys can already be on \ screen as its echo; outcome present_at_entry reports that rather than \ - matched, so a still-pending command does not read as already done. The \ - live stream attaches a client while waiting, changing the session's \ - attached-client state. Each list accepts at most 32 patterns, each at most \ - 4,096 bytes, using Rust's linear-time regex engine.", + matched, so a still-pending command does not read as already done. \ + Waiting owns an observer client until the wait ends. Each list accepts \ + at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time \ + regex engine.", title = "Wait For Pane Text", meta = crate::capability_meta!( Inspect, None, - effects = [Observe, Change], + effects = [Observe], outputs = [TmuxMetadata, TerminalContent], secrets = true, untrusted = true, diff --git a/crates/tmux-mcp/tests/schema.rs b/crates/tmux-mcp/tests/schema.rs index d4d85912..254171a3 100644 --- a/crates/tmux-mcp/tests/schema.rs +++ b/crates/tmux-mcp/tests/schema.rs @@ -121,12 +121,17 @@ fn every_advertised_schema_is_valid_and_closed() -> TestResult { } /// The hand-kept lists above, not the capability rows the hints derive from, -/// say which tools must never look read-only or harmless to a client. +/// say which tools must never look read-only or harmless to a client, and +/// that every inspect tool looks read-only. The observer client that +/// `wait_for_text` or `capture_since` attaches only reads. #[test] fn listed_mutating_tools_carry_mutating_hints() -> TestResult { for tool in tools("inspect,manage,execute,teardown")?.offered() { let name = tool.name.as_ref(); let hints = tool.annotations.as_ref().expect("annotations"); + if INSPECT.contains(&name) { + assert_eq!(hints.read_only_hint, Some(true), "{name} readOnlyHint"); + } if [MANAGE, EXECUTE, TEARDOWN] .iter() .any(|names| names.contains(&name)) From 9d79fa702a90e863770454464fba7c21b5409868 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:19:13 -0500 Subject: [PATCH 099/117] Macros(fix[paths]): Name the crate ::libtmux inside its own package `proc-macro-crate` reports `FoundCrate::Itself` whenever the package being compiled is `libtmux` and `CARGO_TARGET_TMPDIR` is unset: the library, its doctests and its examples. The derive emitted `crate` for it, which is `libtmux` only in the library; a doctest failed with "cannot find `query` in `crate`", so every one carried `#[filterable(crate = "libtmux")]`. Integration tests never needed it -- proc-macro-crate 3.5 answers `Name` there -- and carried it anyway. The derive now emits `::libtmux`, and the library aliases itself with `extern crate self as libtmux` so the same path resolves inside it. The overrides go from the doctests, design.md and the integration tests. Proof: a unit test derives inside the library with no override. With the old `quote!(crate)` restored, `cargo test --doc Filterable` fails at `src/lib.rs - Filterable` with E0433. --- crates/libtmux-macros/src/expand.rs | 6 +++++- crates/libtmux/docs/design.md | 2 +- crates/libtmux/src/lib.rs | 7 ++++++- crates/libtmux/src/query.rs | 21 ++++++++++++++++++++- crates/libtmux/src/query/schema.rs | 2 +- crates/libtmux/tests/filter_derive.rs | 12 ++++++------ crates/libtmux/tests/filter_dialect.rs | 2 +- crates/libtmux/tests/macros_readme.rs | 2 +- crates/tmux-workspace/src/lib.rs | 9 ++++----- 9 files changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/libtmux-macros/src/expand.rs b/crates/libtmux-macros/src/expand.rs index bb65432e..47409c67 100644 --- a/crates/libtmux-macros/src/expand.rs +++ b/crates/libtmux-macros/src/expand.rs @@ -71,7 +71,11 @@ fn resolve_core_path(override_path: Option, span: Span) -> syn::Result Ok(quote!(crate)), + // `Itself` means the package being compiled is `libtmux`, which covers + // its doctests, integration tests and examples: separate crates, where + // `crate` is not `libtmux` and `::libtmux` is. The library names + // itself the same way through `extern crate self as libtmux`. + Ok(FoundCrate::Itself) => Ok(quote!(::libtmux)), Ok(FoundCrate::Name(name)) => { let ident = Ident::new(&name.replace('-', "_"), Span::call_site()); Ok(quote!(::#ident)) diff --git a/crates/libtmux/docs/design.md b/crates/libtmux/docs/design.md index aa1b52dd..2846c2ed 100644 --- a/crates/libtmux/docs/design.md +++ b/crates/libtmux/docs/design.md @@ -485,7 +485,7 @@ data through the current public API: use libtmux::query::{Filterable as _, QueryIteratorExt as _}; #[derive(libtmux::Filterable)] -#[filterable(target = "task", crate = "::libtmux")] +#[filterable(target = "task")] struct Task { name: String, done: bool, diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index b14b6b8c..ba40eb48 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -327,6 +327,12 @@ println!("{} sessions", sessions.len()); #[cfg(not(unix))] compile_error!("libtmux requires a Unix target with tmux available"); +// The derive names this crate `::libtmux` wherever it expands inside the +// `libtmux` package; this alias makes that path resolve here as well as in the +// package's tests and doctests. +#[cfg(feature = "derive")] +extern crate self as libtmux; + #[cfg(feature = "blocking")] pub mod blocking; mod capabilities; @@ -427,7 +433,6 @@ pub struct DesignNotes; /// /// #[derive(libtmux::Filterable)] /// #[filterable(target = "task")] -/// # #[filterable(crate = "libtmux")] /// struct Task { /// name: String, /// done: bool, diff --git a/crates/libtmux/src/query.rs b/crates/libtmux/src/query.rs index aeaac9ab..6d0f1b33 100644 --- a/crates/libtmux/src/query.rs +++ b/crates/libtmux/src/query.rs @@ -40,7 +40,6 @@ //! //! #[derive(libtmux::Filterable)] //! #[filterable(target = "task")] -//! # #[filterable(crate = "libtmux")] //! struct Task { //! name: String, //! done: bool, @@ -1302,6 +1301,26 @@ mod tests { __private::Predicate::new(TEST_FIELD, data) } + /// The derive expanding inside the library itself, with no `crate` + /// override: `proc-macro-crate` reports `Itself` here too. + #[cfg(feature = "derive")] + #[test] + fn the_derive_resolves_the_crate_inside_the_library() { + use super::{Filterable as _, QueryIteratorExt as _}; + + #[derive(crate::Filterable)] + #[filterable(target = "inside")] + struct Inside { + name: String, + } + + let values = [Inside { + name: "kept".into(), + }]; + let fields = Inside::filter_fields(); + assert_eq!(values.iter().matching(&fields.name.eq("kept")).count(), 1); + } + #[cfg(feature = "serde")] #[test] fn once_lock_set_failures_compare_the_installed_value() { diff --git a/crates/libtmux/src/query/schema.rs b/crates/libtmux/src/query/schema.rs index 0326f672..4fdb607a 100644 --- a/crates/libtmux/src/query/schema.rs +++ b/crates/libtmux/src/query/schema.rs @@ -23,7 +23,7 @@ use super::Filterable; /// use libtmux::query::FilterExpr; /// /// #[derive(libtmux::Filterable)] -/// #[filterable(target = "task", crate = "libtmux")] +/// #[filterable(target = "task")] /// struct Task { /// name: String, /// done: bool, diff --git a/crates/libtmux/tests/filter_derive.rs b/crates/libtmux/tests/filter_derive.rs index 86e52a2d..8c678998 100644 --- a/crates/libtmux/tests/filter_derive.rs +++ b/crates/libtmux/tests/filter_derive.rs @@ -24,14 +24,14 @@ impl FilterEnum for WorkflowState { } #[derive(Filterable)] -#[filterable(target = "child", crate = "libtmux")] +#[filterable(target = "child")] struct Child { label: String, active: bool, } #[derive(Filterable)] -#[filterable(target = "collision_target", crate = "libtmux")] +#[filterable(target = "collision_target")] struct InherentTargetCollision { label: String, } @@ -41,7 +41,7 @@ impl InherentTargetCollision { } #[derive(Filterable)] -#[filterable(target = "work_item", fields = "TaskHandles", crate = "libtmux")] +#[filterable(target = "work_item", fields = "TaskHandles")] struct Task { #[filterable(rename = "name")] summary: String, @@ -58,19 +58,19 @@ struct Task { } #[derive(Filterable)] -#[filterable(target = "duplicate", crate = "libtmux")] +#[filterable(target = "duplicate")] struct FirstDuplicate { first: bool, } #[derive(Filterable)] -#[filterable(target = "duplicate", crate = "libtmux")] +#[filterable(target = "duplicate")] struct SecondDuplicate { second: bool, } #[derive(Filterable)] -#[filterable(target = "duplicate_root", crate = "libtmux")] +#[filterable(target = "duplicate_root")] struct DuplicateRoot { #[filterable(many)] first: Vec, diff --git a/crates/libtmux/tests/filter_dialect.rs b/crates/libtmux/tests/filter_dialect.rs index 526639d3..c72b5167 100644 --- a/crates/libtmux/tests/filter_dialect.rs +++ b/crates/libtmux/tests/filter_dialect.rs @@ -29,7 +29,7 @@ use serde_json::Value; /// A single text field, which is all these cases need. #[derive(Filterable)] -#[filterable(target = "subject", crate = "libtmux")] +#[filterable(target = "subject")] struct Subject { text: String, } diff --git a/crates/libtmux/tests/macros_readme.rs b/crates/libtmux/tests/macros_readme.rs index 56c1eb25..f3194d08 100644 --- a/crates/libtmux/tests/macros_readme.rs +++ b/crates/libtmux/tests/macros_readme.rs @@ -9,7 +9,7 @@ use libtmux::query::{Filterable as _, QueryIteratorExt as _}; #[derive(libtmux::Filterable)] -#[filterable(target = "job", crate = "libtmux")] +#[filterable(target = "job")] struct Job { name: String, attempts: u32, diff --git a/crates/tmux-workspace/src/lib.rs b/crates/tmux-workspace/src/lib.rs index 4e81f89d..01bf28eb 100644 --- a/crates/tmux-workspace/src/lib.rs +++ b/crates/tmux-workspace/src/lib.rs @@ -365,11 +365,10 @@ impl<'server> WorkspaceBuilder<'server> { /// Compiles the `libtmux-macros` README's examples, and nothing else. /// -/// It cannot be compiled from `libtmux`, where the derive resolves the crate -/// to `crate`, nor from `libtmux-macros`, whose only dependency on `libtmux` -/// is deliberately renamed so the UI tests prove that resolution works. Here -/// `libtmux` is an ordinary dependency under its own name, which is the one -/// case a reader of that README is actually in. +/// It cannot be compiled from `libtmux-macros`, whose only dependency on +/// `libtmux` is deliberately renamed so the UI tests prove the derive resolves +/// the crate. Here `libtmux` is an ordinary dependency under its own name, +/// which is the case a reader of that README is in. #[cfg(doctest)] #[doc = include_str!("../libtmux-macros-README.md")] pub struct MacrosReadme; From 683d5b6dc65f5eb09ef5380c892745bee6da54ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:23:23 -0500 Subject: [PATCH 100/117] Macros(test[ui]): Cover every derive error site, on one compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to what the derive's UI tests prove. The README listed `#[filterable(many = …)]` and `one = …`; the parser refuses any value ("is a bare flag"). The table now shows the bare flags, and `relation_flag_value` pins the refusal. Three reachable error sites had no compile-fail case: an option path with more than one segment, on a container and on a field, and an empty `#[filterable()]`. Each has a fixture now. With each guard removed, trybuild reports "Expected test case to fail to compile, but it succeeded" for `container_option_path`, `field_option_path` and `empty_helper_attribute`. Two sites could not fire and are gone: a named field without an identifier, and a missing target after `finish()` had already returned the error that explains it -- the target is now held as the `Result` whose error is that explanation. The missing-package error stays, with a comment: trybuild builds each case with this crate's dev-dependencies, which include `libtmux`. The compile-fail `.stderr` files quote rustc's own diagnostics, which reword between releases, and `just msrv` ran them on 1.85.0 too. They now run only when `rustc -V` matches `rust-toolchain.toml`; on 1.85.0 the test prints "compile-fail cases skipped" and checks the passing cases. `just macros-ui-bless` rewrites them after a toolchain bump. --- .github/CONTRIBUTING.md | 5 +++ crates/libtmux-macros/README.md | 4 +-- crates/libtmux-macros/src/expand.rs | 31 ++++++++-------- crates/libtmux-macros/src/parse.rs | 9 ++--- crates/libtmux-macros/tests/derive.rs | 35 ++++++++++++++++++- .../tests/ui/fail/container_option_path.rs | 9 +++++ .../ui/fail/container_option_path.stderr | 5 +++ .../tests/ui/fail/empty_helper_attribute.rs | 10 ++++++ .../ui/fail/empty_helper_attribute.stderr | 5 +++ .../tests/ui/fail/field_option_path.rs | 10 ++++++ .../tests/ui/fail/field_option_path.stderr | 5 +++ .../tests/ui/fail/relation_flag_value.rs | 14 ++++++++ .../tests/ui/fail/relation_flag_value.stderr | 11 ++++++ justfile | 9 +++++ 14 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 crates/libtmux-macros/tests/ui/fail/container_option_path.rs create mode 100644 crates/libtmux-macros/tests/ui/fail/container_option_path.stderr create mode 100644 crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.rs create mode 100644 crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.stderr create mode 100644 crates/libtmux-macros/tests/ui/fail/field_option_path.rs create mode 100644 crates/libtmux-macros/tests/ui/fail/field_option_path.stderr create mode 100644 crates/libtmux-macros/tests/ui/fail/relation_flag_value.rs create mode 100644 crates/libtmux-macros/tests/ui/fail/relation_flag_value.stderr diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8291b67d..a7d991db 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -372,6 +372,11 @@ The floor is stated in several places, and they have to agree: then the `tmux-mcp` tests on 1.88.0. `rust-toolchain.toml` pins a much newer toolchain for day-to-day work; it is not the floor and does not prove one. +The derive's compile-fail cases pin rustc's diagnostics, which change between +releases, so they run on the pinned toolchain only; the MSRV run checks the +passing cases. After a toolchain bump, `just macros-ui-bless` rewrites their +`.stderr` files; review the diff before committing it. + ## Dependencies - A declared version is the **minimum supported**, not the newest published, diff --git a/crates/libtmux-macros/README.md b/crates/libtmux-macros/README.md index d847d9e1..6781220d 100644 --- a/crates/libtmux-macros/README.md +++ b/crates/libtmux-macros/README.md @@ -68,8 +68,8 @@ naming the field, not an expression that quietly matches nothing. | `#[filterable(rename = "…")]` | field | Uses a different name in expressions | | `#[filterable(skip)]` | field | Leaves the field out | | `#[filterable(enum)]` | field | Treats the field as a closed set of values | -| `#[filterable(many = …)]` | field | Declares a one-to-many relation | -| `#[filterable(one = …)]` | field | Declares a one-to-one relation | +| `#[filterable(many)]` | `Vec` field | Declares a one-to-many relation | +| `#[filterable(one)]` | `Option` field | Declares a one-to-one relation | A relation lets a question about what a value *contains* stay one expression: `parents.children.any(children.name.eq("build"))`. diff --git a/crates/libtmux-macros/src/expand.rs b/crates/libtmux-macros/src/expand.rs index 47409c67..dfc8c6af 100644 --- a/crates/libtmux-macros/src/expand.rs +++ b/crates/libtmux-macros/src/expand.rs @@ -14,14 +14,17 @@ pub(super) fn expand_filterable(input: &DeriveInput) -> syn::Result validate_wire_name(target, "filter target", &mut errors), + // A target given in the wrong form was reported where it was parsed. + Err(missing) if !container.target_seen => errors.push(missing.clone()), + Err(_) => {} } let mut field_specs = Vec::new(); @@ -42,13 +45,7 @@ pub(super) fn expand_filterable(input: &DeriveInput) -> syn::Result, span: Span) -> syn::Result Ok(quote!(::libtmux)), Ok(FoundCrate::Name(name)) => { let ident = Ident::new(&name.replace('-', "_"), Span::call_site()); Ok(quote!(::#ident)) } + // No UI test reaches this: trybuild builds each case with this + // crate's dev-dependencies, and one of them is `libtmux`. Err(_) => Err(syn::Error::new( span, "could not locate the `libtmux` package; use `#[filterable(crate = \"path::to::libtmux\")]` to provide it explicitly", diff --git a/crates/libtmux-macros/src/parse.rs b/crates/libtmux-macros/src/parse.rs index e98588fa..7e989df7 100644 --- a/crates/libtmux-macros/src/parse.rs +++ b/crates/libtmux-macros/src/parse.rs @@ -160,13 +160,8 @@ fn parse_crate_path(meta: Meta, options: &mut ContainerOptions, errors: &mut Err } pub(super) fn parse_field(field: &Field, errors: &mut Errors) -> Option { - let Some(ident) = field.ident.clone() else { - errors.push(syn::Error::new( - field.span(), - "Filterable fields must have identifiers", - )); - return None; - }; + // Always present: `named_fields` hands over the fields of a named struct. + let ident = field.ident.clone()?; let mut options = FieldOptions::default(); for_each_filterable_meta(&field.attrs, errors, |meta, errors| { parse_field_meta(meta, &mut options, errors); diff --git a/crates/libtmux-macros/tests/derive.rs b/crates/libtmux-macros/tests/derive.rs index d1730e94..a2dcd04a 100644 --- a/crates/libtmux-macros/tests/derive.rs +++ b/crates/libtmux-macros/tests/derive.rs @@ -1,8 +1,41 @@ //! Compile-time contract tests for the `Filterable` derive. +//! +//! The compile-fail cases pin rustc's own diagnostics beside the derive's, and +//! rustc rewords those between releases. So they run only on the toolchain +//! `rust-toolchain.toml` pins, and `just macros-ui-bless` rewrites them for +//! it. Any other compiler, the MSRV run among them, checks the passing cases +//! alone. + +use std::path::Path; +use std::process::Command; #[test] fn filterable_ui_contract() { let tests = trybuild::TestCases::new(); tests.pass("tests/ui/pass/*.rs"); - tests.compile_fail("tests/ui/fail/*.rs"); + match (pinned_release(), running_release()) { + (Some(pinned), Some(running)) if pinned == running => { + tests.compile_fail("tests/ui/fail/*.rs"); + } + (pinned, running) => { + eprintln!("compile-fail cases skipped: they pin rustc {pinned:?}, this is {running:?}"); + } + } +} + +/// The `channel` `rust-toolchain.toml` names, absent from a packaged crate. +fn pinned_release() -> Option { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../rust-toolchain.toml"); + let text = std::fs::read_to_string(manifest).ok()?; + text.lines().find_map(|line| { + let value = line.trim().strip_prefix("channel")?.trim_start(); + Some(value.strip_prefix('=')?.trim().trim_matches('"').to_owned()) + }) +} + +/// The release `rustc -V` reports here, which is the compiler trybuild runs. +fn running_release() -> Option { + let output = Command::new("rustc").arg("-V").output().ok()?; + let text = String::from_utf8(output.stdout).ok()?; + text.split_whitespace().nth(1).map(ToOwned::to_owned) } diff --git a/crates/libtmux-macros/tests/ui/fail/container_option_path.rs b/crates/libtmux-macros/tests/ui/fail/container_option_path.rs new file mode 100644 index 00000000..2d522f3d --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/container_option_path.rs @@ -0,0 +1,9 @@ +use libtmux_macros::Filterable; + +#[derive(Filterable)] +#[filterable(target = "container_path", serde::rename = "other")] +struct ContainerOptionPath { + name: String, +} + +fn main() {} diff --git a/crates/libtmux-macros/tests/ui/fail/container_option_path.stderr b/crates/libtmux-macros/tests/ui/fail/container_option_path.stderr new file mode 100644 index 00000000..1ce103d7 --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/container_option_path.stderr @@ -0,0 +1,5 @@ +error: unknown filterable container option + --> tests/ui/fail/container_option_path.rs:4:41 + | +4 | #[filterable(target = "container_path", serde::rename = "other")] + | ^^^^^ diff --git a/crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.rs b/crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.rs new file mode 100644 index 00000000..9249a9a8 --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.rs @@ -0,0 +1,10 @@ +use libtmux_macros::Filterable; + +#[derive(Filterable)] +#[filterable(target = "empty_helper")] +#[filterable()] +struct EmptyHelperAttribute { + name: String, +} + +fn main() {} diff --git a/crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.stderr b/crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.stderr new file mode 100644 index 00000000..28701e11 --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/empty_helper_attribute.stderr @@ -0,0 +1,5 @@ +error: filterable attributes require at least one option + --> tests/ui/fail/empty_helper_attribute.rs:5:1 + | +5 | #[filterable()] + | ^ diff --git a/crates/libtmux-macros/tests/ui/fail/field_option_path.rs b/crates/libtmux-macros/tests/ui/fail/field_option_path.rs new file mode 100644 index 00000000..d2fbbad9 --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/field_option_path.rs @@ -0,0 +1,10 @@ +use libtmux_macros::Filterable; + +#[derive(Filterable)] +#[filterable(target = "field_path")] +struct FieldOptionPath { + #[filterable(serde::skip)] + name: String, +} + +fn main() {} diff --git a/crates/libtmux-macros/tests/ui/fail/field_option_path.stderr b/crates/libtmux-macros/tests/ui/fail/field_option_path.stderr new file mode 100644 index 00000000..3402a2f3 --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/field_option_path.stderr @@ -0,0 +1,5 @@ +error: unknown filterable field option + --> tests/ui/fail/field_option_path.rs:6:18 + | +6 | #[filterable(serde::skip)] + | ^^^^^ diff --git a/crates/libtmux-macros/tests/ui/fail/relation_flag_value.rs b/crates/libtmux-macros/tests/ui/fail/relation_flag_value.rs new file mode 100644 index 00000000..8b671b3e --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/relation_flag_value.rs @@ -0,0 +1,14 @@ +use libtmux_macros::Filterable; + +struct Child; + +#[derive(Filterable)] +#[filterable(target = "relation_value")] +struct RelationFlagValue { + #[filterable(many = Child)] + children: Vec, + #[filterable(one = "owner")] + owner: Option, +} + +fn main() {} diff --git a/crates/libtmux-macros/tests/ui/fail/relation_flag_value.stderr b/crates/libtmux-macros/tests/ui/fail/relation_flag_value.stderr new file mode 100644 index 00000000..c3e8bb89 --- /dev/null +++ b/crates/libtmux-macros/tests/ui/fail/relation_flag_value.stderr @@ -0,0 +1,11 @@ +error: filterable `many` is a bare flag and does not accept a value + --> tests/ui/fail/relation_flag_value.rs:8:18 + | +8 | #[filterable(many = Child)] + | ^^^^ + +error: filterable `one` is a bare flag and does not accept a value + --> tests/ui/fail/relation_flag_value.rs:10:18 + | +10 | #[filterable(one = "owner")] + | ^^^ diff --git a/justfile b/justfile index 4e46ef95..1194a772 100644 --- a/justfile +++ b/justfile @@ -114,6 +114,15 @@ msrv: rustup run 1.88.0 cargo test --locked \ --package tmux-mcp --all-targets +# The compile-fail cases run only on the toolchain `rust-toolchain.toml` pins, +# because their `.stderr` files quote rustc's own wording. A toolchain bump +# rewords some of them; this rewrites every mismatch for review. +# +# Rewrite the derive's compile-fail expectations for the pinned toolchain +[group: 'test'] +macros-ui-bless: + TRYBUILD=overwrite cargo test --locked --package libtmux-macros --test derive + # Test the native tool that points agent CLIs at a build of this server [doc('Test the native tool that points agent CLIs at a build of this server')] [group: 'test'] From b2af448802412161b658ca51f4896a26eeadf2e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:33:55 -0500 Subject: [PATCH 101/117] Fuzz(feat[environment]): Fuzz the show-environment -s parser The `-s` listing parser A1.5 added reads names and values the server inherited or a client set, and had no fuzz target, which CONTRIBUTING asks of every parser of tmux output. `environment_listing` feeds it arbitrary bytes, then renders NUL-separated variables the way `cmd_show_environment_print` does and asserts they parse back to themselves. Seeds are hand-written: the unit tests' listings and a round-trip input. The weekly CI matrix runs it. It failed within seconds: an exported variable named `unset t X;\nY` -- tmux accepts any name without `=` -- made the whole listing fail with `Err((2, 58))`, because `unset NAME;` was tried first and took the start of the name for a removal of `t X`. An exported entry is now tried first: its name is printed twice, so the second copy confirms where the first ended. A removed name holding `;` and a newline stays ambiguous: `unset A;\n unset B;` is one removal or two, byte for byte. The oracle leaves that case out and `Session::environment_all` says so. Proof: `an_exported_name_shaped_like_a_removal_stays_one_variable` fails with "listing parses: (1, 9)" under the old order. After the fix, 563,999 runs in 61 s, no crash, cov 672. --- .github/workflows/ci.yml | 1 + crates/libtmux/src/internal/environment.rs | 98 +++++++++++++++++- crates/libtmux/src/lib.rs | 3 + crates/libtmux/src/session/settings.rs | 4 +- fuzz/Cargo.toml | 7 ++ fuzz/fuzz_targets/environment_listing.rs | 12 +++ fuzz/seeds/environment_listing/escapes | 5 + fuzz/seeds/environment_listing/framing-decoys | 6 ++ fuzz/seeds/environment_listing/variables | Bin 0 -> 68 bytes 9 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 fuzz/fuzz_targets/environment_listing.rs create mode 100644 fuzz/seeds/environment_listing/escapes create mode 100644 fuzz/seeds/environment_listing/framing-decoys create mode 100644 fuzz/seeds/environment_listing/variables diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a493bb58..c8b0337b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,7 @@ jobs: target: - control_block - control_line + - environment_listing - filter_expr_json - format_rows - text_filter diff --git a/crates/libtmux/src/internal/environment.rs b/crates/libtmux/src/internal/environment.rs index a6e80fbe..b5f3a2cd 100644 --- a/crates/libtmux/src/internal/environment.rs +++ b/crates/libtmux/src/internal/environment.rs @@ -183,8 +183,12 @@ fn parse_shell_listing( let mut row = 0; while at < stdout.len() { let rest = &stdout[at..]; - let (name, entry, consumed) = parse_unset(rest) - .or_else(|| parse_exported(rest)) + // An exported entry first: its name is printed twice, so the second + // copy confirms where the first ended. `unset NAME;` carries no such + // check, and read first it took the start of an exported name such + // as `unset X;\nY` for a removal of `X`. + let (name, entry, consumed) = parse_exported(rest) + .or_else(|| parse_unset(rest)) .ok_or((row, at))?; environment.insert(name, entry); at += consumed; @@ -195,8 +199,9 @@ fn parse_shell_listing( /// `unset NAME;` and its newline. /// -/// tmux refuses a name containing `=`, which is how an exported entry whose -/// name merely begins `unset ` is told from this. +/// The first `;` and newline end the name. tmux accepts a name holding that +/// pair, and `unset A;\nunset B;` is then one removal or two: the bytes are +/// the same either way, and this reads two. fn parse_unset(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> { let body = rest.strip_prefix(b"unset ")?; let end = body.windows(2).position(|pair| pair == b";\n")?; @@ -254,6 +259,76 @@ fn parse_exported(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> { )) } +/// Parse arbitrary `show-environment -s` output, then check that parsing +/// inverts tmux's rendering. +/// +/// The bytes are parsed as a listing first. Then, split at NUL, each chunk is +/// read as one variable -- its first byte's low bit picks set or removed, and +/// the rest splits at its first `=` into name and value -- rendered as +/// `cmd_show_environment_print` writes it, and parsed back. +/// +/// # Panics +/// +/// When a listing tmux could print does not parse back to its variables. +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub fn __fuzz_environment_listing(data: &[u8]) { + let _ = parse_shell_listing(data); + + let mut variables: BTreeMap<&[u8], Option<&[u8]>> = BTreeMap::new(); + for chunk in data.split(|&byte| byte == 0) { + let Some((&kind, rest)) = chunk.split_first() else { + continue; + }; + let (name, value) = match rest.iter().position(|&byte| byte == b'=') { + Some(equals) => (&rest[..equals], &rest[equals + 1..]), + None => (rest, &[][..]), + }; + let set = kind & 1 == 1; + // tmux refuses an empty name and one holding `=`. It accepts a + // removed name holding `;` and a newline, which prints exactly as two + // removed names do, so no parser can tell them apart. + if name.is_empty() || (!set && name.windows(2).any(|pair| pair == b";\n")) { + continue; + } + variables.insert(name, set.then_some(value)); + } + + // tmux prints in name order, which is this map's. + let mut wire = Vec::new(); + for (name, value) in &variables { + if let Some(value) = value { + wire.extend_from_slice(name); + wire.extend_from_slice(b"=\""); + for &byte in *value { + if matches!(byte, b'$' | b'`' | b'"' | b'\\') { + wire.push(b'\\'); + } + wire.push(byte); + } + wire.extend_from_slice(b"\"; export "); + wire.extend_from_slice(name); + wire.extend_from_slice(b";\n"); + } else { + wire.extend_from_slice(b"unset "); + wire.extend_from_slice(name); + wire.extend_from_slice(b";\n"); + } + } + + let expected: BTreeMap = variables + .iter() + .map(|(name, value)| { + let entry = match value { + Some(value) => EnvironmentEntry::Set(TmuxText::from(value.to_vec())), + None => EnvironmentEntry::Removed, + }; + (String::from_utf8_lossy(name).into_owned(), entry) + }) + .collect(); + assert_eq!(parse_shell_listing(&wire), Ok(expected), "{wire:?}"); +} + #[cfg(test)] mod tests { use super::parse_shell_listing; @@ -298,6 +373,21 @@ mod tests { assert_eq!(parsed["unset A"], set(b"v")); } + /// Found by the `environment_listing` fuzz target: a name tmux accepts, + /// beginning `unset ` and holding `;` and a newline, failed the whole + /// listing. + #[test] + fn an_exported_name_shaped_like_a_removal_stays_one_variable() { + let listing = b"unset X;\nY=\"v\"; export unset X;\nY;\n\ + unset A;\nB=\"w\"; export B;\n"; + let parsed = parse_shell_listing(listing).expect("listing parses"); + + assert_eq!(parsed.len(), 3, "{parsed:?}"); + assert_eq!(parsed["unset X;\nY"], set(b"v")); + assert_eq!(parsed["A"], EnvironmentEntry::Removed); + assert_eq!(parsed["B"], set(b"w")); + } + #[test] fn unframed_output_is_refused_not_guessed() { for listing in [ diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index ba40eb48..c872c270 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -375,6 +375,9 @@ pub use error::{ pub use formats::__fuzz_format_rows; pub use formats::TmuxText; pub use hooks::{IndexedHooks, ReplaceMode, SparseValues}; +#[cfg(feature = "unstable-fuzzing")] +#[doc(hidden)] +pub use internal::environment::__fuzz_environment_listing; #[cfg(feature = "control-mode")] pub use limits::{ControlClientLimits, ControlLimits}; pub use limits::{DispatchLimits, OutputLimits}; diff --git a/crates/libtmux/src/session/settings.rs b/crates/libtmux/src/session/settings.rs index ba90d827..62a5dc6a 100644 --- a/crates/libtmux/src/session/settings.rs +++ b/crates/libtmux/src/session/settings.rs @@ -437,7 +437,9 @@ impl Session { /// Costs one tmux command. The listing is read in tmux's shell form, /// which escapes each value, so a value containing a newline or an `=` /// is not mistaken for the next variable. Each value reads exactly as - /// [`Self::environment`] reads it. + /// [`Self::environment`] reads it. Names are not escaped: a removed name + /// holding `;` and a newline lists exactly as two removed names do, and + /// reads as them. /// /// # Errors /// diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ed85e5ca..1fe2c215 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -29,6 +29,13 @@ test = false doc = false bench = false +[[bin]] +name = "environment_listing" +path = "fuzz_targets/environment_listing.rs" +test = false +doc = false +bench = false + [[bin]] name = "filter_expr_json" path = "fuzz_targets/filter_expr_json.rs" diff --git a/fuzz/fuzz_targets/environment_listing.rs b/fuzz/fuzz_targets/environment_listing.rs new file mode 100644 index 00000000..f10d8992 --- /dev/null +++ b/fuzz/fuzz_targets/environment_listing.rs @@ -0,0 +1,12 @@ +//! The `show-environment -s` parser, fed arbitrary bytes. +//! +//! Names and values are whatever the server inherited or a client set, so the +//! parser reads bytes nobody in this process wrote. Beyond not panicking, a +//! listing rendered as tmux renders one must parse back to its variables. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + libtmux::__fuzz_environment_listing(data); +}); diff --git a/fuzz/seeds/environment_listing/escapes b/fuzz/seeds/environment_listing/escapes new file mode 100644 index 00000000..ca0bfaaf --- /dev/null +++ b/fuzz/seeds/environment_listing/escapes @@ -0,0 +1,5 @@ +V_BS="a\\b"; export V_BS; +V_DOLLAR="a\\$b"; export V_DOLLAR; +V_DQ="a\"b"; export V_DQ; +V_ESC="a\033b"; export V_ESC; +V_TAB="a b"; export V_TAB; diff --git a/fuzz/seeds/environment_listing/framing-decoys b/fuzz/seeds/environment_listing/framing-decoys new file mode 100644 index 00000000..a7523c2c --- /dev/null +++ b/fuzz/seeds/environment_listing/framing-decoys @@ -0,0 +1,6 @@ +MULTI="first +DECOY=x +\"; export X; +Y=\"z"; export MULTI; +unset GONE; +unset A="v"; export unset A; diff --git a/fuzz/seeds/environment_listing/variables b/fuzz/seeds/environment_listing/variables new file mode 100644 index 0000000000000000000000000000000000000000..a2dce6a6ec68fb24c936929ff04414c23d5aaff6 GIT binary patch literal 68 zcmZQ{b@2@G53()GWMFXj_j6@n3=H)TadojxR7z4wPDqJKWngsm4G4*}WnlF2^m7fi X&Cg5aDk;xrU@XlmPAyT0uq^`sTZ9sp literal 0 HcmV?d00001 From d331d421ad88e1a2c9248b07c618fc176f08c7e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:40:16 -0500 Subject: [PATCH 102/117] Server(fix[buffers]): List buffer names as framed bytes `buffer_names` returned `Vec` from an unframed `list-buffers -F '#{buffer_name}'` split on newlines, in a crate whose rule is that text from tmux is bytes. Whoever makes a buffer names it, and tmux before 3.7 stores any bytes (3.6a's paste.c has no `clean_name`): a name holding a newline listed as two names, and one that was not UTF-8 came back altered and could not be passed back. It now lists `#{q:buffer_name}=` through `split_quoted_rows` and returns `Vec`. That splitter accepted only raw `#{q:}` escapes, and `list-buffers` has no version floor, so 3.4 and 3.5 -- the `vis` releases -- are in range: the plan codec's escape decoder is now shared and takes the dialect, and `list-keys` passes `RawQ` because its `-F` arrived in 3.7. `buffer` and `delete_buffer` take `impl AsRef<[u8]>`, the shape name lookups already use, so a listed name passes back; a `&str` still does, and no caller in the workspace changed. `option_names` stays `Vec`, with the reason in its rustdoc. tmux's own names are ASCII and every option method takes `&str`, but a user option's name is caller-chosen: `@a b`, `@c\xffd` and `@n\nl` are all accepted, and `show-options` prints names raw with no `-F` on any release through 3.7b, so `@a b` lists as `@a`. Proof: `a_listed_buffer_name_passes_back_unchanged` lists `a=b c`, and `two\nlines` where tmux accepts it (3.2a, 3.5 and 3.6 run; 3.7d refuses the name). With the template unquoted it fails with `DecodeListing { .. UnexpectedRowTerminator .. }`. `quoted_rows_outside_the_catalog_decode_on_every_dialect` fails with `InvalidEscape` for `Vis` when the dialect is not threaded through. --- crates/libtmux/docs/migration.md | 17 ++++++++ crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 6 +-- crates/libtmux/src/formats.rs | 4 +- crates/libtmux/src/formats/row.rs | 59 +++++++++----------------- crates/libtmux/src/formats/tests.rs | 23 +++++++++- crates/libtmux/src/pane/settings.rs | 6 +++ crates/libtmux/src/server.rs | 57 ++++++++++++++++--------- crates/libtmux/src/server/keys.rs | 7 ++- crates/libtmux/src/server/settings.rs | 6 +++ crates/libtmux/src/session/settings.rs | 6 +++ crates/libtmux/src/window/settings.rs | 6 +++ crates/libtmux/tests/commands.rs | 43 +++++++++++++++++++ 13 files changed, 175 insertions(+), 67 deletions(-) diff --git a/crates/libtmux/docs/migration.md b/crates/libtmux/docs/migration.md index 95e1295b..8b347584 100644 --- a/crates/libtmux/docs/migration.md +++ b/crates/libtmux/docs/migration.md @@ -24,6 +24,23 @@ let seconds = created.duration_since(UNIX_EPOCH)?.as_secs(); The field handles are unchanged: `session.get(fields.session_created)` and a filter on it still see the `i64` tmux reports. +## Buffer names are `TmuxText` + +`Server::buffer_names` returns `Vec` in place of `Vec`. +`Server::buffer` and `Server::delete_buffer` take `impl AsRef<[u8]>`, so a +listed name passes back as it is, and a `&str` needs no change: + +```no_run +# async fn buffers(server: &libtmux::Server) -> Result<(), libtmux::Error> { +for name in server.buffer_names().await? { + // was: `name` was a `String`; `to_string_lossy` is the old reading. + println!("{}", name.to_string_lossy()); + server.delete_buffer(&name).await?; +} +# Ok(()) +# } +``` + ## `respawn` and `display_menu` take types, not literals ```no_run diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index 1daa0345..c9e79f3e 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -538,7 +538,7 @@ Sources: `src/libtmux/server.py`, `docs/api/libtmux.server.md`, | `display_message` | Returns lines only when `get_text=True`, otherwise `None`. Stderr emits `UserWarning` instead of raising. `no_expand` requires 3.4 and is warn-ignore below. | `Server::format`, `Session::format`, `Window::format`, and `Pane::format` read expanded text; each handle's `display` method shows it with loud errors. | Options, hooks, and advanced command families | `implemented` | | `show_prompt_history`, `clear_prompt_history` | Zero or more strings or unit; hard minimum 3.3; stderr raises. | `prompt_history(PromptKind)` and `clear_prompt_history()`, both refusing below tmux 3.3 with `Error::UnsupportedCapability`. The kind is required rather than defaulted: asking without one returns every kind at once, which is a different question. Verified against 3.2a, 3.3a, and 3.7b; covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | | `set_buffer`, `show_buffer`, `delete_buffer` | Unit, one newline-joined string, or unit; stderr raises. Joining loses whether a final newline existed. | `Server::set_buffer`, `Server::buffer`, and `Server::delete_buffer` preserve bytes rather than newline-joining text. | Options, hooks, and advanced command families | `implemented` | -| `list_buffers` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Server::buffer_names` lists names. Typed metadata and filter queries are not exposed. | Options, hooks, and advanced command families | `in progress` | +| `list_buffers` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Server::buffer_names` lists names as `TmuxText`, framed, so a name holding a newline is one name; covered by [`tests/commands.rs`](../tests/commands.rs). Typed metadata and filter queries are not exposed. | Options, hooks, and advanced command families | `in progress` | | `save_buffer`, `load_buffer` | Unit, unit, or zero or more raw strings; paths expand `~`; stderr raises. Malformed native filters can look empty. | `Server::load_buffer` and `Server::save_buffer`. They exist for the ceiling `Server::set_buffer` cannot clear: data carried as a command argument stops at `MAX_ARG_STRLEN`, 128 KiB on Linux, and a larger buffer fails with "argument list too long" before tmux sees it. tmux opens the file itself here. Covered by [`tests/server_command.rs`](../tests/server_command.rs). | Options, hooks, and advanced command families | `implemented` | | `source_file` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | `Server::source_file` is a loud typed request for enqueued source-file work. | Options, hooks, and advanced command families | `implemented` | | `if_shell` | Unit; stderr raises. Background `if_shell` reports enqueue success rather than branch completion. | Not offered. `if-shell` decides in tmux which tmux command to run next, so a typed wrapper would be a second, worse place to write a conditional that Rust already expresses: read the condition, then dispatch. Its background form reports only that the work was enqueued, which is the same promise `Server::spawn_shell` already makes honestly. `Server::cmd` reaches it. | Options, hooks, and advanced command families | `planned` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index fc58c56a..c4b73e42 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -509,8 +509,8 @@ function libtmux::Server::append_array_option: async fn(&self, name: &str, index function libtmux::Server::array_option: async fn(&self, name: &str) -> Result, libtmux::Error> function libtmux::Server::attached_sessions: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::bind_key: async fn(&self, table: &str, key: &str, command: impl Into) -> Result<(), libtmux::Error> -function libtmux::Server::buffer: async fn(&self, name: &str) -> Result>, libtmux::Error> -function libtmux::Server::buffer_names: async fn(&self) -> Result, libtmux::Error> +function libtmux::Server::buffer: async fn(&self, name: impl AsRef<[u8]>) -> Result>, libtmux::Error> +function libtmux::Server::buffer_names: async fn(&self) -> Result, libtmux::Error> function libtmux::Server::builder: fn() -> libtmux::ServerBuilder function libtmux::Server::capabilities: async fn(&self) -> Result<&libtmux::EngineCapabilities, libtmux::Error> function libtmux::Server::chain: async fn(&self, chain: libtmux::CommandChain) -> Result @@ -524,7 +524,7 @@ function libtmux::Server::colors: fn(&self) -> Option function libtmux::Server::command_prompt: async fn(&self, client: Option<&libtmux::Client>, prompt: Option<&str>, command: impl Into) -> Result<(), libtmux::Error> function libtmux::Server::config_file: fn(&self) -> Option<&Path> function libtmux::Server::default_timeout: fn(&self) -> Duration -function libtmux::Server::delete_buffer: async fn(&self, name: &str) -> Result<(), libtmux::Error> +function libtmux::Server::delete_buffer: async fn(&self, name: impl AsRef<[u8]>) -> Result<(), libtmux::Error> function libtmux::Server::display_menu: async fn(&self, client: Option<&libtmux::Client>, title: &str, items: impl IntoIterator) -> Result<(), libtmux::Error> function libtmux::Server::display_panes: async fn(&self, client: Option<&libtmux::Client>) -> Result<(), libtmux::Error> function libtmux::Server::display_popup: async fn(&self, client: Option<&libtmux::Client>, command: impl Into) -> Result<(), libtmux::Error> diff --git a/crates/libtmux/src/formats.rs b/crates/libtmux/src/formats.rs index db71303a..83f9d0a5 100644 --- a/crates/libtmux/src/formats.rs +++ b/crates/libtmux/src/formats.rs @@ -10,9 +10,9 @@ mod text; #[cfg(feature = "unstable-fuzzing")] pub use fuzz::__fuzz_format_rows; -pub(crate) use plan::{FormatPlan, PlanFieldState, PlanPurpose}; +pub(crate) use plan::{FormatPlan, PlanFieldState, PlanPurpose, TransportDialect}; #[cfg(test)] -use plan::{PlanVersion, TransportDialect, for_profile_selection_test}; +use plan::{PlanVersion, for_profile_selection_test}; #[cfg(test)] pub(crate) use row::{FIELD_SEPARATOR, decode_ascii}; pub(crate) use row::{FormatCodecError, FormatCodecErrorKind, ParsedRow, ParsedSlot, decode_text}; diff --git a/crates/libtmux/src/formats/row.rs b/crates/libtmux/src/formats/row.rs index e60c6c92..3ffb7b3c 100644 --- a/crates/libtmux/src/formats/row.rs +++ b/crates/libtmux/src/formats/row.rs @@ -118,7 +118,16 @@ impl FormatPlan { if byte == b'\\' { *cursor += 1; - Self::decode_escape(stdout, cursor, row, field, descriptor, dialect, bytes)?; + Self::decode_escape(stdout, cursor, dialect, bytes, |kind, offset| { + FormatCodecError::framing( + kind, + FormatCodecPhase::Escape, + row, + field, + descriptor, + offset, + ) + })?; } else if byte == FIELD_SEPARATOR { *cursor += 1; return Ok(SlotMeta { @@ -141,23 +150,10 @@ impl FormatPlan { fn decode_escape( stdout: &[u8], cursor: &mut usize, - row: usize, - field: usize, - descriptor: &'static FormatDescriptor, dialect: TransportDialect, bytes: &mut Vec, + framing: impl Fn(FormatCodecErrorKind, usize) -> FormatCodecError, ) -> Result<(), FormatCodecError> { - let framing = |kind, offset| { - FormatCodecError::framing( - kind, - FormatCodecPhase::Escape, - row, - field, - descriptor, - offset, - ) - }; - let Some(escaped) = stdout.get(*cursor).copied() else { return Err(framing(FormatCodecErrorKind::DanglingEscape, stdout.len())); }; @@ -730,12 +726,12 @@ pub(crate) fn decode_text(slot: ParsedSlot<'_>) -> TmuxText { /// /// Each of `names` was rendered `#{q:name}` or bare, followed by /// [`FIELD_SEPARATOR`], and each row ends with LF, as in a plan's template. -/// Only [`TransportDialect::RawQ`] escapes are accepted, so a caller needs a -/// release outside the `vis` range. A row that does not frame fails the -/// whole listing: nothing is skipped. +/// Escapes decode as a plan's do in `dialect`. A row that does not frame +/// fails the whole listing: nothing is skipped. pub(crate) fn split_quoted_rows( stdout: &[u8], names: [&'static str; N], + dialect: TransportDialect, ) -> Result; N]>, FormatCodecError> { let mut cursor = 0; let mut rows = Vec::new(); @@ -766,26 +762,13 @@ pub(crate) fn split_quoted_rows( )); } FIELD_SEPARATOR => break, - b'\\' => match stdout.get(cursor).copied() { - Some(escaped) if QUOTE_SHELL_SPECIALS.contains(&escaped) => { - bytes.push(escaped); - cursor += 1; - } - Some(_) => { - return Err(error( - FormatCodecErrorKind::InvalidEscape, - FormatCodecPhase::Escape, - cursor, - )); - } - None => { - return Err(error( - FormatCodecErrorKind::DanglingEscape, - FormatCodecPhase::Escape, - cursor, - )); - } - }, + b'\\' => FormatPlan::decode_escape( + stdout, + &mut cursor, + dialect, + bytes, + |kind, offset| error(kind, FormatCodecPhase::Escape, offset), + )?, _ => bytes.push(byte), } } diff --git a/crates/libtmux/src/formats/tests.rs b/crates/libtmux/src/formats/tests.rs index b2481553..5b18c800 100644 --- a/crates/libtmux/src/formats/tests.rs +++ b/crates/libtmux/src/formats/tests.rs @@ -9,7 +9,7 @@ use super::{ PANE_INFO_SUPPLEMENTS, ParsedRow, ParsedSlot, PlanFieldState, PlanPurpose, PlanVersion, ProfileSet, QUOTE_SHELL_SPECIALS, RequiredContext, SESSION_ID, SESSION_INFO_DESCRIPTORS, SESSION_INFO_SUPPLEMENTS, SemanticOwner, TransportDialect, WINDOW_ID, WINDOW_INFO_DESCRIPTORS, - WINDOW_INFO_SUPPLEMENTS, encode_like_tmux, for_profile_selection_test, + WINDOW_INFO_SUPPLEMENTS, encode_like_tmux, for_profile_selection_test, split_quoted_rows, }; #[cfg(feature = "test-support")] use crate::Command; @@ -165,6 +165,27 @@ fn format_codec_recovers_adversarial_bytes_on_every_supported_dialect() { } } +/// A listing outside the catalog, such as `list-buffers`, decodes as a plan +/// does: a buffer name tmux before 3.7 stores unchecked can hold every byte +/// in the adversarial value. +#[test] +fn quoted_rows_outside_the_catalog_decode_on_every_dialect() { + for (dialect, wire) in [ + (TransportDialect::RawQ, RAW_Q_WIRE.as_slice()), + (TransportDialect::Vis, VIS_WIRE.as_slice()), + ] { + assert_eq!( + split_quoted_rows(wire, ["buffer_name"], dialect), + Ok(vec![[ADVERSARIAL_VALUE.to_vec()]]), + "{dialect:?}", + ); + } + assert!( + split_quoted_rows(&VIS_WIRE, ["buffer_name"], TransportDialect::RawQ).is_err(), + "a raw reading refuses `vis` escapes rather than decoding them differently", + ); +} + #[test] fn format_codec_rejects_vis_output_when_the_daemon_predates_the_probe() { // The version probe reads the client executable, but the daemon that diff --git a/crates/libtmux/src/pane/settings.rs b/crates/libtmux/src/pane/settings.rs index c64a1bb1..f07b282f 100644 --- a/crates/libtmux/src/pane/settings.rs +++ b/crates/libtmux/src/pane/settings.rs @@ -15,6 +15,12 @@ impl Pane { /// different quoting styles, so re-parsing them would be guesswork. Read /// each value with [`Self::typed_option`], which decodes the exact bytes. /// + /// Names are `String`, not [`TmuxText`](crate::TmuxText), because tmux's + /// own are ASCII and every option method takes one as `&str`; a user + /// option (`@name`) lists cut at any whitespace in its name, and with + /// U+FFFD for bytes that are not UTF-8, since `show-options` prints names + /// unframed. + /// /// # Errors /// /// Returns an error when tmux refuses the listing. diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index bff2b21e..95789b92 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1,13 +1,14 @@ use std::ffi::{OsStr, OsString}; use std::fmt; use std::hash::{Hash, Hasher}; +use std::os::unix::ffi::OsStringExt as _; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; #[cfg(feature = "control-mode")] use crate::SessionId; -use crate::formats::TmuxText; +use crate::formats::{TmuxText, TransportDialect, split_quoted_rows}; use crate::internal::core::Core; use crate::internal::listing; #[cfg(feature = "control-mode")] @@ -16,8 +17,9 @@ use crate::internal::scoped; use crate::pane::Pane; use crate::session::Session; use crate::{ - Command, CommandChain, CommandResult, EngineCapabilities, Error, ReleaseSuffix, ReleaseVersion, - ServerConfigurationErrorKind, ServerGeneration, ServerIdentity, TmuxArg, + Command, CommandChain, CommandResult, EngineCapabilities, Error, ListingDecodeError, + ReleaseSuffix, ReleaseVersion, ServerConfigurationErrorKind, ServerGeneration, ServerIdentity, + TmuxArg, }; mod builder; @@ -794,7 +796,7 @@ impl Server { /// Returns an error when the value is absent, empty, or not shaped like /// tmux's triple, and when the socket path it names is unusable. pub fn from_env_value(value: Option>) -> Result { - use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; + use std::os::unix::ffi::OsStrExt as _; let value: OsString = value.map(Into::into).ok_or_else(|| { Error::invalid_server_configuration(ServerConfigurationErrorKind::NotInsideTmux) @@ -991,17 +993,18 @@ impl Server { /// Read a paste buffer's exact bytes. /// /// Returns `None` when no buffer has that name. Buffer contents are - /// arbitrary bytes, so this is not a string. + /// arbitrary bytes, so this is not a string. The name is bytes too, so + /// one [`Self::buffer_names`] lists passes back as it is. /// /// # Errors /// /// Returns an error when tmux cannot be reached. - pub async fn buffer(&self, name: &str) -> Result>, Error> { + pub async fn buffer(&self, name: impl AsRef<[u8]>) -> Result>, Error> { let result = self .cmd( Command::new("show-buffer") .arg("-b") - .arg(OsString::from(name)), + .arg(OsString::from_vec(name.as_ref().to_vec())), ) .await?; @@ -1016,17 +1019,23 @@ impl Server { /// List the paste buffer names. /// - /// A name containing a newline cannot be told apart from two names, - /// because tmux separates them with newlines and offers no framed form - /// for this listing. Names come from [`Server::set_buffer`], so a caller - /// that avoids newlines avoids the ambiguity. + /// A name is [`TmuxText`] because whoever made the buffer chose it: tmux + /// before 3.7 stores any bytes, a newline included. Each name is read + /// framed, so one holding a newline or `=` is one name, and + /// [`Self::buffer`] and [`Self::delete_buffer`] take it back unchanged. /// /// # Errors /// - /// Returns an error when tmux refuses the listing. - pub async fn buffer_names(&self) -> Result, Error> { + /// Returns an error when tmux refuses the listing, and + /// [`Error::DecodeListing`] when its output does not decode. + pub async fn buffer_names(&self) -> Result, Error> { + let dialect = TransportDialect::for_version(self.capabilities().await?.tmux_version()); let result = self - .cmd(Command::new("list-buffers").arg("-F").arg("#{buffer_name}")) + .cmd( + Command::new("list-buffers") + .arg("-F") + .arg("#{q:buffer_name}="), + ) .await?; if !result.success() { return Err(Error::CommandFailed { @@ -1036,25 +1045,33 @@ impl Server { }); } - Ok(result - .stdout_lossy() - .lines() - .map(ToOwned::to_owned) + let rows = + split_quoted_rows(result.stdout(), ["buffer_name"], dialect).map_err(|detail| { + Error::DecodeListing { + list_command: "list-buffers", + detail: ListingDecodeError::new(detail), + } + })?; + Ok(rows + .into_iter() + .map(|[name]| TmuxText::from(name)) .collect()) } /// Delete one paste buffer. /// + /// The name is bytes, as [`Self::buffer_names`] lists it. + /// /// # Errors /// /// Returns an error when no buffer has that name. - pub async fn delete_buffer(&self, name: &str) -> Result<(), Error> { + pub async fn delete_buffer(&self, name: impl AsRef<[u8]>) -> Result<(), Error> { listing::mutate( &self.core, "delete-buffer", Command::new("delete-buffer") .arg("-b") - .arg(OsString::from(name)), + .arg(OsString::from_vec(name.as_ref().to_vec())), ) .await } diff --git a/crates/libtmux/src/server/keys.rs b/crates/libtmux/src/server/keys.rs index 4a18a1ac..18c85918 100644 --- a/crates/libtmux/src/server/keys.rs +++ b/crates/libtmux/src/server/keys.rs @@ -1,7 +1,9 @@ //! Key bindings read as fields through `list-keys -F`. use super::Server; -use crate::formats::{FormatCodecError, FormatCodecErrorKind, FormatCodecPhase, TmuxText}; +use crate::formats::{ + FormatCodecError, FormatCodecErrorKind, FormatCodecPhase, TmuxText, TransportDialect, +}; use crate::version::since::LIST_KEYS_FORMAT; use crate::{Command, Error, ListingDecodeError}; @@ -101,7 +103,8 @@ impl KeyBinding { /// A row that does not frame, or whose repeat flag is neither `0` nor `1`, /// fails the whole listing rather than being skipped. fn parse(stdout: &[u8]) -> Result, FormatCodecError> { - crate::formats::split_quoted_rows(stdout, FIELDS)? + // `list-keys -F` arrived in 3.7, after the `vis` releases. + crate::formats::split_quoted_rows(stdout, FIELDS, TransportDialect::RawQ)? .into_iter() .enumerate() .map(|(row, [table, key, repeat, note, command])| { diff --git a/crates/libtmux/src/server/settings.rs b/crates/libtmux/src/server/settings.rs index 50a46d59..6a136cc1 100644 --- a/crates/libtmux/src/server/settings.rs +++ b/crates/libtmux/src/server/settings.rs @@ -35,6 +35,12 @@ impl Server { /// different quoting styles, so re-parsing them would be guesswork. Read /// each value with [`Self::typed_option`], which decodes the exact bytes. /// + /// Names are `String`, not [`TmuxText`](crate::TmuxText), because tmux's + /// own are ASCII and every option method takes one as `&str`; a user + /// option (`@name`) lists cut at any whitespace in its name, and with + /// U+FFFD for bytes that are not UTF-8, since `show-options` prints names + /// unframed. + /// /// # Errors /// /// Returns an error when tmux refuses the listing. diff --git a/crates/libtmux/src/session/settings.rs b/crates/libtmux/src/session/settings.rs index 62a5dc6a..635d0f05 100644 --- a/crates/libtmux/src/session/settings.rs +++ b/crates/libtmux/src/session/settings.rs @@ -16,6 +16,12 @@ impl Session { /// different quoting styles, so re-parsing them would be guesswork. Read /// each value with [`Self::typed_option`], which decodes the exact bytes. /// + /// Names are `String`, not [`TmuxText`](crate::TmuxText), because tmux's + /// own are ASCII and every option method takes one as `&str`; a user + /// option (`@name`) lists cut at any whitespace in its name, and with + /// U+FFFD for bytes that are not UTF-8, since `show-options` prints names + /// unframed. + /// /// # Errors /// /// Returns an error when tmux refuses the listing. diff --git a/crates/libtmux/src/window/settings.rs b/crates/libtmux/src/window/settings.rs index d3f5d435..9665cf69 100644 --- a/crates/libtmux/src/window/settings.rs +++ b/crates/libtmux/src/window/settings.rs @@ -15,6 +15,12 @@ impl Window { /// different quoting styles, so re-parsing them would be guesswork. Read /// each value with [`Self::typed_option`], which decodes the exact bytes. /// + /// Names are `String`, not [`TmuxText`](crate::TmuxText), because tmux's + /// own are ASCII and every option method takes one as `&str`; a user + /// option (`@name`) lists cut at any whitespace in its name, and with + /// U+FFFD for bytes that are not UTF-8, since `show-options` prints names + /// unframed. + /// /// # Errors /// /// Returns an error when tmux refuses the listing. diff --git a/crates/libtmux/tests/commands.rs b/crates/libtmux/tests/commands.rs index 0885e4c7..6422eb6a 100644 --- a/crates/libtmux/tests/commands.rs +++ b/crates/libtmux/tests/commands.rs @@ -8,6 +8,49 @@ use std::time::Duration; use libtmux::test::{TestServer, retry_until}; use libtmux::{ChannelWait, Command, NewWindowOptions, SplitDirection, SplitOptions}; +#[tokio::test] +async fn a_listed_buffer_name_passes_back_unchanged() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + + // `=` ends a field in the framed listing and a space is shell-quoted; + // neither may split or alter a name. + let mut expected = vec![libtmux::TmuxText::from("a=b c")]; + server + .set_buffer(Some("a=b c"), "framed") + .await + .expect("buffer is stored"); + // tmux before 3.7 stores a newline in a name; 3.7 refuses one. + let multiline = server + .cmd( + Command::new("set-buffer") + .arg("-b") + .arg("two\nlines") + .arg("--") + .arg("x"), + ) + .await + .expect("tmux answers"); + if multiline.success() { + expected.push(libtmux::TmuxText::from("two\nlines")); + } + + let mut names = server.buffer_names().await.expect("names"); + names.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + assert_eq!(names, expected); + + for name in &names { + assert!( + server.buffer(name).await.expect("read").is_some(), + "{name:?}" + ); + server.delete_buffer(name).await.expect("buffer is deleted"); + } + assert!(server.buffer_names().await.expect("names").is_empty()); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[tokio::test] async fn buffers_hold_exact_bytes_and_report_absence() { let guard = TestServer::builder().start().await.expect("tmux starts"); From f2a1a6f7b7d483be32ea0523f75f041b0dca134e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:45:11 -0500 Subject: [PATCH 103/117] Examples(fix[matrix]): Count the processes each mode starts The `processes` column was assigned: `result.dispatches()` for a subprocess row and `1` for control mode. The README publishes that column and `just example-tables` gates it, so the gate compared a constant with itself -- a dispatch that spawned two processes, or a control-mode run that fell back to subprocesses, printed the same table. Each row now runs its fixture with `tmux_executable` pointed at a wrapper that appends a line to a log and execs the real tmux (`LIBTMUX_TEST_TMUX`, else `PATH`), and reports the lines its run added: from just before `plan.run`, or just before the attach for the control-mode rows so the connection's own process counts. The numbers did not move -- 6, 6, 3, 3, 1 -- and are now measured. A new `control-mode/routed` row runs the same plan with `Plan::run` on a `Server::over_control_mode` handle: six dispatches, one process, the route the whole typed API can take. `waits` and tmux-mcp still have no such count (the rest of A4.2). Proof: with the streaming row sent through `plan.run(server, ..)`, `just example-tables` fails with "control-mode/streaming ... 6 7" where the block shows "6 1", and "processes ranged 1..7". Three clean runs matched. --- README.md | 7 +- crates/libtmux/examples/matrix.rs | 130 ++++++++++++++++++++++++++---- crates/libtmux/src/lib.rs | 2 +- 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5ef2bac6..73fd4783 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,9 @@ a CLI, a config file, or an MCP tool call can carry one. Three switches, each a Cargo feature, none of them the default. The same workload under each, printed by `cargo run --example matrix --all-features`. Every column but `wall` is exact and checked against this block by -`just example-tables`; the timings are one run on one developer machine: +`just example-tables`; the timings are one run on one developer machine. +`processes` is counted: the example runs tmux through a wrapper that logs +each start. `routed` is `Plan::run` on a `Server::over_control_mode` handle: @@ -201,7 +203,8 @@ blocking/sequential plan 6 6 16ms per-c async/sequential plan 6 6 16ms per-command 2 panes, 2 windows, 2 active async/folded plan 3 3 9ms merged 2 panes, 2 windows, 2 active async/marked-fold plan 3 3 10ms merged 2 panes, 2 windows, 2 active -control-mode/streaming plan,control-mode 6 1 6ms per-command 2 panes, 2 windows, 2 active +control-mode/streaming plan,control-mode 6 1 10ms per-command 2 panes, 2 windows, 2 active +control-mode/routed plan,control-mode 6 1 6ms per-command 2 panes, 2 windows, 2 active every mode built the same thing: true dispatches ranged 3..6, processes ranged 1..6 diff --git a/crates/libtmux/examples/matrix.rs b/crates/libtmux/examples/matrix.rs index 9982ac63..eda9f92c 100644 --- a/crates/libtmux/examples/matrix.rs +++ b/crates/libtmux/examples/matrix.rs @@ -1,6 +1,6 @@ //! One workload, every execution mode, side by side. //! -//! The same plan runs five ways. What changes is the price and what the run +//! The same plan runs six ways. What changes is the price and what the run //! can prove; what does not change is the tmux state it leaves or the query //! that reads it back. Run it with: //! @@ -8,16 +8,22 @@ //! $ cargo run --example matrix \ //! --features plan,control-mode,blocking,test-support,query //! ``` +//! +//! The `processes` column is counted, not asserted: every tmux the example +//! starts goes through a wrapper that logs each start before it runs the real +//! executable, and a row reports how many lines its run added. #![allow(clippy::expect_used, clippy::print_stdout, reason = "an example")] +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use libtmux::plan::{ Attribution, NewSession, NewWindow, Outcome, Plan, Planner, SelectPane, SendKeys, SetOption, }; use libtmux::query::{Filterable as _, QueryIteratorExt as _}; -use libtmux::test::TestServer; +use libtmux::test::{TestServer, TestServerBuilder}; use libtmux::{Server, blocking}; /// One row of the comparison. @@ -31,6 +37,66 @@ struct Row { query: String, } +/// A `tmux` that logs each start, then runs the real one in its place. +/// +/// The log gains one line per process, whatever its arguments hold, so a +/// mode that fell back to a subprocess, or spent two where it claims one, +/// changes the table rather than only the prose around it. +struct Witness { + directory: tempfile::TempDir, +} + +impl Witness { + fn new() -> Self { + let real = real_tmux(); + let directory = tempfile::tempdir().expect("a scratch directory"); + let witness = Self { directory }; + let script = format!( + "#!/bin/sh\nprintf 'start\\n' >> '{log}'\nexec '{real}' \"$@\"\n", + log = witness.log().display(), + real = real.display(), + ); + std::fs::write(witness.executable(), script).expect("the wrapper is written"); + std::fs::set_permissions(witness.executable(), std::fs::Permissions::from_mode(0o755)) + .expect("the wrapper is executable"); + std::fs::write(witness.log(), "").expect("the log starts empty"); + witness + } + + fn executable(&self) -> PathBuf { + self.directory.path().join("tmux") + } + + fn log(&self) -> PathBuf { + self.directory.path().join("started.log") + } + + /// A fixture whose every tmux, daemon and clients alike, is this wrapper. + fn server(&self) -> TestServerBuilder { + TestServer::builder().tmux_executable(self.executable()) + } + + /// How many tmux processes have started so far. + fn started(&self) -> usize { + std::fs::read_to_string(self.log()) + .expect("the log is readable") + .lines() + .count() + } +} + +/// The tmux the fixture would have run: `LIBTMUX_TEST_TMUX`, else `PATH`. +fn real_tmux() -> PathBuf { + if let Some(pinned) = std::env::var_os("LIBTMUX_TEST_TMUX") { + return PathBuf::from(pinned); + } + let path = std::env::var_os("PATH").expect("PATH is set"); + std::env::split_paths(&path) + .map(|directory| directory.join("tmux")) + .find(|candidate| Path::is_file(candidate)) + .expect("tmux is on PATH") +} + /// The workload: build a session, split it, decorate the new pane, focus back. /// /// It is deliberately ordinary. The point is not that it is clever but that @@ -57,8 +123,8 @@ fn workload(name: &str) -> Plan { /// This is the "query output" column: if a mode changed what was built, this /// is where it would show. async fn query(server: &Server, session: &str) -> String { - // Scoped to the session the workload built: the control-mode row needs a - // session of its own to attach to, and counting the whole server would + // Scoped to the session the workload built: the control-mode rows need a + // session of their own to attach to, and counting the whole server would // compare that fixture rather than the work. let session = server .session(session) @@ -95,13 +161,16 @@ fn fidelity(outcomes: impl IntoIterator, attribution: Attributio } async fn run_subprocess(mode: &'static str, planner: Planner, name: &str) -> Row { - let guard = TestServer::builder().start().await.expect("tmux starts"); + let witness = Witness::new(); + let guard = witness.server().start().await.expect("tmux starts"); let server = guard.server(); let plan = workload(name); + let before = witness.started(); let started = Instant::now(); let result = plan.run(server, planner).await.expect("the plan runs"); let elapsed = started.elapsed(); + let processes = witness.started() - before; let attribution = result .steps() @@ -118,8 +187,7 @@ async fn run_subprocess(mode: &'static str, planner: Planner, name: &str) -> Row mode, feature: "plan", dispatches: result.dispatches(), - // A subprocess transport spends one tmux client per invocation. - processes: result.dispatches(), + processes, elapsed, attribution: fidelity( result @@ -135,8 +203,19 @@ async fn run_subprocess(mode: &'static str, planner: Planner, name: &str) -> Row row } -async fn run_control_mode(name: &str) -> Row { - let guard = TestServer::builder().start().await.expect("tmux starts"); +/// Which way a plan reaches tmux over one control-mode connection. +#[derive(Clone, Copy)] +enum Connection { + /// `Plan::run_over_control_mode`, a plan-only route. + Streaming, + /// `Plan::run` on a handle from `Server::over_control_mode`, the route + /// the whole typed API can take. + Routed, +} + +async fn run_control_mode(connection: Connection, name: &str) -> Row { + let witness = Witness::new(); + let guard = witness.server().start().await.expect("tmux starts"); let server = guard.server(); // Control mode attaches to a session, so the plan cannot be the thing that @@ -146,6 +225,9 @@ async fn run_control_mode(name: &str) -> Row { .new_session("control-host") .await .expect("host session"); + + // Counted from here, so the connection's own process is in the row. + let before = witness.started(); let control = libtmux::control::ControlMode::attach(server, host.id()) .await .expect("control mode attaches"); @@ -153,18 +235,31 @@ async fn run_control_mode(name: &str) -> Row { let plan = workload(name); let started = Instant::now(); - let result = plan - .run_over_control_mode(&sender) - .await - .expect("the plan runs"); + let (mode, result) = match connection { + Connection::Streaming => ( + "control-mode/streaming", + plan.run_over_control_mode(&sender).await, + ), + Connection::Routed => { + let routed = server + .over_control_mode(&sender) + .await + .expect("the connection reaches this server"); + ( + "control-mode/routed", + plan.run(&routed, Planner::Sequential).await, + ) + } + }; + let result = result.expect("the plan runs"); let elapsed = started.elapsed(); + let processes = witness.started() - before; let row = Row { - mode: "control-mode/streaming", + mode, feature: "plan,control-mode", dispatches: result.dispatches(), - // Every block shares one connection, so the whole plan costs one. - processes: 1, + processes, elapsed, attribution: fidelity( result @@ -196,7 +291,8 @@ async fn main() { run_subprocess("async/sequential", Planner::Sequential, "async-seq").await, run_subprocess("async/folded", Planner::Folding, "async-fold").await, run_subprocess("async/marked-fold", Planner::Marked, "async-marked").await, - run_control_mode("control").await, + run_control_mode(Connection::Streaming, "control").await, + run_control_mode(Connection::Routed, "routed").await, ]; // The blocking runtime owns a reactor, so it cannot be built inside one. rows.insert( diff --git a/crates/libtmux/src/lib.rs b/crates/libtmux/src/lib.rs index c872c270..4df6d338 100644 --- a/crates/libtmux/src/lib.rs +++ b/crates/libtmux/src/lib.rs @@ -157,7 +157,7 @@ //! sent, output waited for rather than slept on, and a scope that kills the //! session whether the body succeeded or not. `watch` reacts to what a server //! does over one control-mode connection while driving it down the same one. -//! `matrix` runs one workload five ways, so the cost of each execution mode is +//! `matrix` runs one workload six ways, so the cost of each execution mode is //! visible side by side. `sweep` reaps servers that abandoned fixtures left //! behind, which is maintenance rather than orchestration. //! From ebdef7710fa456d6e013fb3fc37cb2e7472d73cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:50:37 -0500 Subject: [PATCH 104/117] Plan(feat[pause]): Wait between a plan's operations tmuxp's `sleep_before`/`sleep_after` had nowhere to go: `Plan::run` dispatches start to finish, and a plan cannot be split and resumed because a later half cannot address what the first half created (N10). tmux already has the pause. `run-shell -d SECONDS` with no command waits and runs nothing, from 3.2a on (`bd:Ct:`), and returns CMD_RETURN_WAIT, so it holds the client. Measured on 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7, 3.7c and 3.8-rc: 0.3 s alone, and `display a ; run-shell -d 0.3 ; display b` printed both lines after 0.3 s -- the wait holds inside a chain. So `Pause` is an ordinary operation: chainable, read-only, and a folded plan pauses where an unfolded one does. On the wire it is `{"Pause": {"seconds": 1.5}}`, tmuxp's unit; a negative or oversized number fails to deserialize. A control-mode connection cannot carry it. Over `tmux -C`, the pause's `%end` arrived at 1 ms and the next command's at 600 ms: the same early guard as `wait-for` (N19). Both control-mode routes refuse a plan holding a pause with `BlockingCommand` before sending anything. Proof: `a_pause_holds_the_plan_on_every_planner` fails with "Sequential returned after 7.492133ms" when the delay flag is not rendered; `a_pause_is_refused_over_control_mode` fails when the refusal returns `Ok`. --- crates/libtmux/docs/parity.md | 2 +- crates/libtmux/docs/public-api.txt | 14 ++++ crates/libtmux/src/error.rs | 8 +-- crates/libtmux/src/plan.rs | 9 ++- crates/libtmux/src/plan/ops.rs | 2 + crates/libtmux/src/plan/ops/timing.rs | 95 +++++++++++++++++++++++++++ crates/libtmux/src/plan/run.rs | 30 +++++++++ crates/libtmux/src/plan/wire.rs | 16 +++++ crates/libtmux/src/server.rs | 6 ++ crates/libtmux/tests/plan.rs | 89 ++++++++++++++++++++++++- 10 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 crates/libtmux/src/plan/ops/timing.rs diff --git a/crates/libtmux/docs/parity.md b/crates/libtmux/docs/parity.md index c9e79f3e..1303dbd1 100644 --- a/crates/libtmux/docs/parity.md +++ b/crates/libtmux/docs/parity.md @@ -522,7 +522,7 @@ Sources: `src/libtmux/server.py`, `docs/api/libtmux.server.md`, | `has_session` | One bool; validates the name and optionally forces exact matching. Ordinary nonzero status, including a dead server, becomes false; launch failures propagate. | `Server::has_session` validates a `SessionName` and returns a loud bool. | Discovery, traversal, refresh, and environment resolution | `implemented` | | `kill`, `kill_session` | Unit or the same Server. `kill` ignores recognized already-dead errors. `kill_session` performs no documented name validation and accepts integers. | `Server::kill` and `Session::kill` are loud unit operations over typed handles. | Object mutations and interactions | `implemented` | | `new_session` | Exactly one hydrated Session. Validates explicit names, detects collisions, optionally kills the old session, and raises on stderr. Extra args and kwargs are ignored. It temporarily mutates process-global `$TMUX`. | `Server::new_session` returns a hydrated `Session`, uses command-local environment, and rejects unknown options. | Object mutations and interactions | `implemented` | -| `run_shell` | Foreground returns stdout lines; background returns `None`; stderr raises. `cwd` requires 3.4, `show_stderr` 3.6, and positional args 3.7; unsupported options warn and disappear. | `Server::run_shell` returns the command's output and `Server::spawn_shell` runs it without waiting; covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | +| `run_shell` | Foreground returns stdout lines; background returns `None`; stderr raises. `cwd` requires 3.4, `show_stderr` 3.6, and positional args 3.7; unsupported options warn and disappear. | `Server::run_shell` returns the command's output and `Server::spawn_shell` runs it without waiting; covered by [`tests/commands.rs`](../tests/commands.rs). `plan::Pause` is `run-shell -d` with no command, a wait between a plan's operations; covered by [`tests/plan.rs`](../tests/plan.rs). | Options, hooks, and advanced command families | `implemented` | | `wait_for` | Unit; default can block indefinitely. Lock, unlock, and set actions are independent booleans without mutual-exclusion validation. | `Server::signal_channel`, `lock_channel`, `unlock_channel`, and `wait_for_channel` cover `wait-for -S`, `-L`, `-U`, and the flagless blocking form. The wait is bounded rather than indefinite, and running out of time is `ChannelWait::TimedOut` rather than an error, so a caller separates it from a failure to reach tmux. | Options, hooks, and advanced command families | `implemented` | | `bind_key`, `unbind_key` | Unit; stderr raises. `unbind_key(None, all_keys=False)` is forwarded for tmux to reject. | `Server::bind_key` and `Server::unbind_key`, both taking the key table by name. | Options, hooks, and advanced command families | `implemented` | | `list_keys` | Zero or more raw strings; stderr raises. `list_keys(format_=...)` requires 3.7 and is warn-ignore below. | `Server::key_bindings` returns the `bind-key` lines on every release. `Server::typed_key_bindings` reads `list-keys -F` into `KeyBinding` fields on tmux 3.7 and later (`since::LIST_KEYS_FORMAT`) and refuses below it; it narrows a table itself, because 3.7 through 3.7c print nothing for a one-binding `list-keys -T`. A free-form template is not exposed. Covered by [`tests/commands.rs`](../tests/commands.rs). | Options, hooks, and advanced command families | `implemented` | diff --git a/crates/libtmux/docs/public-api.txt b/crates/libtmux/docs/public-api.txt index c4b73e42..d9a569b3 100644 --- a/crates/libtmux/docs/public-api.txt +++ b/crates/libtmux/docs/public-api.txt @@ -818,6 +818,8 @@ function libtmux::plan::OperationReport::kind: const fn(&self) -> libtmux::plan: function libtmux::plan::OperationReport::outcome: const fn(&self) -> libtmux::plan::Outcome function libtmux::plan::OperationReport::value: const fn(&self) -> Option<&libtmux::plan::OperationValue> function libtmux::plan::Outcome::is_complete: const fn(self) -> bool +function libtmux::plan::Pause::duration: const fn(&self) -> Duration +function libtmux::plan::Pause::new: const fn(duration: Duration) -> Self function libtmux::plan::Plan::add: fn(&mut self, operation: O) -> ::Creates function libtmux::plan::Plan::chain: fn(&mut self, operation: O) -> ::Creates function libtmux::plan::Plan::is_empty: fn(&self) -> bool @@ -1093,6 +1095,7 @@ impl Clone for libtmux::plan::OperationValue impl Clone for libtmux::plan::Outcome impl Clone for libtmux::plan::PaneSlot impl Clone for libtmux::plan::PaneTarget +impl Clone for libtmux::plan::Pause impl Clone for libtmux::plan::Plan impl Clone for libtmux::plan::PlanResult impl Clone for libtmux::plan::PlanValidationError @@ -1276,6 +1279,7 @@ impl Debug for libtmux::plan::OperationValue impl Debug for libtmux::plan::Outcome impl Debug for libtmux::plan::PaneSlot impl Debug for libtmux::plan::PaneTarget +impl Debug for libtmux::plan::Pause impl Debug for libtmux::plan::Plan impl Debug for libtmux::plan::PlanResult impl Debug for libtmux::plan::PlanValidationError @@ -1488,6 +1492,7 @@ impl From for libtmux::plan::Op impl From for libtmux::plan::Op impl From for libtmux::plan::Op impl From for libtmux::plan::Op +impl From for libtmux::plan::Op impl From for libtmux::plan::Op impl From for libtmux::plan::Op impl From for libtmux::plan::Op @@ -1547,6 +1552,7 @@ impl JsonSchema for libtmux::plan::NewSession impl JsonSchema for libtmux::plan::NewWindow impl JsonSchema for libtmux::plan::Op impl JsonSchema for libtmux::plan::PaneTarget +impl JsonSchema for libtmux::plan::Pause impl JsonSchema for libtmux::plan::Plan impl JsonSchema for libtmux::plan::RenameWindow impl JsonSchema for libtmux::plan::Scope @@ -1689,6 +1695,7 @@ impl Serialize for libtmux::plan::NewWindow impl Serialize for libtmux::plan::Op impl Serialize for libtmux::plan::PaneSlot impl Serialize for libtmux::plan::PaneTarget +impl Serialize for libtmux::plan::Pause impl Serialize for libtmux::plan::Plan impl Serialize for libtmux::plan::RenameWindow impl Serialize for libtmux::plan::Safety @@ -1708,6 +1715,7 @@ impl Stream for libtmux::control::ControlEvents impl Stream for libtmux::control::PaneOutput impl libtmux::plan::Chainable for libtmux::plan::KillPane impl libtmux::plan::Chainable for libtmux::plan::KillWindow +impl libtmux::plan::Chainable for libtmux::plan::Pause impl libtmux::plan::Chainable for libtmux::plan::RenameWindow impl libtmux::plan::Chainable for libtmux::plan::SelectLayout impl libtmux::plan::Chainable for libtmux::plan::SelectPane @@ -1721,6 +1729,7 @@ impl libtmux::plan::Operation for libtmux::plan::KillPane impl libtmux::plan::Operation for libtmux::plan::KillWindow impl libtmux::plan::Operation for libtmux::plan::NewSession impl libtmux::plan::Operation for libtmux::plan::NewWindow +impl libtmux::plan::Operation for libtmux::plan::Pause impl libtmux::plan::Operation for libtmux::plan::RenameWindow impl libtmux::plan::Operation for libtmux::plan::SelectLayout impl libtmux::plan::Operation for libtmux::plan::SelectPane @@ -1777,6 +1786,7 @@ impl<'de> Deserialize<'de> for libtmux::plan::NewWindow impl<'de> Deserialize<'de> for libtmux::plan::Op impl<'de> Deserialize<'de> for libtmux::plan::PaneSlot impl<'de> Deserialize<'de> for libtmux::plan::PaneTarget +impl<'de> Deserialize<'de> for libtmux::plan::Pause impl<'de> Deserialize<'de> for libtmux::plan::Plan impl<'de> Deserialize<'de> for libtmux::plan::RenameWindow impl<'de> Deserialize<'de> for libtmux::plan::Safety @@ -1922,6 +1932,7 @@ struct libtmux::plan::NewSession struct libtmux::plan::NewWindow struct libtmux::plan::OperationReport struct libtmux::plan::PaneSlot +struct libtmux::plan::Pause struct libtmux::plan::Plan struct libtmux::plan::PlanResult struct libtmux::plan::PlanValidationError @@ -2237,6 +2248,7 @@ struct_field libtmux::plan::Op::KillPane::0: libtmux::plan::KillPane struct_field libtmux::plan::Op::KillWindow::0: libtmux::plan::KillWindow struct_field libtmux::plan::Op::NewSession::0: libtmux::plan::NewSession struct_field libtmux::plan::Op::NewWindow::0: libtmux::plan::NewWindow +struct_field libtmux::plan::Op::Pause::0: libtmux::plan::Pause struct_field libtmux::plan::Op::RenameWindow::0: libtmux::plan::RenameWindow struct_field libtmux::plan::Op::SelectLayout::0: libtmux::plan::SelectLayout struct_field libtmux::plan::Op::SelectPane::0: libtmux::plan::SelectPane @@ -2472,6 +2484,7 @@ variant libtmux::plan::Op::KillPane variant libtmux::plan::Op::KillWindow variant libtmux::plan::Op::NewSession variant libtmux::plan::Op::NewWindow +variant libtmux::plan::Op::Pause variant libtmux::plan::Op::RenameWindow variant libtmux::plan::Op::SelectLayout variant libtmux::plan::Op::SelectPane @@ -2485,6 +2498,7 @@ variant libtmux::plan::OperationKind::KillPane variant libtmux::plan::OperationKind::KillWindow variant libtmux::plan::OperationKind::NewSession variant libtmux::plan::OperationKind::NewWindow +variant libtmux::plan::OperationKind::Pause variant libtmux::plan::OperationKind::RenameWindow variant libtmux::plan::OperationKind::SelectLayout variant libtmux::plan::OperationKind::SelectPane diff --git a/crates/libtmux/src/error.rs b/crates/libtmux/src/error.rs index 75d652af..0f0709d2 100644 --- a/crates/libtmux/src/error.rs +++ b/crates/libtmux/src/error.rs @@ -166,10 +166,10 @@ pub enum ControlModeErrorKind { /// The command would hold the connection's one command queue. /// /// A connection runs one command at a time, and tmux closes a blocking - /// `wait-for` the moment it queues it: routed, the call would report - /// success without waiting, and nothing else would be answered on that - /// connection until the channel released it. Run it from a handle that - /// starts its own clients. + /// `wait-for` -- or the delayed `run-shell` a plan's `Pause` renders -- + /// the moment it queues it: routed, the call would report success without + /// waiting, and nothing else would be answered on that connection until + /// the wait ended. Run it from a handle that starts its own clients. BlockingCommand, } diff --git a/crates/libtmux/src/plan.rs b/crates/libtmux/src/plan.rs index 2b9bd011..5e773aae 100644 --- a/crates/libtmux/src/plan.rs +++ b/crates/libtmux/src/plan.rs @@ -61,7 +61,7 @@ mod run; mod wire; pub use ops::{ - CapturePane, KillPane, KillWindow, NewSession, NewWindow, RenameWindow, SelectLayout, + CapturePane, KillPane, KillWindow, NewSession, NewWindow, Pause, RenameWindow, SelectLayout, SelectPane, SelectWindow, SendKeys, SetEnvironment, SetOption, SplitWindow, }; pub use planner::{Planner, Step, StepReason}; @@ -733,6 +733,8 @@ operation_set! { KillPane(KillPane) => "kill-pane", /// Destroy a window. KillWindow(KillWindow) => "kill-window", + /// Wait before the next operation runs. + Pause(Pause) => "run-shell", } impl Op { @@ -787,7 +789,7 @@ impl Op { /// The targets this operation resolves before it can render. fn slots(&self) -> [Option; 2] { match self { - Self::NewSession(_) => [None, None], + Self::NewSession(_) | Self::Pause(_) => [None, None], Self::NewWindow(op) => [op.target.slot(), None], Self::SplitWindow(op) => [op.target.slot(), None], Self::SendKeys(op) => [op.target.slot(), None], @@ -806,7 +808,7 @@ impl Op { #[cfg(feature = "serde")] fn rebind_slots(&mut self, producers: &[Option]) { match self { - Self::NewSession(_) => {} + Self::NewSession(_) | Self::Pause(_) => {} Self::NewWindow(op) => op.target.rebind(producers), Self::SplitWindow(op) => op.target.rebind(producers), Self::SendKeys(op) => op.target.rebind(producers), @@ -863,6 +865,7 @@ impl Op { Self::CapturePane(op) => op.render(resolve), Self::KillPane(op) => op.render(resolve), Self::KillWindow(op) => op.render(resolve), + Self::Pause(op) => Some(op.render()), } } } diff --git a/crates/libtmux/src/plan/ops.rs b/crates/libtmux/src/plan/ops.rs index 38a9fc45..0e4f4880 100644 --- a/crates/libtmux/src/plan/ops.rs +++ b/crates/libtmux/src/plan/ops.rs @@ -64,9 +64,11 @@ macro_rules! operation { mod options; mod panes; mod sessions; +mod timing; mod windows; pub use options::{SetEnvironment, SetOption}; pub use panes::{CapturePane, KillPane, SelectPane, SendKeys, SplitWindow}; pub use sessions::NewSession; +pub use timing::Pause; pub use windows::{KillWindow, NewWindow, RenameWindow, SelectLayout, SelectWindow}; diff --git a/crates/libtmux/src/plan/ops/timing.rs b/crates/libtmux/src/plan/ops/timing.rs new file mode 100644 index 00000000..4e9aaf89 --- /dev/null +++ b/crates/libtmux/src/plan/ops/timing.rs @@ -0,0 +1,95 @@ +//! Operations about when the next one runs rather than about an object. + +use std::time::Duration; + +use crate::Command; + +use super::{Chainable, Effects, Op, Operation, Safety}; + +/// Wait before the next operation runs. +/// +/// Renders `run-shell -d SECONDS` with no command, which every supported tmux +/// accepts: the client waits out the delay and runs nothing. The wait happens +/// in tmux, so a pause folded into a shared invocation still falls between +/// its neighbours, and a plan pauses in the same place whatever its +/// [`Planner`](crate::plan::Planner). +/// +/// A shell does not need one to catch typed input: tmux holds it until the +/// pane reads it. A pause is for a program that drops what arrives before it +/// is ready. +/// +/// A control-mode connection cannot carry one, because tmux answers a delayed +/// `run-shell` there at once and holds the next command instead: +/// [`Plan::run_over_control_mode`](crate::plan::Plan::run_over_control_mode), +/// and [`Plan::run`](crate::plan::Plan::run) on a handle from +/// `Server::over_control_mode`, refuse a plan holding a pause with +/// `ControlModeErrorKind::BlockingCommand` before sending anything. +/// +/// # Examples +/// +/// ``` +/// use std::time::Duration; +/// +/// use libtmux::PaneId; +/// use libtmux::plan::{Pause, Plan, SendKeys}; +/// +/// let pane: PaneId = "%1".parse()?; +/// let mut plan = Plan::new(); +/// plan.add(SendKeys::new(pane.clone()).text("./start-db").enter()); +/// plan.add(Pause::new(Duration::from_millis(1500))); +/// plan.add(SendKeys::new(pane).text("./migrate").enter()); +/// +/// let pause = plan.preview().remove(1).expect("a pause renders"); +/// assert_eq!(pause.summary().to_string(), r#""run-shell" "-d" "1.5""#); +/// # Ok::<(), libtmux::IdParseError>(()) +/// ``` +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct Pause { + #[cfg_attr( + feature = "serde", + serde( + rename = "seconds", + serialize_with = "crate::plan::wire::seconds", + deserialize_with = "crate::plan::wire::parse_seconds" + ) + )] + #[cfg_attr(feature = "schema", schemars(rename = "seconds", with = "f64"))] + duration: Duration, +} + +impl Pause { + /// Wait `duration` before the next operation. + #[must_use] + pub const fn new(duration: Duration) -> Self { + Self { duration } + } + + /// How long this operation waits. + #[must_use] + pub const fn duration(&self) -> Duration { + self.duration + } + + pub(crate) fn render(&self) -> Command { + // tmux reads the delay with `strtod`, and `f64`'s `Display` never + // writes an exponent, so any `Duration` arrives as plain seconds. + Command::new("run-shell") + .arg("-d") + .arg(self.duration.as_secs_f64().to_string()) + } +} + +operation!( + Pause, + creates = (), + effects = Effects { + read_only: true, + idempotent: true, + ..Effects::MUTATING + }, + safety = Safety::ReadOnly, + chainable +); diff --git a/crates/libtmux/src/plan/run.rs b/crates/libtmux/src/plan/run.rs index 54789537..9d676840 100644 --- a/crates/libtmux/src/plan/run.rs +++ b/crates/libtmux/src/plan/run.rs @@ -297,6 +297,11 @@ impl Plan { /// command tmux *refuses* is reported through the returned [`PlanResult`], /// not as an error, because a plan may expect one. /// + /// On a handle from `Server::over_control_mode`, a plan holding a + /// [`super::ops::Pause`] fails with + /// [`crate::ControlModeErrorKind::BlockingCommand`] before anything is + /// sent, as [`Self::run_over_control_mode`] does. + /// /// # Cancel safety /// /// The effect can be partial: steps already dispatched stay done, and the @@ -305,6 +310,10 @@ impl Plan { pub async fn run(&self, server: &Server, planner: Planner) -> Result { self.validate() .map_err(|source| Error::InvalidPlan { source })?; + #[cfg(feature = "control-mode")] + if server.routes_over_control_mode() { + self.refuse_pause_over_control_mode()?; + } self.validate_option_scopes()?; self.validate_layouts(server).await?; let steps = planner.steps(self); @@ -710,6 +719,20 @@ fn bound_id( #[cfg(feature = "control-mode")] impl Plan { + /// Refuse a plan holding a pause on a control-mode connection. + /// + /// tmux ends a delayed `run-shell` block the moment it queues the delay + /// (`cmdq_fire_command` writes the guard when `exec` returns + /// `CMD_RETURN_WAIT`), then holds the connection's next command until the + /// delay is over: the pause would report done before it was, and a pause + /// at the end would not be waited for at all. + fn refuse_pause_over_control_mode(&self) -> Result<(), Error> { + if self.steps().iter().any(|op| matches!(op, Op::Pause(_))) { + return Err(Error::control_mode_blocking()); + } + Ok(()) + } + /// Run this plan over an open control-mode connection. /// /// Control mode is the one transport that separates *how many commands* @@ -734,6 +757,12 @@ impl Plan { /// connection carries no [`Server`] to run one against. A layout value /// this cannot parse still reaches tmux directly. /// + /// A plan holding a [`super::ops::Pause`] fails with + /// [`crate::ControlModeErrorKind::BlockingCommand`] before anything is + /// sent: tmux answers a delayed `run-shell` on a connection at once and + /// holds the next command instead, so the pause would report done before + /// it was. + /// /// # Cancel safety /// /// The effect can be partial, as for [`Self::run`]: steps already sent @@ -744,6 +773,7 @@ impl Plan { ) -> Result { self.validate() .map_err(|source| Error::InvalidPlan { source })?; + self.refuse_pause_over_control_mode()?; let mut bound: HashMap<(usize, Part), OsString> = HashMap::new(); let mut outcomes = vec![Outcome::Skipped; self.len()]; let mut reported = Vec::with_capacity(self.len()); diff --git a/crates/libtmux/src/plan/wire.rs b/crates/libtmux/src/plan/wire.rs index 6968a9b4..3d0d4e1b 100644 --- a/crates/libtmux/src/plan/wire.rs +++ b/crates/libtmux/src/plan/wire.rs @@ -177,3 +177,19 @@ fn borrowed(argument: Argument) -> OsString { Argument::Bytes(bytes) => OsString::from_vec(bytes), } } + +/// Serialize a duration as seconds, the unit tmuxp and `run-shell -d` use. +pub(super) fn seconds( + value: &std::time::Duration, + serializer: S, +) -> Result { + serializer.serialize_f64(value.as_secs_f64()) +} + +/// Read seconds back, refusing a negative, infinite or oversized number. +pub(super) fn parse_seconds<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result { + let seconds = f64::deserialize(deserializer)?; + std::time::Duration::try_from_secs_f64(seconds).map_err(D::Error::custom) +} diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index 95789b92..c2fb39d3 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1737,6 +1737,12 @@ impl Server { core: Arc::new(self.core.over_control_mode(sender.clone(), capabilities)), }) } + + /// Whether this handle came from [`Self::over_control_mode`]. + #[cfg(all(feature = "control-mode", feature = "plan"))] + pub(crate) fn routes_over_control_mode(&self) -> bool { + self.core.routes_over_control_mode() + } } impl PartialEq for Server { diff --git a/crates/libtmux/tests/plan.rs b/crates/libtmux/tests/plan.rs index 19464f40..e87cd63b 100644 --- a/crates/libtmux/tests/plan.rs +++ b/crates/libtmux/tests/plan.rs @@ -15,7 +15,7 @@ use std::time::Duration; use libtmux::plan::{ Attribution, CapturePane, KillPane, KillWindow, NewSession, NewWindow, OperationKind, - OperationReport, OperationValue, Outcome, PaneTarget, Plan, PlanResult, + OperationReport, OperationValue, Outcome, PaneTarget, Pause, Plan, PlanResult, PlanValidationErrorKind, Planner, SelectLayout, SelectPane, SelectWindow, SendKeys, SetEnvironment, SetOption, SplitWindow, StepReason, WindowTarget, }; @@ -518,6 +518,78 @@ async fn a_failure_alone_is_named_and_a_failure_in_a_fold_is_not() { guard.shutdown().await.expect("tmux fixture shuts down"); } +/// A pause holds the plan where it stands, folded or not: inside a shared +/// invocation the wait happens in tmux, between its neighbours. +#[tokio::test] +async fn a_pause_holds_the_plan_on_every_planner() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + server + .new_session("paused") + .await + .expect("session is created"); + let pane = server.panes().await.expect("panes list").remove(0); + let pause = Duration::from_millis(300); + + let mut plan = Plan::new(); + plan.add(SelectPane::new(pane.id().clone())); + plan.add(Pause::new(pause)); + plan.add(SelectPane::new(pane.id().clone())); + assert_eq!(Planner::Folding.steps(&plan).len(), 1, "one invocation"); + + for planner in [Planner::Sequential, Planner::Folding] { + let started = std::time::Instant::now(); + let result = plan.run(server, planner).await.expect("the plan runs"); + let elapsed = started.elapsed(); + + assert!(result.is_complete(), "{planner:?}: {result:?}"); + assert!(elapsed >= pause, "{planner:?} returned after {elapsed:?}"); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// tmux answers a delayed `run-shell` on a connection before the delay ends, +/// so both control-mode routes refuse a pause rather than report it done. +#[cfg(feature = "control-mode")] +#[tokio::test] +async fn a_pause_is_refused_over_control_mode() { + use libtmux::control::ControlMode; + use libtmux::{ControlModeErrorKind, Error}; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("paused-control") + .await + .expect("session is created"); + let (sender, events) = ControlMode::attach(server, session.id()) + .await + .expect("control mode attaches") + .split(); + let routed = server + .over_control_mode(&sender) + .await + .expect("the connection reaches this server"); + + let mut plan = Plan::new(); + plan.add(Pause::new(Duration::from_millis(50))); + let blocking = |outcome: Result| { + matches!( + outcome, + Err(Error::ControlMode { + kind: ControlModeErrorKind::BlockingCommand, + .. + }) + ) + }; + assert!(blocking(plan.run_over_control_mode(&sender).await)); + assert!(blocking(plan.run(&routed, Planner::Sequential).await)); + + events.shutdown().await.expect("control mode shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + #[cfg(feature = "control-mode")] #[tokio::test] async fn real_tmux_compat_control_plan_refusals_preserve_safe_diagnostics() { @@ -604,10 +676,13 @@ fn a_plan_survives_a_round_trip_through_json() { // An argument tmux accepts but a text format cannot carry as text. plan.add(SendKeys::new(window.pane()).text(OsString::from_vec(vec![0xff, b'x']))); plan.add(SetOption::window(window, "synchronize-panes", "on")); + plan.add(Pause::new(Duration::from_millis(1500))); let json = serde_json::to_string(&plan).expect("a plan serialises"); // The common case stays readable rather than becoming an array of bytes. assert!(json.contains("\"cargo test\""), "{json}"); + // A pause is seconds, as tmuxp writes one. + assert!(json.contains(r#"{"Pause":{"seconds":1.5}}"#), "{json}"); let restored: Plan = serde_json::from_str(&json).expect("a plan deserialises"); assert_eq!(restored.len(), plan.len()); @@ -647,6 +722,18 @@ fn deserialization_rejects_a_slot_with_the_wrong_scope() { assert!(failure.to_string().contains("not Session"), "{failure}"); } +#[cfg(feature = "serde")] +#[test] +fn deserialization_rejects_a_pause_no_duration_can_hold() { + for seconds in [-1.0, f64::MAX] { + let wire = serde_json::json!([{ "Pause": { "seconds": seconds } }]); + assert!( + serde_json::from_value::(wire).is_err(), + "{seconds} seconds was accepted", + ); + } +} + #[tokio::test] async fn a_creation_the_run_can_name_is_not_reported_as_unproven() { let guard = TestServer::builder().start().await.expect("tmux starts"); From 31092584ba57e2249b3236e158f0e35a0f8eac40 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 04:53:55 -0500 Subject: [PATCH 105/117] Workspace(fix[sleep]): Wait for tmuxp's sleeps `sleep_before` and `sleep_after` were read, written back by `to_yaml`, and ignored, because a plan could not pause (N10). With `plan::Pause` they lower to pauses around the command they belong to, and carry forward to the pane's later commands as tmuxp's builder carries them (`sleep_before = cmd.get("sleep_before", sleep_before)` in `builder/classic.py`), pane-level values first. The pause happens in tmux, so the `Marked` fold `build` uses waits too. Decision on N10: add the step rather than decline. The pause needs no split plan and no new transport -- tmux's own `run-shell -d` is one command -- and a caller of `WorkspaceBuilder::plan` sees each wait as a step. Proof: `sleeps_pause_the_build_between_commands` pins the plan's shape and a build of at least 400 ms. With the `sleep_before` pause removed it fails with `left: [.., "new-window", "send-keys", "send-keys", ..]`. --- crates/tmux-workspace/README.md | 4 +- crates/tmux-workspace/src/config.rs | 15 +++---- crates/tmux-workspace/src/lib.rs | 15 ++++++- crates/tmux-workspace/tests/build.rs | 67 ++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/crates/tmux-workspace/README.md b/crates/tmux-workspace/README.md index 81865ffa..32cf5032 100644 --- a/crates/tmux-workspace/README.md +++ b/crates/tmux-workspace/README.md @@ -138,6 +138,8 @@ where tmuxp's behaviour is surprising: - `enter: false` on a command holds for the commands after it in that pane, so the next one is typed onto the same line. A pane's `enter: false` covers its `shell_command_before` commands, which are typed first. +- `sleep_before` and `sleep_after` hold the same way, and are waited for in + tmux: each is a `libtmux::plan::Pause` between the commands it separates. - `~` and `$NAME` or `${NAME}` expand from the loading process's environment in names, start directories, and `environment` and option values. An unset variable stays as written, and there is no escape: a frozen name holding @@ -154,8 +156,6 @@ It differs where following tmuxp would be unsafe or impossible: - Commands are typed as written, for the pane's shell to expand. tmuxp expands variables in them first, which reads a variable's value as shell code. -- `sleep_before` and `sleep_after` are read and kept, and not waited for: a - `libtmux` plan cannot pause. tmux holds typed input until the pane reads it. - `~name` in a start directory is refused, not looked up; elsewhere it stays as written. - A `.` path with nothing to inherit, and a null among commands, crash diff --git a/crates/tmux-workspace/src/config.rs b/crates/tmux-workspace/src/config.rs index cdef6380..e09fce7b 100644 --- a/crates/tmux-workspace/src/config.rs +++ b/crates/tmux-workspace/src/config.rs @@ -176,12 +176,8 @@ pub struct PaneConfig { /// the `shell_command_before` commands typed into this pane too. pub enter: bool, /// How long to wait before each command, until a command sets its own. - /// - /// Read and kept, not acted on: see [`ShellCommand::sleep_before`]. pub sleep_before: Option, /// How long to wait after each command, until a command sets its own. - /// - /// Read and kept, not acted on: see [`ShellCommand::sleep_before`]. pub sleep_after: Option, /// Whether this pane's commands stay out of the shell's history. /// @@ -244,14 +240,13 @@ pub struct ShellCommand { pub cmd: String, /// Whether to press Enter after it. `None` keeps whatever is in force. pub enter: Option, - /// How long tmuxp waits before typing it. + /// How long to wait before typing it. `None` keeps whatever is in force. /// - /// Read and kept so the file round-trips, and not acted on: a - /// [`libtmux::plan::Plan`] runs start to finish with no way to pause - /// between steps. tmux buffers typed input until the pane reads it, so a - /// sleep that only waited for a shell to start is not needed. + /// The wait is a [`libtmux::plan::Pause`], so it happens in tmux and + /// holds the steps after it. tmux keeps typed input until the pane reads + /// it, so a sleep that only waited for a shell to start is not needed. pub sleep_before: Option, - /// How long tmuxp waits after typing it. Read and kept, not acted on. + /// How long to wait after typing it. `None` keeps whatever is in force. pub sleep_after: Option, } diff --git a/crates/tmux-workspace/src/lib.rs b/crates/tmux-workspace/src/lib.rs index 01bf28eb..6c343b86 100644 --- a/crates/tmux-workspace/src/lib.rs +++ b/crates/tmux-workspace/src/lib.rs @@ -39,7 +39,7 @@ pub use freeze::freeze; use std::path::Path; use libtmux::plan::{ - KillWindow, NewSession, NewWindow, PaneSlot, Plan, Planner, SelectLayout, SelectPane, + KillWindow, NewSession, NewWindow, PaneSlot, Pause, Plan, Planner, SelectLayout, SelectPane, SelectWindow, SendKeys, SessionSlot, SetEnvironment, SetOption, Slot, SplitWindow, }; use libtmux::{Server, Session, SessionId}; @@ -202,8 +202,11 @@ impl<'server> WorkspaceBuilder<'server> { .unwrap_or(workspace.suppress_history); // As in tmuxp, the `shell_command_before` commands lead the - // pane's own list, and a command's `enter` holds for the rest. + // pane's own list, and a command's `enter` and sleeps hold + // for the rest. let mut enter = pane_config.enter; + let mut sleep_before = pane_config.sleep_before; + let mut sleep_after = pane_config.sleep_after; let commands = workspace .shell_command_before .iter() @@ -211,7 +214,15 @@ impl<'server> WorkspaceBuilder<'server> { .chain(&pane_config.shell_commands); for command in commands { enter = command.enter.unwrap_or(enter); + sleep_before = command.sleep_before.or(sleep_before); + sleep_after = command.sleep_after.or(sleep_after); + if let Some(duration) = sleep_before { + plan.add(Pause::new(duration)); + } plan.add(Self::typing(*pane, &command.cmd, suppress, enter)); + if let Some(duration) = sleep_after { + plan.add(Pause::new(duration)); + } } if pane_config.focus { focus_pane = Some(*pane); diff --git a/crates/tmux-workspace/tests/build.rs b/crates/tmux-workspace/tests/build.rs index 58fa67e3..bbeac2c7 100644 --- a/crates/tmux-workspace/tests/build.rs +++ b/crates/tmux-workspace/tests/build.rs @@ -1368,3 +1368,70 @@ fn an_error_names_the_line_and_column_to_fix() { ); } } + +/// tmuxp's sleeps become pauses around the commands they belong to, and hold +/// for the later commands in the pane as `enter` does. The pause happens in +/// tmux, so a folded build waits too. +#[tokio::test] +async fn sleeps_pause_the_build_between_commands() { + use std::time::{Duration, Instant}; + + use libtmux::plan::Op; + + let workspace = Workspace::from_yaml( + " +session_name: sleepy +windows: + - panes: + - sleep_before: 0.1 + shell_command: + - echo one + - cmd: echo two + sleep_after: 0.05 + - echo three +", + ) + .expect("configuration parses"); + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let builder = WorkspaceBuilder::new(guard.server()); + + let shape: Vec = builder + .plan(&workspace) + .steps() + .iter() + .map(|op| match op { + Op::Pause(pause) => format!("pause {:?}", pause.duration()), + other => other.name().to_owned(), + }) + .collect(); + assert_eq!( + shape, + [ + "new-session", + "new-window", + "pause 100ms", + "send-keys", + "pause 100ms", + "send-keys", + "pause 50ms", + "pause 100ms", + "send-keys", + "pause 50ms", + "kill-window", + ], + ); + + let started = Instant::now(); + builder + .build(&workspace) + .await + .expect("the workspace builds"); + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(400), + "built in {elapsed:?}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From 060cad4bab1561778b71af5b7718e6efa08da15d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 10:14:19 -0500 Subject: [PATCH 106/117] Pane(fix[capture]): Trim tmux 3.2a's wrap padding why: `capture-pane -J` joins a wrapped line back into one row and is documented to preserve trailing spaces, right for what a program printed. On tmux 3.2a the join also carries the pane's unwritten cells past the printed text: joining a 300-byte line's wrap in an 80-column pane returns 320 bytes, the last 20 of them cells nothing wrote. Every later release already drops them unasked, confirmed by building 3.3, 3.3a, 3.4, 3.5 and 3.5a and running the same join against each -- well before `capture-pane -T` gives 3.4 and newer a flag for it. `Pane::wait_until`, added yesterday, compares a joined line for equality rather than `wait_for_text`'s substring, so it read the pane's width instead of what ran in it and timed out on every 3.2a run in `just compat`. what: - Add `CaptureOptions::needs_wrap_trim` and `trim_wrap_padding`; `Pane::capture_with` applies it when a caller asked to join wrapped lines without asking to keep trailing spaces, and `Pane::wait_until` always does, since its own capture is exactly that combination - Record the fix in the changelog --- crates/libtmux/src/pane.rs | 13 +++++++++ crates/libtmux/src/pane/observe.rs | 45 ++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 8309bddc..3b7504ab 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -1572,6 +1572,19 @@ impl CaptureOptions { self } + /// Whether a joined line still needs its blank-cell padding trimmed. + /// + /// tmux 3.2a's `-J` carries the unwritten cells past a wrapped line's + /// last printed row into the join; every later release, `-T` (3.4) or + /// not, already drops them. This crate trims the same padding itself + /// rather than adding a version gate for one release. Skipped when the + /// caller asked to keep exactly what a program printed with + /// [`Self::trailing_spaces`], and moot without [`Self::join_wrapped`], + /// where every row is tmux's own and already trimmed. + pub(crate) const fn needs_wrap_trim(&self) -> bool { + self.join_wrapped && !self.trailing_spaces + } + /// Lower these options into a `capture-pane` command for one pane. /// /// Takes the release so a flag cannot reach tmux without the check that diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index 2be928e9..ea3c46df 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -162,6 +162,7 @@ impl Pane { .await? .tmux_version() .clone(); + let needs_wrap_trim = options.needs_wrap_trim(); let command = options.lower(self.id().as_ref(), &version)?; let target = command.target().map(OsStr::to_os_string); let result = self.core.execute(command).await?; @@ -173,7 +174,12 @@ impl Pane { )); } - Ok(split_lines(result.stdout())) + let lines = split_lines(result.stdout()); + Ok(if needs_wrap_trim { + lines.into_iter().map(trim_wrap_padding).collect() + } else { + lines + }) } /// Capture with the per-line flags tmux records, marking shell prompts. @@ -431,8 +437,17 @@ impl Pane { within: Duration, mut settled: impl FnMut(&[TmuxText]) -> bool, ) -> Result { - self.look_until(within, |text, _| settled(&split_lines(text))) - .await + // The loop's own capture is `history().join_wrapped()` with no + // `trailing_spaces`, so a joined line always needs this trim -- see + // `trim_wrap_padding`. + self.look_until(within, |text, _| { + let lines: Vec = split_lines(text) + .into_iter() + .map(trim_wrap_padding) + .collect(); + settled(&lines) + }) + .await } /// The shared loop: look, decide, sleep, repeat until the deadline. @@ -514,6 +529,30 @@ fn split_lines(stdout: &[u8]) -> Vec { .collect() } +/// Drop the blank-cell padding `-J` leaves on a wrapped line's last row. +/// +/// `-J` joins a wrapped line back into one and is documented to preserve +/// trailing spaces, which is right for what a program printed -- tmux +/// already trims a row's genuinely unwritten cells everywhere else. tmux +/// 3.2a's join carries those unwritten cells past the printed text too: +/// printing 300 bytes into an 80-column pane and joining the wrap back +/// returns 320 bytes there, the last 20 of them cells nothing wrote. Every +/// later release already drops them unasked -- confirmed by building 3.3, +/// 3.3a, 3.4, 3.5, and 3.5a and running this same join against each, well +/// before `capture-pane -T` gives 3.4 and newer a flag for it. Trimming here +/// matches what every one of those already returns, instead of adding a +/// version gate for one release, so a caller comparing a joined line for +/// equality is not reading a pane's width instead of what ran in it. +fn trim_wrap_padding(line: TmuxText) -> TmuxText { + let bytes = line.as_bytes(); + let content_len = bytes.len() - bytes.iter().rev().take_while(|&&byte| byte == b' ').count(); + if content_len == bytes.len() { + line + } else { + TmuxText::from(bytes[..content_len].to_vec()) + } +} + /// Split one look's output into the pane's dead flag and its capture. /// /// `display-message -p` writes one line, so the flag is everything before the From bc442f577acd891aacd967f9c9b55f3c128c2dc9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 10:14:36 -0500 Subject: [PATCH 107/117] Test(fix[timeout]): Dodge run-shell's version gate why: `just check` installs whatever tmux apt currently carries for ubuntu-latest, now 3.4, inside `Server::run_shell`'s 3.3-3.4 range where it refuses outright rather than trust output tmux is known to drop. `a_caller_can_bound_one_command_with_tokio_timeout` used `run_shell` only to hold a process open past its deadline, so the refusal returned instantly, the outer `tokio::time::timeout` saw a finished future well under its 400ms bound, and the test failed on the assertion that it should not have. what: - Dispatch `run-shell sleep 3` through `Server::cmd` instead, which carries no output-capability gate and still blocks for the same process --- crates/libtmux/tests/server_command.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/libtmux/tests/server_command.rs b/crates/libtmux/tests/server_command.rs index 4af13aa8..95997bab 100644 --- a/crates/libtmux/tests/server_command.rs +++ b/crates/libtmux/tests/server_command.rs @@ -1677,10 +1677,15 @@ async fn a_caller_can_bound_one_command_with_tokio_timeout() { // `run-shell` blocks in tmux for as long as the command does, and the // server's own default timeout is 30 seconds, so reaching this deadline - // is the caller's bound rather than the crate's. + // is the caller's bound rather than the crate's. Dispatched with `cmd` + // rather than `Server::run_shell`, which refuses outright on 3.3 through + // 3.4 -- a version gate on captured output this test does not read. let started = Instant::now(); - let outcome = - tokio::time::timeout(Duration::from_millis(400), server.run_shell("sleep 3")).await; + let outcome = tokio::time::timeout( + Duration::from_millis(400), + server.cmd(Command::new("run-shell").arg("sleep 3")), + ) + .await; assert!(outcome.is_err(), "the caller's deadline ended the wait"); assert!( From 1b099ee517fba11cd4b1214e4dadadb2503e3981 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 10:31:13 -0500 Subject: [PATCH 108/117] Docs(fix[readme]): Dodge run-shell's version gate why: The cancellation example bounds `Server::run_shell("sleep 3")` with `tokio::time::timeout` and asserts the timeout wins. `just check` installs whatever tmux apt currently carries for ubuntu-latest, now 3.4, inside `run_shell`'s 3.3-3.4 range where it refuses outright rather than trust output tmux is known to drop. The refusal returned instantly, so the outer timeout saw a finished future well under its 400ms bound and the doctest's assertion failed -- the same cause the fixed `a_caller_can_bound_one_command_with_tokio_timeout` test already worked around, missed here because it lives in a README example rather than a test. what: - Dispatch `run-shell sleep 3` through `Server::cmd` instead, which carries no output-capability gate and still blocks for the same process; the example never reads the output either way --- crates/libtmux/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/libtmux/README.md b/crates/libtmux/README.md index 63116865..78ccdeb4 100644 --- a/crates/libtmux/README.md +++ b/crates/libtmux/README.md @@ -579,6 +579,7 @@ for it. Dropping the future is what signals the group, so wrapping the call is a per-call deadline with the cleanup already attached: ```rust +use libtmux::Command; use std::time::Duration; #[tokio::main] @@ -591,7 +592,7 @@ async fn main() -> Result<(), Box> { // `sleep` runs under the tmux server, so it finishes regardless. let bounded = tokio::time::timeout( Duration::from_millis(400), - server.run_shell("sleep 3"), + server.cmd(Command::new("run-shell").arg("sleep 3")), ) .await; assert!(bounded.is_err()); From 2f9a00bb4efb68f64ad58c6fac18f74acb846440 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 11:43:39 -0500 Subject: [PATCH 109/117] Repo(docs[contributing]): Retire the _or_empty pairing rule why: The API-conventions rule still told a contributor to add an `_or_empty` twin for every listing, the exact method design.md records the crate removing because nothing called one and each was a way to discard a failure reason by accident. what: - Replace the pairing rule with the propagate-and-unwrap_or_default convention the crate now follows --- .github/CONTRIBUTING.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a7d991db..47dde055 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -406,11 +406,12 @@ follows is the rule. invalid UTF-8, so they cross the API as `TmuxText`. Reading a tmux stream with anything that requires UTF-8 fails the whole operation the first time a pane prints a high byte. -- **Listings come in pairs, and the short name is the loud one.** `sessions()` - returns `Result`; `sessions_or_empty()` collapses failure into no rows. Both - halves are load-bearing, and which one gets the short name is the point: a - caller who writes the obvious thing gets the error, and a caller who wants - an empty list on failure has to say so. Add both when adding a listing. +- **A listing propagates; nothing collapses a failure into empty rows for + it.** `sessions()` returns `Result`, and a caller who wants an empty list on + failure writes `sessions().await.unwrap_or_default()` at the call site. An + `_or_empty` twin of each listing existed once; nothing called one, and a + method whose whole purpose is to discard a reason is a way to discard one by + accident, so they are gone. - **A failure says what to do about it.** `Error::kind` reduces the variants to a decision, and `is_object_gone` is the branch most callers write. tmux reports a missing target and a bad argument with the same exit status, so From 3b85e7263edc03f841f6bbc8e0ee9f82026c6ed3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 11:43:44 -0500 Subject: [PATCH 110/117] Docs(fix[doctest]): Use ? instead of expect in two examples why: WRITING.md requires examples to propagate with ?, not expect, since clippy denies expect_used outside tests and an example teaches whatever it shows. what: - Pause's plan preview example returns Box and uses ok_or(...)? instead of Option::expect - KeyBinding's typed_key_bindings example does the same --- crates/libtmux/src/plan/ops/timing.rs | 6 ++++-- crates/libtmux/src/server/keys.rs | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/libtmux/src/plan/ops/timing.rs b/crates/libtmux/src/plan/ops/timing.rs index 4e9aaf89..b0e5bc76 100644 --- a/crates/libtmux/src/plan/ops/timing.rs +++ b/crates/libtmux/src/plan/ops/timing.rs @@ -28,6 +28,7 @@ use super::{Chainable, Effects, Op, Operation, Safety}; /// # Examples /// /// ``` +/// # fn main() -> Result<(), Box> { /// use std::time::Duration; /// /// use libtmux::PaneId; @@ -39,9 +40,10 @@ use super::{Chainable, Effects, Op, Operation, Safety}; /// plan.add(Pause::new(Duration::from_millis(1500))); /// plan.add(SendKeys::new(pane).text("./migrate").enter()); /// -/// let pause = plan.preview().remove(1).expect("a pause renders"); +/// let pause = plan.preview().remove(1).ok_or("a pause renders")?; /// assert_eq!(pause.summary().to_string(), r#""run-shell" "-d" "1.5""#); -/// # Ok::<(), libtmux::IdParseError>(()) +/// # Ok(()) +/// # } /// ``` #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/libtmux/src/server/keys.rs b/crates/libtmux/src/server/keys.rs index 18c85918..e0ab530d 100644 --- a/crates/libtmux/src/server/keys.rs +++ b/crates/libtmux/src/server/keys.rs @@ -41,9 +41,10 @@ const TEMPLATE: &str = /// let version = server.capabilities().await?.tmux_version().clone(); /// if version.has_behavior(&libtmux::since::LIST_KEYS_FORMAT) { /// let bindings = server.typed_key_bindings(Some("prefix")).await?; -/// let bound = bindings.iter().find(|binding| binding.key() == "Y"); -/// -/// let bound = bound.expect("the binding is listed"); +/// let bound = bindings +/// .iter() +/// .find(|binding| binding.key() == "Y") +/// .ok_or("the binding is listed")?; /// assert_eq!(bound.command(), "display-message hello"); /// assert!(!bound.repeats()); /// assert_eq!(bound.note(), None); From 096f86dfc41acc3069a5053004ee75aa2fddad3a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 13:25:25 -0500 Subject: [PATCH 111/117] Wait(fix[echo]): Never match a command's own echo why: wait_for_text confirmed a match by checking only that a pattern sat above the cursor row. A line send_keys types and submits scrolls above that row the instant tmux processes Enter, so its own echo satisfied that check before the command it named produced anything -- a match on typed text, not on output. what: - Record each pane's submitted lines (crates/tmux-mcp/src/echo.rs), keyed by tmux server generation and pane id, and mask them out of the confirmed screen by whole-word-bounded exact-text removal, aged out after 10s and capped per pane and in total - Model send_keys' key names (submit, kill-line, erase, DC) to track what a dispatch adds to a pane's still-open line; a key it cannot model (arrows, Home, Tab) stops tracking that line rather than mask stale text, so it can then read as a match but never hides real output - Record a submitted line before dispatch, since the terminal's echo can reach a waiting client first, but do not publish the pane's new current line until tmux confirms the dispatch: doing so eagerly raced a command whose own output has no trailing newline, where the next prompt lands on the same row the position rule alone would then exclude from matching forever - Never roll a submitted line back on a failed dispatch: masking a line that never reaches the pane removes nothing real - State the contract in send_keys' and wait_for_text's descriptions - Add scenario tests for six echo-contract cases plus a resize control, run against the unfixed code first and shown to fail for the stated reason paste_text and run_shell_command also type into a pane and share this gap; left alone here. A submitted line that wraps across terminal rows is not masked either, matching the position rule's own existing limitation for wrapped output. --- crates/tmux-mcp/TOOLS.md | 4 +- crates/tmux-mcp/src/echo.rs | 517 +++++++++++++++++++++++++ crates/tmux-mcp/src/exec.rs | 107 ++++- crates/tmux-mcp/src/exec/tests.rs | 121 ++++++ crates/tmux-mcp/src/lib.rs | 4 + crates/tmux-mcp/src/policy.rs | 1 + crates/tmux-mcp/src/run_request.rs | 4 +- crates/tmux-mcp/src/tools/control.rs | 38 +- crates/tmux-mcp/src/tools/observe.rs | 29 +- crates/tmux-mcp/tests/echo_contract.rs | 494 +++++++++++++++++++++++ 10 files changed, 1287 insertions(+), 32 deletions(-) create mode 100644 crates/tmux-mcp/src/echo.rs create mode 100644 crates/tmux-mcp/tests/echo_contract.rs diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index aef2a81b..ad296639 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -501,7 +501,7 @@ Select a window, making it its session's active window. Give a direction to move ## `send_keys` -Type text into a pane, press named keys in it, or both. `text` is sent literally, so C-c in it types those three characters. Use `keys` for anything without a character of its own -- C-c to interrupt a running command, Escape, Up, C-d -- which are tmux key names and are interpreted. Text, keys, and optional Enter keep that order in one tmux dispatch. Before input, the configured synchronized-pane cohort is observed; a dead, input-disabled, mode-owned, terminal-attended, or inherited-caller member refuses the whole call, and so does an active run_shell_command, except for keys C-c or C-\ sent alone, which interrupt it. Returned pane IDs describe configured membership, not confirmed delivery. The observation can race with tmux processing the input. Send input to a pane's program; a shell that receives it runs it with your user's permissions. +Type text into a pane, press named keys in it, or both. `text` is sent literally, so C-c in it types those three characters. Use `keys` for anything without a character of its own -- C-c to interrupt a running command, Escape, Up, C-d -- which are tmux key names and are interpreted. Text, keys, and optional Enter keep that order in one tmux dispatch. Before input, the configured synchronized-pane cohort is observed; a dead, input-disabled, mode-owned, terminal-attended, or inherited-caller member refuses the whole call, and so does an active run_shell_command, except for keys C-c or C-\ sent alone, which interrupt it. Returned pane IDs describe configured membership, not confirmed delivery. The observation can race with tmux processing the input. A submitted line is remembered briefly so wait_for_text can discount its own echo; an unrecognized key stops that for the pane's current line rather than mask it inaccurately. Send input to a pane's program; a shell that receives it runs it with your user's permissions. - Toolset: `execute` - Process reach: `pane-input` @@ -711,7 +711,7 @@ Block until something signals a tmux wait-for channel. A pending signal is consu ## `wait_for_text` -Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A pattern that is a substring of a command you just sent with send_keys can already be on screen as its echo; outcome present_at_entry reports that rather than matched, so a still-pending command does not read as already done. Waiting owns an observer client until the wait ends. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. +Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A line this server itself typed and submitted with send_keys is discounted from a match for a short time afterward, so waiting for text you just sent does not match its own echo; output that happens to repeat the same words still does. A pattern still on the row being typed into, not yet submitted, reports outcome pending instead of matched, and one already on a completed row before this call attached reports present_at_entry. Waiting owns an observer client until the wait ends. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/echo.rs b/crates/tmux-mcp/src/echo.rs new file mode 100644 index 00000000..cd8166b6 --- /dev/null +++ b/crates/tmux-mcp/src/echo.rs @@ -0,0 +1,517 @@ +//! What this server itself typed into a pane, so `wait_for_text` can tell +//! the pane's answer from its own question. +//! +//! A pane echoes what is typed into it. `send_keys` with `text: "echo +//! MARKER", enter: true` types a line containing the very pattern a caller +//! is about to wait for, and the line scrolls into the pane's completed +//! output as soon as Enter is processed -- before the command it names has +//! run. [`crate::exec::wait_for_text`]'s row-position split already keeps a +//! pattern still on the row being typed into from matching; this covers the +//! row after it is submitted, which position alone cannot tell from real +//! output. +//! +//! Tracking is best-effort and, on any doubt, fails toward showing text +//! rather than hiding it. A key this cannot represent as literal text -- +//! `Left`, `Home`, a function key -- clears the pane's tracked line instead +//! of masking stale text a subsequent edit may have moved past: the +//! consequence is that an echo may then read as a match, never that real +//! output goes missing because it happened to repeat a phrase this recorded. + +use std::collections::{HashMap, VecDeque}; +use std::path::Path; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use libtmux::ServerGeneration; + +use crate::run_request::{EndpointIdentity, endpoint_identity}; + +/// How long a submitted line's echo is still worth discounting. +/// +/// Long enough to cover the gap between submitting a command and a +/// `wait_for_text` call that started before or shortly after it; short +/// enough that a pane reused for something else is not still suppressing +/// text that only looks like an old echo. +const RECENT_TTL: Duration = Duration::from_secs(10); + +/// Submitted lines kept per pane, oldest dropped first. +const RECENT_PER_PANE: usize = 4; + +/// Panes tracked at once, across every server this process has selected. +/// +/// Evicting the least recently touched pane when this is exceeded is what +/// keeps a killed pane's entry from living for the life of the process when +/// nothing ever ages it out on its own. +const MAX_TRACKED_PANES: usize = 256; + +/// One pane, identified so a reused pane id after a server restart never +/// inherits another server's record. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct EchoKey { + generation: ServerGeneration, + endpoint: EndpointIdentity, + pane: String, +} + +impl EchoKey { + /// Build a key from a pane input dispatch's already-resolved endpoint and + /// server generation, or from a fresh read for a caller that has neither + /// in hand. + /// + /// `None` when the endpoint cannot be identified; the caller is meant to + /// treat that as "track nothing for this call" rather than propagate a + /// failure through a path this is not load-bearing for. + pub(crate) fn new(generation: ServerGeneration, endpoint: &Path, pane: &str) -> Option { + Some(Self { + generation, + endpoint: endpoint_identity(endpoint).ok()?, + pane: pane.to_owned(), + }) + } +} + +#[derive(Default)] +struct PaneRecord { + /// This pane's current line: typed but neither submitted nor cleared. + /// + /// Only meaningful when `in_flight` is `0`: a dispatch computes what + /// this should become before tmux has seen any of it, and this is not + /// updated until that dispatch is confirmed, so a concurrent reader + /// never sees a line reported empty while the real terminal is still + /// mid-typing it. + pending: String, + /// How many dispatches to this pane have computed a new `pending` but + /// not yet confirmed it against tmux. + in_flight: u32, + /// Lines recently submitted on this pane, oldest first, each with when. + recent: VecDeque<(String, Instant)>, + /// Last time this pane was touched, for bounding the outer table. + touched: Option, +} + +impl PaneRecord { + fn push_recent(&mut self, line: String, now: Instant) { + if let Some((last, at)) = self.recent.back_mut() + && *last == line + { + // The same line twice is one record, freshened: a shell that + // redraws a submitted line on completion must not push an + // earlier, different echo out of a short, bounded list. + *at = now; + return; + } + self.recent.push_back((line, now)); + while self.recent.len() > RECENT_PER_PANE { + self.recent.pop_front(); + } + } + + fn prune(&mut self, now: Instant) { + while self + .recent + .front() + .is_some_and(|(_, at)| now.duration_since(*at) > RECENT_TTL) + { + self.recent.pop_front(); + } + } + + /// Whether this pane's current line can safely be treated as something + /// other than this server's own mid-typing. + fn has_pending(&self) -> bool { + self.in_flight > 0 || !self.pending.is_empty() + } + + fn is_empty(&self, now: Instant) -> bool { + self.in_flight == 0 + && self.pending.is_empty() + && self.recent.is_empty() + && self + .touched + .is_none_or(|touched| now.duration_since(touched) > RECENT_TTL) + } +} + +/// What one `send_keys` dispatch does to a pane's still-being-typed line. +struct DispatchOutcome { + /// The line this dispatch submitted, when one of its keys did. + submitted: Option, +} + +/// tmux key names that submit a line, as Enter does. +const SUBMIT_KEYS: [&str; 3] = ["Enter", "C-m", "KPEnter"]; +/// tmux key names that discard the line without submitting it. +const KILL_LINE_KEYS: [&str; 2] = ["C-u", "C-c"]; +/// tmux key names that remove the character before the cursor. +const ERASE_KEYS: [&str; 2] = ["BSpace", "C-h"]; + +/// One printable character, when `key` names exactly one rather than naming +/// a key with no character of its own. +fn typed_char(key: &str) -> Option { + if key == "Space" { + return Some(' '); + } + let mut chars = key.chars(); + let only = chars.next()?; + if chars.next().is_some() || only.is_control() { + return None; + } + Some(only) +} + +/// Apply one dispatch's `text` (typed literally, verbatim, first) and `keys` +/// (interpreted, in order) to `pending`. +/// +/// An unmodelable key clears `pending` -- it stops discounting this pane's +/// line rather than keep text an edit it cannot represent may have moved +/// past -- and processing continues after it, so a recognized key later in +/// the same dispatch still starts a fresh, accurate line. +fn apply_dispatch(pending: &mut String, text: Option<&str>, keys: &[String]) -> DispatchOutcome { + if let Some(text) = text.filter(|text| !text.is_empty()) { + pending.push_str(text); + } + let mut submitted = None; + for key in keys { + let key = key.as_str(); + if SUBMIT_KEYS.contains(&key) { + let line = std::mem::take(pending); + if !line.is_empty() { + submitted = Some(line); + } + } else if KILL_LINE_KEYS.contains(&key) { + pending.clear(); + } else if ERASE_KEYS.contains(&key) { + pending.pop(); + } else if key == "DC" { + // A pending line only ever grows at the cursor, so the cursor is + // always at its end; forward-delete there has nothing to remove. + } else if let Some(character) = typed_char(key) { + pending.push(character); + } else { + pending.clear(); + } + } + DispatchOutcome { submitted } +} + +/// What [`PaneEchoes::apply`] computed for one dispatch, to be confirmed with +/// [`PaneEchoes::commit`] or given up on with [`PaneEchoes::abandon`]. +/// +/// Dropping this without calling either leaks that dispatch's share of +/// `in_flight`, which would leave the pane's line permanently excluded from +/// [`PaneEchoes::has_pending`]'s relaxation; every path in `send_keys_one` +/// that can end a dispatch must reach one of the two. +pub(crate) struct EchoUpdate { + /// The pane's line as it will read once this dispatch is confirmed. + pending: Vec<(EchoKey, String)>, +} + +/// What this process has typed into panes it has touched, kept per tmux +/// server generation and pane id. +#[derive(Default)] +pub(crate) struct PaneEchoes { + inner: Mutex>, +} + +impl PaneEchoes { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Compute what every pane a dispatch reaches (its configured, possibly + /// synchronized, cohort) will owe to `text` and `keys`, before that + /// dispatch reaches tmux. + /// + /// A line this dispatch submits is recorded as a fresh echo immediately, + /// not deferred to [`Self::commit`]: the terminal's echo can reach a + /// waiting `wait_for_text` client before this call even returns, so + /// masking it has to be in place that early. The pane's current line + /// itself is not published yet, because a real one is genuinely still + /// being typed on the wire until tmux confirms it, and reporting it + /// empty in that window would let `wait_for_text` treat mid-typing text + /// as the pane's own settled state. + pub(crate) fn apply( + &self, + generation: ServerGeneration, + endpoint: &Path, + panes: &[String], + text: Option<&str>, + keys: &[String], + ) -> EchoUpdate { + let Ok(endpoint) = endpoint_identity(endpoint) else { + return EchoUpdate { + pending: Vec::new(), + }; + }; + let now = Instant::now(); + let mut table = self.hold(); + let mut pending = Vec::with_capacity(panes.len()); + for pane in panes { + let key = EchoKey { + generation, + endpoint, + pane: pane.clone(), + }; + let record = table.entry(key.clone()).or_default(); + record.in_flight += 1; + record.touched = Some(now); + let mut scratch = record.pending.clone(); + let outcome = apply_dispatch(&mut scratch, text, keys); + if let Some(line) = outcome.submitted { + record.push_recent(line, now); + } + pending.push((key, scratch)); + } + evict_stale(&mut table, now); + EchoUpdate { pending } + } + + /// Publish an [`Self::apply`] call's computed line once tmux has + /// confirmed the dispatch that produced it. + pub(crate) fn commit(&self, update: EchoUpdate) { + let now = Instant::now(); + let mut table = self.hold(); + for (key, computed) in update.pending { + if let Some(record) = table.get_mut(&key) { + record.pending = computed; + record.in_flight = record.in_flight.saturating_sub(1); + record.touched = Some(now); + } + } + evict_stale(&mut table, now); + } + + /// Give up on an [`Self::apply`] call whose dispatch never reached tmux. + /// + /// The computed line is discarded, not published: the pane's line is + /// exactly what it was before this dispatch. The echo already pushed to + /// `recent`, if any, is kept regardless -- masking a line that never + /// appears on the pane removes nothing real. + pub(crate) fn abandon(&self, update: EchoUpdate) { + let mut table = self.hold(); + for (key, _) in update.pending { + if let Some(record) = table.get_mut(&key) { + record.in_flight = record.in_flight.saturating_sub(1); + } + } + } + + /// Whether `pane`'s current line can safely be treated as something + /// other than this server's own mid-typing. + pub(crate) fn has_pending(&self, key: &EchoKey) -> bool { + self.hold().get(key).is_some_and(PaneRecord::has_pending) + } + + /// Lines this pane has recently submitted, still young enough to + /// discount, as the bytes a captured screen would show them in. + pub(crate) fn snapshot(&self, key: &EchoKey) -> Vec> { + let now = Instant::now(); + let mut table = self.hold(); + let Some(record) = table.get_mut(key) else { + return Vec::new(); + }; + record.prune(now); + record + .recent + .iter() + .map(|(line, _)| line.clone().into_bytes()) + .collect() + } + + #[cfg(test)] + pub(crate) fn tracked_panes(&self) -> usize { + self.hold().len() + } + + fn hold(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Drop panes that carry nothing worth keeping, then the least recently +/// touched over the table's cap -- so a killed pane's entry does not live +/// for the life of the process even when nothing else ages it out. +fn evict_stale(table: &mut HashMap, now: Instant) { + table.retain(|_, record| { + record.prune(now); + !record.is_empty(now) + }); + while table.len() > MAX_TRACKED_PANES { + let Some(stale) = table + .iter() + .min_by_key(|(_, record)| record.touched) + .map(|(key, _)| key.clone()) + else { + break; + }; + table.remove(&stale); + } +} + +/// Whether `byte` can be part of the word a masked echo must not tear into. +/// +/// A high-bit byte (part of a multi-byte UTF-8 sequence) counts as a word +/// byte too, conservatively: treating it as a boundary risks masking half of +/// a character next to one this recorded, and this crate's patterns and +/// recorded echoes are ASCII in every case that matters here. +const fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80 +} + +/// Remove every whole-word-bounded occurrence of `needle` from `haystack`. +/// +/// Only a word boundary, not "ends its line": a shell draws its own things +/// straight after what was typed (a right-hand prompt, a redraw on submit), +/// so insisting on the end of a row let such echoes through. The cost is +/// that real output repeating the typed text as a word of its own loses that +/// word along with it; a wait for exactly that text is not one a screen can +/// answer, whichever side wrote it. +/// +/// A submitted line that wrapped across terminal rows when it was typed is +/// not found here and is a known gap: `above` inserts a real newline at +/// every captured row, wrapped or not, because this crate does not join +/// wrapped lines (`-J` breaks the cursor arithmetic `wait_for_text` relies +/// on), so a wrapped echo's recorded text never matches the broken-up rows. +fn without_echo(haystack: &[u8], needle: &[u8]) -> Vec { + if needle.is_empty() || haystack.len() < needle.len() { + return haystack.to_vec(); + } + let mut masked = vec![false; haystack.len()]; + let mut from = 0; + while from + needle.len() <= haystack.len() { + let Some(offset) = haystack[from..] + .windows(needle.len()) + .position(|window| window == needle) + else { + break; + }; + let at = from + offset; + let end = at + needle.len(); + let opens = at == 0 || !is_word_byte(haystack[at - 1]); + let closes = end == haystack.len() || !is_word_byte(haystack[end]); + if opens && closes { + for slot in &mut masked[at..end] { + *slot = true; + } + from = end; + } else { + from = at + 1; + } + } + haystack + .iter() + .zip(masked) + .filter_map(|(&byte, hit)| (!hit).then_some(byte)) + .collect() +} + +/// Remove every recorded echo from `haystack`, in order. +pub(crate) fn mask(haystack: &[u8], echoes: &[Vec]) -> Vec { + let mut text = haystack.to_vec(); + for echo in echoes { + text = without_echo(&text, echo); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_submitted_line_is_masked_whole_word_only() { + let haystack = b"echo MARKER\nMARKER\nprefixMARKER\n"; + let masked = mask(haystack, &[b"echo MARKER".to_vec()]); + assert_eq!(masked, b"\nMARKER\nprefixMARKER\n"); + } + + #[test] + fn masking_never_tears_a_longer_word() { + let masked = without_echo(b"prefixMARKERsuffix", b"MARKER"); + assert_eq!(masked, b"prefixMARKERsuffix"); + } + + #[test] + fn repeated_output_still_matches_after_masking_the_echo() { + let haystack = b"sleep 1; echo MARKER\nMARKER\n"; + let masked = mask(haystack, &[b"sleep 1; echo MARKER".to_owned().to_vec()]); + assert_eq!(masked, b"\nMARKER\n"); + } + + #[test] + fn backspaces_reach_an_earlier_calls_pending_text() { + let mut pending = String::new(); + apply_dispatch(&mut pending, Some("xMARKER"), &[]); + assert_eq!(pending, "xMARKER"); + let backspaces = vec!["BSpace".to_owned(); 7]; + apply_dispatch(&mut pending, None, &backspaces); + assert_eq!(pending, ""); + let outcome = apply_dispatch(&mut pending, Some("echo MARKER"), &["Enter".to_owned()]); + assert_eq!(outcome.submitted.as_deref(), Some("echo MARKER")); + assert_eq!(pending, ""); + } + + #[test] + fn an_unmodelable_key_clears_pending_instead_of_keeping_it_stale() { + let mut pending = "MARKER".to_owned(); + let outcome = apply_dispatch(&mut pending, None, &["Left".to_owned()]); + assert!(outcome.submitted.is_none()); + assert_eq!( + pending, "", + "an unrecognized key stops discounting the line" + ); + } + + #[test] + fn kill_line_keys_discard_without_recording_an_echo() { + let mut pending = "doomed".to_owned(); + let outcome = apply_dispatch(&mut pending, None, &["C-u".to_owned()]); + assert!(outcome.submitted.is_none()); + assert_eq!(pending, ""); + } + + /// A pane's record is bounded in lifetime (TTL) and the table in size + /// (a cap), so a killed pane's entry does not live for the life of the + /// process even when nothing else ages it out. + /// + /// `ServerGeneration` has no public constructor -- correctly, since a + /// fabricated one could collide with a real server's -- so this reads + /// one real value from a fixture rather than faking it; `apply` itself + /// makes no tmux round trip, so this stays well inside the inner-loop + /// budget despite exercising the table at its cap. + #[tokio::test] + async fn a_killed_panes_record_does_not_outlive_the_table_cap() { + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let generation = guard + .server() + .generation() + .await + .expect("server generation"); + let endpoint = guard.server().socket_path().to_path_buf(); + let echoes = PaneEchoes::new(); + + for index in 0..MAX_TRACKED_PANES + 8 { + let pane = format!("%{index}"); + let _ = echoes.apply( + generation, + &endpoint, + std::slice::from_ref(&pane), + Some("x"), + &[], + ); + } + + assert!( + echoes.tracked_panes() <= MAX_TRACKED_PANES, + "the table stays bounded at {} panes", + echoes.tracked_panes() + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); + } +} diff --git a/crates/tmux-mcp/src/exec.rs b/crates/tmux-mcp/src/exec.rs index bd68bc66..02ed6436 100644 --- a/crates/tmux-mcp/src/exec.rs +++ b/crates/tmux-mcp/src/exec.rs @@ -13,6 +13,7 @@ use libtmux::{CaptureOptions, ControlLimits, ControlModeErrorKind, Error, Pane}; use regex::bytes::Regex; use serde::Serialize; +use crate::echo::{EchoKey, PaneEchoes}; use crate::retained::MAX_BYTES as OUTPUT_LIMIT; #[cfg(test)] use crate::retained::{COMPACT_AFTER, RetainedBytes}; @@ -24,6 +25,15 @@ const MAX_PATTERNS: usize = 32; const MAX_PATTERN_BYTES: usize = 4_096; const MAX_TOTAL_PATTERN_BYTES: usize = 16_384; +/// The shared echo record and this pane's key into it, bundled so threading +/// both through `wait_for_text`'s internal calls does not itself run into +/// clippy's argument-count limit. +#[derive(Clone, Copy)] +pub(crate) struct EchoContext<'a> { + pub(crate) echoes: &'a PaneEchoes, + pub(crate) key: Option<&'a EchoKey>, +} + mod run; #[cfg(test)] @@ -77,14 +87,17 @@ pub enum RunOutcome { #[serde(rename_all = "snake_case")] pub enum WaitOutcome { /// A wanted pattern was already in the pane's output before this began - /// watching, on a row above the one still being typed into. + /// watching, on a row above the one still being typed into, and is not + /// a line this server itself submitted moments earlier. /// /// A wait only sees what a pane writes after it starts, so this is never /// folded into [`Self::Matched`]: the same pattern printed moments - /// earlier -- an earlier command's own output, or the shell's echo of a - /// line that has already been submitted -- can already be sitting there, - /// and a caller that treated that as a fresh match would act on - /// something that happened before this call, not because of it. + /// earlier -- an earlier command's own output, say -- can already be + /// sitting there, and a caller that treated that as a fresh match would + /// act on something that happened before this call, not because of it. + /// A line this server typed and submitted with `send_keys` is discounted + /// rather than reported here or as [`Self::Matched`], for a short time + /// after it was submitted. PresentAtEntry, /// A wanted pattern's only occurrence is the row still being typed /// into: text this server (or a person sharing the pane) sent and has @@ -93,7 +106,8 @@ pub enum WaitOutcome { /// Submit it, then wait again: the next wait sees the command's own /// output on a row above a new one still being typed into, which is /// [`Self::PresentAtEntry`] or [`Self::Matched`] depending on when it - /// arrived, never this. + /// arrived, never this -- and never the submitted line's own echo, + /// which stays discounted for a short time after. Pending, /// A pattern matched, in output that arrived after the wait attached. Matched, @@ -240,6 +254,7 @@ pub(crate) async fn wait_for_text( stops: &Patterns, timeout: Duration, cancelled: &CancellationToken, + echo: EchoContext<'_>, ) -> Result { wait_for_text_with_limits( pane, @@ -248,6 +263,7 @@ pub(crate) async fn wait_for_text( timeout, cancelled, ControlLimits::default(), + echo, ) .await } @@ -270,6 +286,7 @@ pub(crate) async fn wait_for_text_with_limits( timeout: Duration, cancelled: &CancellationToken, limits: ControlLimits, + echo: EchoContext<'_>, ) -> Result { // Attached first: a pattern that arrives while the screen is being read // must still be seen. Reading first would lose one that landed between @@ -277,13 +294,13 @@ pub(crate) async fn wait_for_text_with_limits( // did arrive. One landing in that gap is reported as present at entry // instead, which is still true of the screen. let output = pane.stream_output_with_limits(limits).await?; - if let Some(view) = read_present_at_entry(pane, patterns).await? { + if let Some(view) = read_present_at_entry(pane, patterns, echo).await? { // The answer is already in hand; a failure closing a stream nothing // read does not change it. let _ = output.shutdown().await; return Ok(view); } - wait_on_output(pane, output, patterns, stops, timeout, cancelled).await + wait_on_output(pane, output, patterns, stops, timeout, cancelled, echo).await } /// One pane's screen, split at the row still being typed into. @@ -354,6 +371,7 @@ impl Screen { async fn read_present_at_entry( pane: &Pane, patterns: &Patterns, + echo: EchoContext<'_>, ) -> Result, Error> { // No patterns means "wait for anything at all", which nothing already on // screen can pre-empt: there is nothing yet to call present. @@ -367,13 +385,34 @@ async fn read_present_at_entry( return Ok(None); }; + // A line this server itself submitted moments ago -- before this wait + // even attached -- is not evidence of anything the pane did; discount it + // the same way a live match is discounted below. The row still being + // typed into is never masked: nothing not yet submitted is ever in + // `recent`. + let recent = echo + .key + .map(|key| echo.echoes.snapshot(key)) + .unwrap_or_default(); + let masked_above = crate::echo::mask(&screen.above, &recent); + + // Nothing of this server's own is unsubmitted, so the row the cursor + // sits on is not mid-typing either -- reported the same as `above` + // rather than `pending`, since it is not this server's own question. + // This is what keeps a command whose output does not end in a newline + // (so the next prompt lands on the same row) from being hidden forever. + let pending_outcome = if echo.key.is_some_and(|key| echo.echoes.has_pending(key)) { + WaitOutcome::Pending + } else { + WaitOutcome::PresentAtEntry + }; let outcome = patterns - .first_match(&screen.above) + .first_match(&masked_above) .map(|found| (WaitOutcome::PresentAtEntry, found)) .or_else(|| { patterns .first_match(&screen.pending) - .map(|found| (WaitOutcome::Pending, found)) + .map(|found| (pending_outcome, found)) }); let Some((outcome, (index, source))) = outcome else { return Ok(None); @@ -390,6 +429,41 @@ async fn read_present_at_entry( })) } +/// Confirm a fresh match against `pane`'s completed rows, discounting every +/// line `echoes` has recorded for it. +/// +/// `sticky` accumulates every echo seen across the whole wait, not only this +/// call's snapshot: `echoes` ages a record out on its own schedule (10 +/// seconds), and a wait may run longer than that. Losing the record mid-wait +/// must not resurrect the very false match it existed to prevent, so once an +/// echo is seen it stays discounted for the rest of this call. +async fn confirmed_above( + pane: &Pane, + patterns: &Patterns, + echo: EchoContext<'_>, + sticky: &mut Vec>, +) -> bool { + if let Some(key) = echo.key { + for line in echo.echoes.snapshot(key) { + if !sticky.contains(&line) { + sticky.push(line); + } + } + } + let Some(screen) = Screen::capture(pane).await else { + return false; + }; + let mut haystack = crate::echo::mask(&screen.above, sticky); + // Nothing of this server's own is unsubmitted here, so whatever is on + // this row is not mid-typing -- most often a command's own output that + // did not end in a newline and left the next prompt on the same row, + // which the position rule alone would otherwise hide forever. + if !echo.key.is_some_and(|key| echo.echoes.has_pending(key)) { + haystack.extend_from_slice(&screen.pending); + } + patterns.first_match(&haystack).is_some() +} + /// The read loop [`wait_for_text_with_limits`] runs once attached. async fn wait_on_output( pane: &Pane, @@ -398,6 +472,7 @@ async fn wait_on_output( stops: &Patterns, timeout: Duration, cancelled: &CancellationToken, + echo: EchoContext<'_>, ) -> Result { let mut filter = TextFilter::new(); let mut text: Vec = Vec::new(); @@ -405,6 +480,7 @@ async fn wait_on_output( let mut outcome = WaitOutcome::Deadline; let mut matched_index = None; let mut matched_pattern = None; + let mut sticky_echoes: Vec> = Vec::new(); let deadline = tokio::time::Instant::now() + timeout; loop { @@ -443,10 +519,9 @@ async fn wait_on_output( // when it starts reading, both genuinely new output that // can still be sitting on the row being typed into. // Confirmed only once the *current* screen shows the - // pattern above that row. - let confirmed = Screen::capture(pane) - .await - .is_some_and(|screen| patterns.first_match(&screen.above).is_some()); + // pattern above that row, discounting a line this server + // has itself recently submitted there. + let confirmed = confirmed_above(pane, patterns, echo, &mut sticky_echoes).await; if confirmed { outcome = WaitOutcome::Matched; matched_index = Some(index); @@ -495,9 +570,7 @@ async fn wait_on_output( // not a genuine match. Confirmed the same way, against the row still // being typed into, or the promotion is undone. if matches!(outcome, WaitOutcome::Matched) - && Screen::capture(pane) - .await - .is_none_or(|screen| patterns.first_match(&screen.above).is_none()) + && !confirmed_above(pane, patterns, echo, &mut sticky_echoes).await { outcome = WaitOutcome::Deadline; matched_index = None; diff --git a/crates/tmux-mcp/src/exec/tests.rs b/crates/tmux-mcp/src/exec/tests.rs index 5433dc95..9ef9a35d 100644 --- a/crates/tmux-mcp/src/exec/tests.rs +++ b/crates/tmux-mcp/src/exec/tests.rs @@ -1022,6 +1022,7 @@ async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() Patterns::compile(&["AAAAAAAAAA".to_owned()], false, false).expect("pattern compiles"); let stops = Patterns::compile(&[], false, false).expect("empty stop patterns compile"); let cancelled = CancellationToken::new(); + let echoes = PaneEchoes::new(); let error = wait_on_output( &pane, @@ -1030,6 +1031,10 @@ async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() &stops, Duration::from_secs(5), &cancelled, + EchoContext { + echoes: &echoes, + key: None, + }, ) .await .expect_err("a too-small frame budget is a real shutdown error, not a tolerated Closed"); @@ -1040,3 +1045,119 @@ async fn wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it() guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// S2 (echo contract), attached-before-send variant: a wait that is already +/// watching before a command is even typed must still match the command's +/// real output, not the echo of the line it submitted. +/// +/// Attaches before dispatching `send_keys`, rather than racing a concurrent +/// wait against it: `tokio::spawn`ing the wait first proves nothing on a +/// single-threaded test runtime, since the spawned task does not run a step +/// until the spawning task yields, so `send_keys`'s own first await point +/// could easily run before the wait's. `pane.stream_output` awaited to +/// completion is the same seam +/// `wait_for_text_surfaces_a_frame_budget_error_instead_of_tolerating_it` +/// above uses for exactly this: attach, then, only once that is provably +/// done, act. +/// +/// Goes through the real `send_keys` tool method (not a raw `send-keys` +/// dispatch) so the echo this test defends against is recorded exactly as +/// production code records it. +#[tokio::test] +async fn a_wait_attached_before_the_send_matches_output_not_its_submitted_echo() { + use rmcp::handler::server::wrapper::Parameters; + + use crate::{SendKeysArgs, TmuxTools}; + + let guard = libtmux::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("echo-contract-s2-before") + .await + .expect("session starts"); + let pane = session.panes().await.expect("panes list").remove(0); + + libtmux::test::retry_until(Duration::from_secs(2), async || { + server + .cmd( + libtmux::Command::new("display-message") + .arg("-p") + .arg("-t") + .arg(pane.id().to_string()) + .arg("#{cursor_x},#{cursor_y}"), + ) + .await + .ok() + .map(|result| result.stdout_lossy().trim().to_owned()) + .is_some_and(|reading| !reading.is_empty() && reading != "0,0") + }) + .await + .expect("the pane draws a prompt"); + + let tools = TmuxTools::builder(server.clone()).caller(None).build(); + + // Attached before anything is typed: proves this wait cannot be + // answered by a screen it only read after the command already ran. + let output = pane + .stream_output() + .await + .expect("attaching on a quiet pane succeeds"); + + tools + .send_keys(Parameters(SendKeysArgs { + pane: pane.id().to_string(), + text: Some("sleep 1; echo MARKER".to_owned()), + keys: None, + enter: true, + })) + .await + .expect("the command is sent"); + + let patterns = + Patterns::compile(&["MARKER".to_owned()], false, false).expect("pattern compiles"); + let stops = Patterns::compile(&[], false, false).expect("empty stop patterns compile"); + let cancelled = CancellationToken::new(); + let generation = server.generation().await.expect("server generation"); + let key = EchoKey::new(generation, server.socket_path(), pane.id().as_ref()) + .expect("a key builds against a live socket"); + + let started = std::time::Instant::now(); + let view = wait_on_output( + &pane, + output, + &patterns, + &stops, + Duration::from_secs(5), + &cancelled, + EchoContext { + echoes: &tools.echoes, + key: Some(&key), + }, + ) + .await + .expect("the wait completes"); + + assert_eq!( + view.outcome, + WaitOutcome::Matched, + "must not time out over the masked echo either: {view:?}" + ); + // A match on the submitted line's own echo would land in a few + // milliseconds; the real `echo MARKER` output cannot exist before the + // pane's `sleep 1` returns. + assert!( + started.elapsed() >= Duration::from_millis(800), + "matched after only {:?}, too fast to be sleep 1's real output", + started.elapsed() + ); + assert!( + view.text.lines().any(|line| line.trim() == "MARKER"), + "the bare output line must be in the reported text: {:?}", + view.text + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} diff --git a/crates/tmux-mcp/src/lib.rs b/crates/tmux-mcp/src/lib.rs index b6fc5218..376bc0f1 100644 --- a/crates/tmux-mcp/src/lib.rs +++ b/crates/tmux-mcp/src/lib.rs @@ -39,6 +39,7 @@ pub mod cli; pub mod resources; mod caller; +mod echo; mod exec; mod identity; mod manifest; @@ -91,6 +92,9 @@ pub struct TmuxTools { socket: Arc>>, /// Live per-pane output, for `capture_since`. tails: Arc, + /// What this process has typed into panes but not submitted, and what it + /// has recently submitted, for `wait_for_text` to discount its own echo. + echoes: Arc, /// The startup-resolved router used for both listing and dispatch. tool_router: rmcp::handler::server::router::tool::ToolRouter, /// Aggregate-only child routes, retained without advertising direct calls. diff --git a/crates/tmux-mcp/src/policy.rs b/crates/tmux-mcp/src/policy.rs index da2e2e8d..8a913ed1 100644 --- a/crates/tmux-mcp/src/policy.rs +++ b/crates/tmux-mcp/src/policy.rs @@ -428,6 +428,7 @@ impl Builder { capability_report: Arc::new(resolved.report), socket: Arc::new(OnceLock::new()), tails: Arc::new(Tails::new(identity)), + echoes: Arc::new(crate::echo::PaneEchoes::new()), tool_router: router, nested_tool_router: resolved.nested_router, environment_values: Arc::new(self.environment_values), diff --git a/crates/tmux-mcp/src/run_request.rs b/crates/tmux-mcp/src/run_request.rs index 6f5c54dd..936a2960 100644 --- a/crates/tmux-mcp/src/run_request.rs +++ b/crates/tmux-mcp/src/run_request.rs @@ -25,12 +25,12 @@ struct RunKey { } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -struct EndpointIdentity { +pub(crate) struct EndpointIdentity { device: u64, inode: u64, } -fn endpoint_identity(path: &Path) -> std::io::Result { +pub(crate) fn endpoint_identity(path: &Path) -> std::io::Result { let metadata = std::fs::metadata(path)?; Ok(EndpointIdentity { device: metadata.dev(), diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index c4ec3f56..c767392b 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -156,20 +156,43 @@ impl TmuxTools { "pane {pane} changed its configured input authority before send dispatch" ))); } + + // Computed before dispatch: a line this call submits is recorded as + // a fresh echo immediately, because the terminal's echo can reach a + // waiting `wait_for_text` client before this call returns. The + // pane's line itself is not published until dispatch is confirmed + // below, so a concurrent reader never sees it reported empty while + // tmux is still typing it for real. + let mut tracked_keys = keys.clone(); + if enter { + tracked_keys.push("Enter".to_owned()); + } + let echo_update = self.echoes.apply( + plan.generation, + &plan.endpoint, + &plan.configured, + text.as_deref(), + &tracked_keys, + ); + let dispatch = input_dispatch(plan.target.id().as_ref(), text, keys, enter) .ok_or_else(|| bad_input("send_keys needs text, keys, or enter".to_owned()))?; let mut boundary = EffectBoundary::new("send_keys"); if dispatch.command_count() > 1 { boundary.mark(); } - let result = self - .server - .chain(dispatch) - .await - .map_err(|error| boundary.error(error))?; + let result = match self.server.chain(dispatch).await { + Ok(result) => result, + Err(error) => { + self.echoes.abandon(echo_update); + return Err(boundary.error(error)); + } + }; if let Some(error) = result.refusal_for("send-keys") { + self.echoes.abandon(echo_update); return Err(boundary.error(error)); } + self.echoes.commit(echo_update); Ok(Json(Sent { pane: plan.target.id().to_string(), @@ -342,7 +365,10 @@ impl TmuxTools { refuses the whole call, and so does an active run_shell_command, except \ for keys C-c or C-\\ sent alone, which interrupt it. Returned pane IDs \ describe configured membership, not confirmed delivery. The \ - observation can race with tmux processing the input.", + observation can race with tmux processing the input. A submitted line is \ + remembered briefly so wait_for_text can discount its own echo; an \ + unrecognized key stops that for the pane's current line rather than mask \ + it inaccurately.", title = "Send Keys To Pane", meta = crate::capability_meta!(Execute, PaneInput, [Change], [TmuxMetadata], true, true, { "pane" => [TmuxLookup], diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index 23c8d392..5d631173 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -347,10 +347,13 @@ impl TmuxTools { redrawn in place repeats; capture_pane shows the screen. Prefer \ run_shell_command for commands you are sending yourself: it reports an exit \ status instead of guessing from output. Use this for output you did \ - not author, such as a server logging that it is ready. A pattern that is a \ - substring of a command you just sent with send_keys can already be on \ - screen as its echo; outcome present_at_entry reports that rather than \ - matched, so a still-pending command does not read as already done. \ + not author, such as a server logging that it is ready. A line this server \ + itself typed and submitted with send_keys is discounted from a match for \ + a short time afterward, so waiting for text you just sent does not match \ + its own echo; output that happens to repeat the same words still does. A \ + pattern still on the row being typed into, not yet submitted, reports \ + outcome pending instead of matched, and one already on a completed row \ + before this call attached reports present_at_entry. \ Waiting owns an observer client until the wait ends. Each list accepts \ at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time \ regex engine.", @@ -397,10 +400,26 @@ impl TmuxTools { let stops = compile(stop.unwrap_or_default())?; let target = self.find_pane(&pane).await?; + // Best-effort: a generation this crate cannot read is not a reason + // to refuse the wait, only to skip discounting this server's own + // recent echo on it. + let key = self.server.generation().await.ok().and_then(|generation| { + crate::echo::EchoKey::new(generation, self.server.socket_path(), target.id().as_ref()) + }); let view = reporting( reporter, "still watching for the pattern", - exec::wait_for_text(&target, &wanted, &stops, Self::budget(seconds), &cancelled), + exec::wait_for_text( + &target, + &wanted, + &stops, + Self::budget(seconds), + &cancelled, + exec::EchoContext { + echoes: &self.echoes, + key: key.as_ref(), + }, + ), ) .await .map_err(|e| tmux_error(&e))?; diff --git a/crates/tmux-mcp/tests/echo_contract.rs b/crates/tmux-mcp/tests/echo_contract.rs new file mode 100644 index 00000000..8e84e294 --- /dev/null +++ b/crates/tmux-mcp/tests/echo_contract.rs @@ -0,0 +1,494 @@ +//! Scenario tests for the echo contract: `wait_for_text` versus the pane's +//! echo of text this MCP server itself typed with `send_keys`. +//! +//! Scenarios S1-S6 below are named to match the contract they were drafted +//! against. Every "matches real output" assertion pairs a floor on elapsed +//! time with the outcome: a match that lands before the pane's own `sleep 1` +//! can finish is a match on the echo, not on what the command produced. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::time::{Duration, Instant}; + +use libtmux::test::TestServer; +use tokio_util::sync::CancellationToken; + +mod support; + +use support::{args, bare_tools, json, prompt_ready}; + +/// S1: a short, unsubmitted answer must never mask later real output that +/// contains it as a substring, and must never mask a whole row. +/// +/// `y` is typed and left pending (no Enter) while a command already +/// submitted earlier is still running in the background; when it finishes, +/// its output ends in the same letter the pending answer is. A wait for +/// `ready` must still see it. +#[tokio::test] +async fn s1_a_pending_answer_never_hides_real_output_containing_it() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s1"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + // A real, independent producer of "ready" -- backgrounded so the prompt + // returns immediately and the pending keystroke below lands on a fresh + // line, not mid-dispatch of this one. + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "(sleep 1; echo ready) &", + "enter": true + }))) + .await + .expect("the background job starts"); + + // A short pending answer that is also the last letter of the word this + // waits for: proof against masking by "contains this substring" + // instead of by the submitted line it actually is. + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "y", + "enter": false + }))) + .await + .expect("the pending keystroke is typed"); + + let started = Instant::now(); + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["ready"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + assert_eq!(view["outcome"], "matched", "{view}"); + assert!( + started.elapsed() >= Duration::from_millis(800), + "matched after only {:?}, too fast to be the background job's real output", + started.elapsed() + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// S2 (wait started after the send): a line this server typed and submitted +/// is not the match; only the command's own output is. +#[tokio::test] +async fn s2_after_a_submitted_commands_echo_is_not_the_match() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s2-after"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "sleep 1; echo MARKER", + "enter": true + }))) + .await + .expect("the command is sent and this call returns only once it is"); + + let started = Instant::now(); + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + assert_eq!( + view["outcome"], "matched", + "must not time out over a masked echo either: {view}" + ); + assert!( + started.elapsed() >= Duration::from_millis(800), + "matched after only {:?}, too fast to be sleep 1's real output -- the echo, not the \ + output, was matched", + started.elapsed() + ); + assert!( + view["text"] + .as_str() + .unwrap_or_default() + .lines() + .any(|line| line.trim() == "MARKER"), + "the bare output line must be in the reported text: {view}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// S3: text typed but never submitted times out (reported here as `pending`, +/// this port's outcome for exactly this case) rather than matching, and does +/// so promptly rather than waiting out the deadline. +#[tokio::test] +async fn s3_unsubmitted_text_is_pending_not_matched() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s3"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo MARKER", + "enter": false + }))) + .await + .expect("the line is typed but not submitted"); + + let started = Instant::now(); + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 1 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + assert_eq!(view["outcome"], "pending", "{view}"); + assert!( + started.elapsed() < Duration::from_millis(500), + "an unsubmitted pattern must not wait out the deadline: {:?}", + started.elapsed() + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// S4: edits made before a line is submitted are applied, and none of the +/// key names used to make them -- `BSpace` here -- becomes part of the +/// submitted text. The precise claim that `BSpace` never becomes literal +/// text is checked at the unit level +/// (`crate::echo::tests::backspaces_reach_an_earlier_calls_pending_text`); +/// this is the end-to-end shape of the same workflow. +#[tokio::test] +async fn s4_edits_are_applied_before_a_line_is_submitted() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s4"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "xMARKER", + "enter": false + }))) + .await + .expect("the typo is typed"); + let backspaces: Vec<&str> = vec!["BSpace"; 7]; + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "keys": backspaces + }))) + .await + .expect("the typo is erased in a separate call"); + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "echo MARKER", + "enter": true + }))) + .await + .expect("the corrected line is submitted"); + + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + // No `sleep` in this scenario, so the correction, submission, and its + // output can all land before `wait_for_text` even attaches; either + // outcome below means the mask found the real row, not the echo. + assert!( + matches!( + view["outcome"].as_str(), + Some("matched" | "present_at_entry") + ), + "{view}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// S5: unsubmitted type-ahead into a pane whose shell has not drawn its +/// first prompt yet must never be reported as a match. No `prompt_ready` +/// wait here -- a cold shell is the point of this scenario. +#[tokio::test] +async fn s5_unsubmitted_type_ahead_into_a_cold_shell_is_never_matched() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + // A shell that reads nothing for a moment: this server's own type-ahead + // can arrive on the live stream looking exactly like fresh output once + // the pane's process starts reading it. + guard + .server() + .cmd( + libtmux::Command::new("set-option") + .arg("-g") + .arg("default-command") + .arg("stty raw -echo; sleep 0.4; exec cat"), + ) + .await + .expect("the cold-shell fixture command is set"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s5"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "MARKER", + "enter": false + }))) + .await + .expect("type-ahead is queued before the shell reads it"); + + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 2 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + assert_ne!( + view["outcome"], "matched", + "unsubmitted type-ahead must never read as a match: {view}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// S6: after a key this server cannot apply to its tracked text (an arrow +/// key here), it stops discounting that pane's current line rather than +/// keep masking text an edit it could not represent may have moved past. +/// bash runs the pane so `Left` has its ordinary readline meaning -- pure +/// cursor movement, no visible effect on the line -- rather than the +/// undefined one plain `/bin/sh` (this fixture's default) gives it. +#[tokio::test] +async fn s6_an_unrecognized_key_stops_discounting_the_line() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + guard + .server() + .cmd( + libtmux::Command::new("set-option") + .arg("-g") + .arg("default-command") + .arg("/bin/bash --noprofile --norc"), + ) + .await + .expect("bash is set as the pane's command"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s6"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "MARKER", + "enter": false + }))) + .await + .expect("the line is typed"); + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "keys": ["Left"] + }))) + .await + .expect("an unmodelable key is sent"); + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "keys": ["Enter"] + }))) + .await + .expect("the line is submitted"); + + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 3 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + // No `sleep` here either -- bash rejects the bare command fast enough + // that this can resolve as `present_at_entry` as easily as `matched`. + // The discriminator is the outcome family, not which of the two it is: + // a stale mask would remove `MARKER` from both the echo and the error + // line, since both are exactly that word, and time out instead. + assert!( + matches!( + view["outcome"].as_str(), + Some("matched" | "present_at_entry") + ), + "an unrecognized key must stop discounting the line rather than keep hiding a real \ + error message that happens to repeat it: {view}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// Resize constraint: a window resize between a command being submitted and +/// its output arriving must not defeat the mask. This mask is keyed to the +/// submitted line's own text, not to a remembered row, so a resize is not +/// expected to change the outcome here -- this is the no-resize control +/// (`s2_after_a_submitted_commands_echo_is_not_the_match`) run again with a +/// resize between submit and wait, to check that stays true. +#[tokio::test] +async fn a_resize_between_submit_and_output_does_not_defeat_the_mask() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "resize"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + tools + .send_keys(args(serde_json::json!({ + "pane": pane, + "text": "sleep 1; echo MARKER", + "enter": true + }))) + .await + .expect("the command is sent"); + + // Reflow the pane's rows before the real output arrives. + guard + .server() + .cmd( + libtmux::Command::new("resize-window") + .arg("-t") + .arg(&pane) + .arg("-x") + .arg("50") + .arg("-y") + .arg("20"), + ) + .await + .expect("the window resizes"); + + let started = Instant::now(); + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + assert_eq!(view["outcome"], "matched", "{view}"); + assert!( + started.elapsed() >= Duration::from_millis(800), + "matched after only {:?}, too fast to be real output", + started.elapsed() + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From bd4349da8a48395c09dd5e63454346ac32c925ea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 14:34:40 -0500 Subject: [PATCH 112/117] Echo(fix[coverage]): Mask paste and run's own echo why: wait_for_text discounted a submitted line typed with send_keys, but paste_text pastes a line just as literally and run_shell_command types a short loader line to run its staged frame; either one's own echo could still satisfy a later wait. what: - Route paste_text's dispatch through the same record: apply before the tmux paste-buffer call, commit or abandon on its result - Route run_shell_command's dispatch the same way, recording the actual typed loader line (PreparedRun::typed_line), not the requested command text, since that is what the pane echoes; an unproven dispatch outcome is kept rather than abandoned, since a false match on a real echo outranks over-discounting - Add real-tmux tests for both gaps, shown to fail against the prior commit for the stated reason - State the widened contract in wait_for_text's and paste_text's descriptions, including the still-open wrapped-line gap --- crates/tmux-mcp/TOOLS.md | 4 +- crates/tmux-mcp/src/echo.rs | 7 +- crates/tmux-mcp/src/exec/run.rs | 9 ++ crates/tmux-mcp/src/run_request.rs | 34 +++++- crates/tmux-mcp/src/tools/control.rs | 36 +++++- crates/tmux-mcp/src/tools/observe.rs | 14 ++- crates/tmux-mcp/tests/echo_contract.rs | 154 +++++++++++++++++++++++++ 7 files changed, 240 insertions(+), 18 deletions(-) diff --git a/crates/tmux-mcp/TOOLS.md b/crates/tmux-mcp/TOOLS.md index ad296639..d02ac527 100644 --- a/crates/tmux-mcp/TOOLS.md +++ b/crates/tmux-mcp/TOOLS.md @@ -336,7 +336,7 @@ Move one window to a session and index. Change tmux state; no client-supplied ex ## `paste_text` -Put text into a pane through a tmux paste buffer instead of typing it key by key. Use this for anything long or awkward: send_keys types the text, so a shell reading it can react to each character, and a bracketed-paste aware program treats a paste as one block. Optional Enter is appended to that same block. Empty text without Enter is a guarded buffer-free no-op. Paste targets only the named pane, even when synchronized input is enabled. A dead, input-disabled, mode-owned, terminal-attended, or inherited-caller target is refused before setup and again immediately before paste. The private buffer is deleted after setup, refusal, and paste outcomes; observations can still race with tmux. Send input to a pane's program; a shell that receives it runs it with your user's permissions. +Put text into a pane through a tmux paste buffer instead of typing it key by key. Use this for anything long or awkward: send_keys types the text, so a shell reading it can react to each character, and a bracketed-paste aware program treats a paste as one block. Optional Enter is appended to that same block. Empty text without Enter is a guarded buffer-free no-op. Paste targets only the named pane, even when synchronized input is enabled. A dead, input-disabled, mode-owned, terminal-attended, or inherited-caller target is refused before setup and again immediately before paste. The private buffer is deleted after setup, refusal, and paste outcomes; observations can still race with tmux. A submitted paste is remembered briefly so wait_for_text can discount its own echo. Send input to a pane's program; a shell that receives it runs it with your user's permissions. - Toolset: `execute` - Process reach: `pane-input` @@ -711,7 +711,7 @@ Block until something signals a tmux wait-for channel. A pending signal is consu ## `wait_for_text` -Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A line this server itself typed and submitted with send_keys is discounted from a match for a short time afterward, so waiting for text you just sent does not match its own echo; output that happens to repeat the same words still does. A pattern still on the row being typed into, not yet submitted, reports outcome pending instead of matched, and one already on a completed row before this call attached reports present_at_entry. Waiting owns an observer client until the wait ends. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. +Wait until a pane writes matching text. Reads the pane's live output stream, so text that scrolls past between checks is still seen. The returned text is that raw stream, not the rendered screen: a line redrawn in place repeats; capture_pane shows the screen. Prefer run_shell_command for commands you are sending yourself: it reports an exit status instead of guessing from output. Use this for output you did not author, such as a server logging that it is ready. A line this server itself types and submits -- with send_keys, paste_text, or run_shell_command's own dispatch -- is discounted from a match for a short time afterward, so waiting for text you just sent does not match its own echo; output that happens to repeat the same words still does. A submitted line that wrapped across terminal rows when it was typed is not discounted. A pattern still on the row being typed into, not yet submitted, reports outcome pending instead of matched, and one already on a completed row before this call attached reports present_at_entry. Waiting owns an observer client until the wait ends. Each list accepts at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time regex engine. Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted. - Toolset: `inspect` - Process reach: `none` diff --git a/crates/tmux-mcp/src/echo.rs b/crates/tmux-mcp/src/echo.rs index cd8166b6..fc652317 100644 --- a/crates/tmux-mcp/src/echo.rs +++ b/crates/tmux-mcp/src/echo.rs @@ -132,7 +132,7 @@ impl PaneRecord { } } -/// What one `send_keys` dispatch does to a pane's still-being-typed line. +/// What one pane-input dispatch does to a pane's still-being-typed line. struct DispatchOutcome { /// The line this dispatch submitted, when one of its keys did. submitted: Option, @@ -199,8 +199,9 @@ fn apply_dispatch(pending: &mut String, text: Option<&str>, keys: &[String]) -> /// /// Dropping this without calling either leaks that dispatch's share of /// `in_flight`, which would leave the pane's line permanently excluded from -/// [`PaneEchoes::has_pending`]'s relaxation; every path in `send_keys_one` -/// that can end a dispatch must reach one of the two. +/// [`PaneEchoes::has_pending`]'s relaxation; every path in `send_keys_one`, +/// `paste_text`, and `run_request::run` that can end a dispatch must reach +/// one of the two. pub(crate) struct EchoUpdate { /// The pane's line as it will read once this dispatch is confirmed. pending: Vec<(EchoKey, String)>, diff --git a/crates/tmux-mcp/src/exec/run.rs b/crates/tmux-mcp/src/exec/run.rs index e78852ea..39e5f6e2 100644 --- a/crates/tmux-mcp/src/exec/run.rs +++ b/crates/tmux-mcp/src/exec/run.rs @@ -340,6 +340,15 @@ async fn remove_frame(path: &Path) { } impl PreparedRun { + /// The exact line this run's dispatch will type into the pane. + /// + /// Not the requested command: the command runs from a staged frame file + /// this loads and evaluates, so this is the short loader line the pane's + /// terminal actually echoes. + pub(crate) fn typed_line(&self) -> &OsStr { + &self.staged + } + /// Send the prepared payload and Enter while retaining its watcher. pub(crate) async fn dispatch(self) -> RunDispatch { let Self { diff --git a/crates/tmux-mcp/src/run_request.rs b/crates/tmux-mcp/src/run_request.rs index 936a2960..861088ad 100644 --- a/crates/tmux-mcp/src/run_request.rs +++ b/crates/tmux-mcp/src/run_request.rs @@ -190,6 +190,7 @@ pub(crate) struct RunTransport<'a> { pub(crate) endpoint: &'a Path, pub(crate) shell: &'a [u8], pub(crate) lease: PaneReservation, + pub(crate) echoes: &'a crate::echo::PaneEchoes, } /// Why a request-owned pane command could not establish a result. @@ -303,6 +304,7 @@ pub(crate) async fn run( endpoint, shell, lease, + echoes, } = transport; let prepared = exec::prepare_run(pane, command, suppress_history, executable, endpoint, shell).await?; @@ -310,10 +312,35 @@ pub(crate) async fn run( let _ = prepared.shutdown().await; return Err(RunError::Guard(error)); } + + // Recorded before dispatch, the same as send_keys: the terminal's echo + // of this loader line can reach a waiting `wait_for_text` client before + // tmux even confirms whether it accepted the input. + let pane_id = pane.id().to_string(); + let typed_line = prepared.typed_line().to_str().map(str::to_owned); + let echo_update = echoes.apply( + generation, + endpoint, + std::slice::from_ref(&pane_id), + typed_line.as_deref(), + &[String::from("Enter")], + ); + let run = match prepared.dispatch().await { - exec::RunDispatch::Confirmed(run) => run, - exec::RunDispatch::NotDispatched(error) => return Err(RunError::Tmux(error)), + exec::RunDispatch::Confirmed(run) => { + echoes.commit(echo_update); + run + } + exec::RunDispatch::NotDispatched(error) => { + // Proven never to have reached the pane: nothing to discount. + echoes.abandon(echo_update); + return Err(RunError::Tmux(error)); + } exec::RunDispatch::Unknown { run, error } => { + // Delivery is unproven either way, so this is kept rather than + // abandoned: a false match on a real echo is worse than masking + // one that never reached the pane. + echoes.commit(echo_update); let proof = run.proof(server.clone(), generation); let server = server.clone(); retain_lease_until( @@ -332,7 +359,6 @@ pub(crate) async fn run( } }; - let pane_id = pane.id().to_string(); let progress = Arc::new(Mutex::new(Progress::new())); let update = Arc::clone(&progress); let proof = run.proof(server.clone(), generation); @@ -423,6 +449,7 @@ mod tests { let panes = vec![pane.id().to_string()]; let lease = reserve(generation, guard.server().socket_path(), &panes) .expect("the fixture pane is unreserved"); + let echoes = crate::echo::PaneEchoes::new(); // The count is read after `run` returns, so a shutdown that had not // completed first would report zero. @@ -439,6 +466,7 @@ mod tests { endpoint: guard.server().socket_path(), shell: b"sh", lease, + echoes: &echoes, }, async { Err(refusal) }, )) diff --git a/crates/tmux-mcp/src/tools/control.rs b/crates/tmux-mcp/src/tools/control.rs index c767392b..f512d059 100644 --- a/crates/tmux-mcp/src/tools/control.rs +++ b/crates/tmux-mcp/src/tools/control.rs @@ -614,7 +614,9 @@ impl TmuxTools { synchronized input is enabled. A dead, input-disabled, mode-owned, \ terminal-attended, or inherited-caller target is refused before setup \ and again immediately before paste. The private buffer is deleted after \ - setup, refusal, and paste outcomes; observations can still race with tmux.", + setup, refusal, and paste outcomes; observations can still race with tmux. \ + A submitted paste is remembered briefly so wait_for_text can discount its \ + own echo.", title = "Paste Text Into Pane", meta = crate::capability_meta!(Execute, PaneInput, [Change], [TmuxMetadata], true, true, { "pane" => [TmuxLookup], @@ -640,6 +642,7 @@ impl TmuxTools { bytes, })); } + let echoed_text = text.clone(); let mut payload = text; if enter { payload.push('\n'); @@ -667,7 +670,7 @@ impl TmuxTools { delete_private_paste_buffer(&self.server, &buffer).await, )); }; - let target = match self + let plan = match self .preflight_reserved_pane_input( &pane, PaneInputReach::TargetOnly, @@ -676,7 +679,7 @@ impl TmuxTools { ) .await { - Ok(plan) if initial.same_authority(&plan) && plan.owns(&reservation) => plan.target, + Ok(plan) if initial.same_authority(&plan) && plan.owns(&reservation) => plan, Ok(_) => { return Err(cleanup_after_refusal( bad_input(format!( @@ -692,12 +695,35 @@ impl TmuxTools { )); } }; - let pasted = target.paste_buffer(Some(&buffer)).await; + + // Recorded before dispatch, the same as send_keys: a paste's echo + // can reach a waiting `wait_for_text` client before this call even + // returns. + let target_id = plan.target.id().to_string(); + let mut tracked_keys = Vec::new(); + if enter { + tracked_keys.push("Enter".to_owned()); + } + let echo_text = (!echoed_text.is_empty()).then_some(echoed_text.as_str()); + let echo_update = self.echoes.apply( + plan.generation, + &plan.endpoint, + std::slice::from_ref(&target_id), + echo_text, + &tracked_keys, + ); + + let pasted = plan.target.paste_buffer(Some(&buffer)).await; + if pasted.is_ok() { + self.echoes.commit(echo_update); + } else { + self.echoes.abandon(echo_update); + } let deleted = delete_private_paste_buffer(&self.server, &buffer).await; paste_outcome(pasted, deleted).map_err(|error| tmux_error(&error))?; Ok(Json(Pasted { - pane: target.id().to_string(), + pane: plan.target.id().to_string(), bytes, })) } diff --git a/crates/tmux-mcp/src/tools/observe.rs b/crates/tmux-mcp/src/tools/observe.rs index 5d631173..59e68d33 100644 --- a/crates/tmux-mcp/src/tools/observe.rs +++ b/crates/tmux-mcp/src/tools/observe.rs @@ -329,6 +329,7 @@ impl TmuxTools { endpoint: &route.endpoint, shell: foreground.as_bytes(), lease, + echoes: self.echoes.as_ref(), }, final_check, ), @@ -348,12 +349,14 @@ impl TmuxTools { run_shell_command for commands you are sending yourself: it reports an exit \ status instead of guessing from output. Use this for output you did \ not author, such as a server logging that it is ready. A line this server \ - itself typed and submitted with send_keys is discounted from a match for \ - a short time afterward, so waiting for text you just sent does not match \ + itself types and submits -- with send_keys, paste_text, or \ + run_shell_command's own dispatch -- is discounted from a match for a \ + short time afterward, so waiting for text you just sent does not match \ its own echo; output that happens to repeat the same words still does. A \ - pattern still on the row being typed into, not yet submitted, reports \ - outcome pending instead of matched, and one already on a completed row \ - before this call attached reports present_at_entry. \ + submitted line that wrapped across terminal rows when it was typed is not \ + discounted. A pattern still on the row being typed into, not yet \ + submitted, reports outcome pending instead of matched, and one already on \ + a completed row before this call attached reports present_at_entry. \ Waiting owns an observer client until the wait ends. Each list accepts \ at most 32 patterns, each at most 4,096 bytes, using Rust's linear-time \ regex engine.", @@ -782,6 +785,7 @@ mod tests { endpoint: &socket, shell: foreground.as_bytes(), lease, + echoes: tools.echoes.as_ref(), }, final_check, ) diff --git a/crates/tmux-mcp/tests/echo_contract.rs b/crates/tmux-mcp/tests/echo_contract.rs index 8e84e294..b611d34a 100644 --- a/crates/tmux-mcp/tests/echo_contract.rs +++ b/crates/tmux-mcp/tests/echo_contract.rs @@ -492,3 +492,157 @@ async fn a_resize_between_submit_and_output_does_not_defeat_the_mask() { guard.shutdown().await.expect("tmux fixture shuts down"); } + +/// S2 via `paste_text`: a line this server pastes and submits with Enter is +/// not the match; only the command's own output is. +#[tokio::test] +async fn s2_pasted_text_with_enter_is_not_the_match() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s2-paste"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + + tools + .paste_text(args(serde_json::json!({ + "pane": pane, + "text": "sleep 1; echo MARKER", + "enter": true + }))) + .await + .expect("the command is pasted and this call returns only once it is"); + + let started = Instant::now(); + let view = json( + tools + .wait_for_text( + args(serde_json::json!({ + "pane": pane, + "patterns": ["MARKER"], + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + + assert_eq!( + view["outcome"], "matched", + "must not time out over a masked echo either: {view}" + ); + assert!( + started.elapsed() >= Duration::from_millis(800), + "matched after only {:?}, too fast to be sleep 1's real output -- the paste's own echo, \ + not the output, was matched", + started.elapsed() + ); + assert!( + view["text"] + .as_str() + .unwrap_or_default() + .lines() + .any(|line| line.trim() == "MARKER"), + "the bare output line must be in the reported text: {view}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +/// S2 via `run_shell_command`: the short line it types to load and run its +/// staged command frame must not read as a later wait's match; the +/// command's real output still does. +/// +/// The window is widened before dispatch: the staged-frame line's length +/// depends on its random nonce and can approach the default 80-column +/// width, and a wrapped line is past what the whole-line mask can find -- +/// the gap `wait_for_text` documents. +#[tokio::test] +async fn s2_run_shell_commands_dispatch_line_is_not_the_match() { + let guard = TestServer::builder().start().await.expect("tmux starts"); + let tools = bare_tools(guard.server()); + tools + .create_session(args(serde_json::json!({"name": "s2-run"}))) + .await + .expect("session starts"); + let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"] + .as_str() + .expect("pane id") + .to_owned(); + prompt_ready(guard.server(), &pane).await; + guard + .server() + .cmd( + libtmux::Command::new("resize-window") + .arg("-t") + .arg(&pane) + .arg("-x") + .arg("120") + .arg("-y") + .arg("24"), + ) + .await + .expect("the window widens past the staged frame line's length"); + + let finished = json( + tools + .run_command( + args(serde_json::json!({ + "pane": pane, + "command": "printf done", + "seconds": 5 + })), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("the command runs"), + ); + assert_eq!(finished["outcome"], "completed", "{finished}"); + + // "eval" names no output of "printf done"; its only appearance on the + // pane is the line run_shell_command itself typed to load and run the + // staged frame. A wait for it must not read that echo as a match. + let echo = json( + tools + .wait_for_text( + args(serde_json::json!({"pane": pane, "patterns": ["eval"], "seconds": 1})), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + assert!( + !matches!( + echo["outcome"].as_str(), + Some("matched" | "present_at_entry") + ), + "run_shell_command's own dispatch line must not read as a match: {echo}" + ); + + // The command's real output is unaffected by masking the dispatch line. + let output = json( + tools + .wait_for_text( + args(serde_json::json!({"pane": pane, "patterns": ["done"], "seconds": 1})), + CancellationToken::new(), + tmux_mcp::Reporter::none(), + ) + .await + .expect("wait completes"), + ); + assert_eq!( + output["outcome"], "present_at_entry", + "the command's real output must still be found: {output}" + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From d9b288aadd705fca3fafc6ffe2de584be97d293d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 15:05:26 -0500 Subject: [PATCH 113/117] Test(fix[deadline]): Scale two cancellation waits why: cancelling_a_line_send_cannot_leave_enter_undispatched used fixed 1s and 5s budgets for two real-tmux channel waits, unlike every other wait_for_channel call in this suite. CI's own run timed out on the second wait even though tmux answered it, since nothing here stretched for a loaded machine. what: - Scale both waits with libtmux::test::scaled, matching the convention crates/libtmux/tests/server_command.rs already uses for every wait_for_channel call there --- crates/libtmux/tests/mutations.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/libtmux/tests/mutations.rs b/crates/libtmux/tests/mutations.rs index a5653455..7d6e65dc 100644 --- a/crates/libtmux/tests/mutations.rs +++ b/crates/libtmux/tests/mutations.rs @@ -7,7 +7,7 @@ use std::time::Duration; -use libtmux::test::{TestServer, retry_until}; +use libtmux::test::{TestServer, retry_until, scaled}; use libtmux::{ErrorKind, Layout, NewSessionOptions, NewWindowOptions}; use libtmux::{SplitDirection, SplitOptions, TmuxText}; @@ -513,7 +513,7 @@ async fn cancelling_a_line_send_cannot_leave_enter_undispatched() { }); assert_eq!( server - .wait_for_channel(accepted, Duration::from_secs(5)) + .wait_for_channel(accepted, scaled(Duration::from_secs(5))) .await .expect("the send can signal"), libtmux::ChannelWait::Signalled, @@ -534,7 +534,7 @@ async fn cancelling_a_line_send_cannot_leave_enter_undispatched() { assert_eq!( server - .wait_for_channel(ran, Duration::from_secs(1)) + .wait_for_channel(ran, scaled(Duration::from_secs(1))) .await .expect("the command signal can be read"), libtmux::ChannelWait::Signalled, From 7c71d855a668d534e77b13b718ff5e249c417ff9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 17:07:00 -0500 Subject: [PATCH 114/117] Plan(fix): Validate control-mode layouts why: Invalid select-layout values can terminate older tmux daemons. The control-mode runner must reject them before any plan effects. what: - Reuse the window layout validator with the connected daemon version - Check layouts before dispatching control-mode plan operations - Cover daemon survival, refusal before effects, and valid presets --- crates/libtmux/src/plan/run.rs | 49 +++++++++++++++++++++++--- crates/libtmux/src/window.rs | 17 ++++----- crates/libtmux/tests/plan.rs | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/crates/libtmux/src/plan/run.rs b/crates/libtmux/src/plan/run.rs index 9d676840..b375d581 100644 --- a/crates/libtmux/src/plan/run.rs +++ b/crates/libtmux/src/plan/run.rs @@ -466,7 +466,10 @@ impl Plan { async fn validate_layouts(&self, server: &Server) -> Result<(), Error> { for operation in self.steps() { if let Op::SelectLayout(select) = operation { - Window::validate_saved_layout(server, select.layout()).await?; + Window::validate_saved_layout( + server.capabilities().await?.tmux_version(), + select.layout(), + )?; } } Ok(()) @@ -733,6 +736,42 @@ impl Plan { Ok(()) } + async fn validate_control_layouts( + &self, + sender: &crate::control::ControlSender, + ) -> Result<(), Error> { + if !self + .steps() + .iter() + .any(|op| matches!(op, Op::SelectLayout(_))) + { + return Ok(()); + } + let block = sender + .send( + Command::new("display-message") + .arg("-p") + .arg("--") + .arg("tmux #{version}"), + ) + .await?; + if let Some(error) = block.refusal_for("display-message") { + return Err(error); + } + let mut output = Vec::new(); + for line in block.output() { + output.extend_from_slice(line.as_bytes()); + output.push(b'\n'); + } + let version = crate::TmuxVersion::parse_output(&output)?; + for operation in self.steps() { + if let Op::SelectLayout(select) = operation { + Window::validate_saved_layout(&version, select.layout())?; + } + } + Ok(()) + } + /// Run this plan over an open control-mode connection. /// /// Control mode is the one transport that separates *how many commands* @@ -752,10 +791,9 @@ impl Plan { /// return valid IDs. Validation happens before the first command. A command /// tmux refuses is reported in the [`PlanResult`]. /// - /// A [`super::ops::SelectLayout`] here does not get [`Self::run`]'s - /// `select-layout` guard: that check needs a version probe, and this - /// connection carries no [`Server`] to run one against. A layout value - /// this cannot parse still reaches tmux directly. + /// Layouts are checked against the connected daemon's version before + /// any operation runs. Invalid or unsupported layouts return the same + /// errors as [`Window::select_layout`]. /// /// A plan holding a [`super::ops::Pause`] fails with /// [`crate::ControlModeErrorKind::BlockingCommand`] before anything is @@ -774,6 +812,7 @@ impl Plan { self.validate() .map_err(|source| Error::InvalidPlan { source })?; self.refuse_pause_over_control_mode()?; + self.validate_control_layouts(sender).await?; let mut bound: HashMap<(usize, Part), OsString> = HashMap::new(); let mut outcomes = vec![Outcome::Skipped; self.len()]; let mut reported = Vec::with_capacity(self.len()); diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 1124ec60..16b4bd6f 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -476,7 +476,7 @@ impl Window { } LayoutSpec::Saved(saved) => { let server = crate::Server::from_core(Arc::clone(&self.core)); - Self::validate_saved_layout(&server, saved).await? + Self::validate_saved_layout(server.capabilities().await?.tmux_version(), saved)? } }; @@ -517,20 +517,17 @@ impl Window { /// one preset available on the running release; and /// [`crate::ErrorKind::UnsupportedVersion`] for a preset or a JSON /// layout this release predates. - pub(crate) async fn validate_saved_layout( - server: &crate::Server, + pub(crate) fn validate_saved_layout( + version: &crate::TmuxVersion, saved: &OsStr, ) -> Result { // Checked here rather than left to tmux: 3.3 and 3.3a exit on a // value `select-layout` cannot parse, taking every session on the // socket with them, and `--` does not help -- it turns `-o` from // the undo flag into exactly such a value. - let version = server.capabilities().await?.tmux_version().clone(); - match SavedLayout::classify(saved, &version) { + match SavedLayout::classify(saved, version) { SavedLayout::Preset(named) => { - server - .require(named.as_str(), named.minimum_release()) - .await?; + version.require(named.as_str(), named.minimum_release())?; // The resolved name, not whatever prefix the caller spelled: // a caller who typed `tile` gets `tiled` sent, not a second // round of tmux's own matching. @@ -538,9 +535,7 @@ impl Window { } SavedLayout::Classic => Ok(saved.to_owned()), SavedLayout::Json => { - server - .require("a JSON layout string", crate::version::since::JSON_LAYOUTS) - .await?; + version.require("a JSON layout string", crate::version::since::JSON_LAYOUTS)?; Ok(saved.to_owned()) } SavedLayout::Ambiguous(candidates) => Err(Error::AmbiguousLayout { diff --git a/crates/libtmux/tests/plan.rs b/crates/libtmux/tests/plan.rs index e87cd63b..40c1f218 100644 --- a/crates/libtmux/tests/plan.rs +++ b/crates/libtmux/tests/plan.rs @@ -854,6 +854,70 @@ async fn a_plan_refuses_a_layout_value_select_layout_cannot_parse() { guard.shutdown().await.expect("tmux fixture shuts down"); } +#[cfg(feature = "control-mode")] +#[tokio::test] +async fn real_tmux_compat_control_plan_validates_layouts_before_effects() { + use libtmux::control::ControlMode; + + let guard = TestServer::builder().start().await.expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("layout-control") + .await + .expect("session is created"); + let (sender, events) = ControlMode::attach(server, session.id()) + .await + .expect("control mode attaches") + .split(); + + let mut invalid = vec![ + ("-o", libtmux::ErrorKind::InvalidInput), + ("garbage", libtmux::ErrorKind::InvalidInput), + ("", libtmux::ErrorKind::InvalidInput), + ]; + if !server + .capabilities() + .await + .expect("capabilities") + .tmux_version() + .has_behavior(&libtmux::since::JSON_LAYOUTS) + { + invalid.push((r#"{"V":2,"L":[]}"#, libtmux::ErrorKind::UnsupportedVersion)); + } + for (value, expected) in invalid { + let mut plan = Plan::new(); + let created = plan.add(NewSession::new("layout-effect")); + plan.add(SelectLayout::new(created.window(), value)); + + let result = plan.run_over_control_mode(&sender).await; + assert!(server.is_alive().await, "{value:?}: the server survives"); + assert_eq!( + result.expect_err("invalid layout is refused").kind(), + expected, + "{value:?}", + ); + assert_eq!( + server.sessions().await.expect("sessions").len(), + 1, + "{value:?}: validation happens before creating a session", + ); + } + + let window = session.windows().await.expect("windows").remove(0); + for value in ["tiled", "tile"] { + let mut plan = Plan::new(); + plan.add(SelectLayout::new(window.id().clone(), value)); + let result = plan + .run_over_control_mode(&sender) + .await + .expect("valid layouts are accepted"); + assert!(result.is_complete(), "{value:?}: {result:?}"); + } + + events.shutdown().await.expect("control mode shuts down"); + guard.shutdown().await.expect("tmux fixture shuts down"); +} + /// Text that happens to name a tmux key is typed, not pressed. /// /// `send-keys` resolves every argument against its key table before treating From 996ff03d3d057463def50544ee672795d16b7974 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 18:18:58 -0500 Subject: [PATCH 115/117] Commands(fix): Keep leading-dash text literal why: tmux parsed caller text as its own flags in commands and plans. what: - Guard command, format, key and access operands - Exercise affected families against isolated tmux daemons --- crates/libtmux/src/pane.rs | 7 +- crates/libtmux/src/plan/ops/panes.rs | 2 +- crates/libtmux/src/plan/ops/windows.rs | 2 +- crates/libtmux/src/server.rs | 17 +- crates/libtmux/src/session.rs | 9 +- crates/libtmux/src/window.rs | 9 +- crates/libtmux/tests/positional_args.rs | 215 ++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 crates/libtmux/tests/positional_args.rs diff --git a/crates/libtmux/src/pane.rs b/crates/libtmux/src/pane.rs index 3b7504ab..fdc1b1ed 100644 --- a/crates/libtmux/src/pane.rs +++ b/crates/libtmux/src/pane.rs @@ -840,6 +840,7 @@ impl Pane { .cmd( Command::new("display-message") .arg("-p") + .arg("--") .arg(OsString::from(template)), ) .await?; @@ -872,7 +873,11 @@ impl Pane { /// Returns an error when tmux cannot be reached or refuses the message. pub async fn display(&self, message: &str) -> Result<(), Error> { let result = self - .cmd(Command::new("display-message").arg(OsString::from(message))) + .cmd( + Command::new("display-message") + .arg("--") + .arg(OsString::from(message)), + ) .await?; if result.success() { return Ok(()); diff --git a/crates/libtmux/src/plan/ops/panes.rs b/crates/libtmux/src/plan/ops/panes.rs index d97b50cd..0ede41ff 100644 --- a/crates/libtmux/src/plan/ops/panes.rs +++ b/crates/libtmux/src/plan/ops/panes.rs @@ -184,7 +184,7 @@ impl SplitWindow { command = command.arg("-e").sensitive_arg(assignment(name, value)); } if let Some(shell_command) = &self.command { - command = command.sensitive_arg(shell_command.clone()); + command = command.arg("--").sensitive_arg(shell_command.clone()); } Some(command) } diff --git a/crates/libtmux/src/plan/ops/windows.rs b/crates/libtmux/src/plan/ops/windows.rs index 2973d1f1..7d98267e 100644 --- a/crates/libtmux/src/plan/ops/windows.rs +++ b/crates/libtmux/src/plan/ops/windows.rs @@ -169,7 +169,7 @@ impl NewWindow { // The shell command is positional, so it goes last: tmux stops parsing // flags at the first one. if let Some(shell_command) = &self.command { - command = command.sensitive_arg(shell_command.clone()); + command = command.arg("--").sensitive_arg(shell_command.clone()); } Some(command) } diff --git a/crates/libtmux/src/server.rs b/crates/libtmux/src/server.rs index c2fb39d3..facbdd54 100644 --- a/crates/libtmux/src/server.rs +++ b/crates/libtmux/src/server.rs @@ -1093,6 +1093,7 @@ impl Server { Command::new("bind-key") .arg("-T") .arg(OsString::from(table)) + .arg("--") .arg(OsString::from(key)) .arg(command.into()), ) @@ -1111,6 +1112,7 @@ impl Server { Command::new("unbind-key") .arg("-T") .arg(OsString::from(table)) + .arg("--") .arg(OsString::from(key)), ) .await @@ -1192,7 +1194,9 @@ impl Server { command = command.arg("-t").arg(pane.id().to_string()); } - let result = self.cmd(command.arg(OsString::from(format))).await?; + let result = self + .cmd(command.arg("--").arg(OsString::from(format))) + .await?; if !result.success() { return Err(Error::from_refused_result("display-message", &result, None)); } @@ -1395,6 +1399,7 @@ impl Server { AccessMode::ReadOnly => "-r", AccessMode::Write => "-w", }) + .arg("--") .arg(OsString::from(user)), ) .await @@ -1415,6 +1420,7 @@ impl Server { "server-access", Command::new("server-access") .arg("-d") + .arg("--") .arg(OsString::from(user)), ) .await @@ -1501,7 +1507,11 @@ impl Server { .await?; let result = self - .cmd(Command::new("run-shell").sensitive_arg(command.into())) + .cmd( + Command::new("run-shell") + .arg("--") + .sensitive_arg(command.into()), + ) .await?; if !result.success() { return Err(Error::from_refused_result("run-shell", &result, None)); @@ -1533,6 +1543,7 @@ impl Server { "run-shell", Command::new("run-shell") .arg("-b") + .arg("--") .sensitive_arg(command.into()), ) .await @@ -2263,7 +2274,7 @@ impl NewSessionOptions { .sensitive_arg(crate::window::assignment(&name, &value)); } if let Some(shell_command) = self.command { - command = command.sensitive_arg(shell_command); + command = command.arg("--").sensitive_arg(shell_command); } command } diff --git a/crates/libtmux/src/session.rs b/crates/libtmux/src/session.rs index f71fd684..11484003 100644 --- a/crates/libtmux/src/session.rs +++ b/crates/libtmux/src/session.rs @@ -618,6 +618,7 @@ impl Session { .cmd( Command::new("display-message") .arg("-p") + .arg("--") .arg(OsString::from(template)), ) .await?; @@ -650,7 +651,11 @@ impl Session { /// Returns an error when tmux cannot be reached or refuses the message. pub async fn display(&self, message: &str) -> Result<(), Error> { let result = self - .cmd(Command::new("display-message").arg(OsString::from(message))) + .cmd( + Command::new("display-message") + .arg("--") + .arg(OsString::from(message)), + ) .await?; if result.success() { return Ok(()); @@ -1160,7 +1165,7 @@ impl NewWindowOptions { .sensitive_arg(crate::window::assignment(&name, &value)); } if let Some(shell_command) = self.command { - command = command.sensitive_arg(shell_command); + command = command.arg("--").sensitive_arg(shell_command); } command } diff --git a/crates/libtmux/src/window.rs b/crates/libtmux/src/window.rs index 16b4bd6f..d0a07db2 100644 --- a/crates/libtmux/src/window.rs +++ b/crates/libtmux/src/window.rs @@ -791,6 +791,7 @@ impl Window { .cmd( Command::new("display-message") .arg("-p") + .arg("--") .arg(OsString::from(template)), ) .await?; @@ -823,7 +824,11 @@ impl Window { /// Returns an error when tmux cannot be reached or refuses the message. pub async fn display(&self, message: &str) -> Result<(), Error> { let result = self - .cmd(Command::new("display-message").arg(OsString::from(message))) + .cmd( + Command::new("display-message") + .arg("--") + .arg(OsString::from(message)), + ) .await?; if result.success() { return Ok(()); @@ -1963,7 +1968,7 @@ impl SplitOptions { command = command.arg("-e").sensitive_arg(assignment(&name, &value)); } if let Some(shell_command) = self.command { - command = command.sensitive_arg(shell_command); + command = command.arg("--").sensitive_arg(shell_command); } command } diff --git a/crates/libtmux/tests/positional_args.rs b/crates/libtmux/tests/positional_args.rs new file mode 100644 index 00000000..196fc8a9 --- /dev/null +++ b/crates/libtmux/tests/positional_args.rs @@ -0,0 +1,215 @@ +//! Caller text remains positional when it begins with a dash. + +#![cfg(feature = "test-support")] + +use libtmux::test::TestServer; +use libtmux::{ + Command, ErrorKind, NewSessionOptions, NewWindowOptions, SplitDirection, SplitOptions, +}; + +#[tokio::test] +async fn real_tmux_compat_dash_shell_commands_are_not_flags() { + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server(); + guard.session("shell").await.expect("a session exists"); + + let foreground = server.run_shell("-b").await; + let background = server.spawn_shell("-z").await; + let error = foreground.expect_err("the shell refuses -b instead of tmux accepting a flag"); + assert!(matches!( + error.kind(), + ErrorKind::Refused | ErrorKind::UnsupportedVersion + )); + background.expect("tmux accepts -z as shell text without parsing it as a flag"); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn real_tmux_compat_dash_formats_and_messages_are_text() { + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server(); + let session = guard.session("formats").await.expect("session"); + let window = session.windows().await.expect("windows").remove(0); + let pane = window.panes().await.expect("panes").remove(0); + + let formats = [ + server.format(Some(&pane), "-a").await, + session.format("-a").await, + window.format("-a").await, + pane.format("-a").await, + ]; + let messages = [ + session.display("-dnot-a-number").await, + window.display("-dnot-a-number").await, + pane.display("-dnot-a-number").await, + ]; + assert!( + formats + .iter() + .all(|result| result.as_ref().is_ok_and(|text| text.as_bytes() == b"-a")), + "a flag-shaped format must not list tmux variables", + ); + assert!( + messages.iter().all(Result::is_ok), + "a flag-shaped message must not set a delay: {messages:?}", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn real_tmux_compat_dash_keys_do_not_change_binding_flags() { + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server(); + guard.session("keys").await.expect("session"); + // tmux 3.7 through 3.7c omit a table containing only one binding. + for key in ["X", "Y"] { + server + .bind_key("dash-table", key, "display-message retained") + .await + .expect("a binding exists"); + } + + let bind = server + .bind_key("dash-table", "-a", "display-message unwanted") + .await; + let unbind = server.unbind_key("dash-table", "-a").await; + for result in [bind, unbind] { + let error = result.expect_err("-a is an invalid key, not a binding flag"); + assert!( + error.to_string().contains("unknown key: -a"), + "tmux must receive the literal key: {error}", + ); + } + assert_eq!( + server + .key_bindings(Some("dash-table")) + .await + .expect("the original binding remains") + .len(), + 2, + "-a must not remove every binding", + ); + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn real_tmux_compat_dash_access_users_are_not_flags() { + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server(); + guard.session("access").await.expect("session"); + + for result in [ + server + .grant_access("-w", libtmux::AccessMode::ReadOnly) + .await, + server.revoke_access("-w").await, + ] { + let error = result.expect_err("-w is not a user"); + if error.kind() != ErrorKind::UnsupportedVersion { + assert!( + error.to_string().contains("unknown user: -w"), + "tmux must receive the literal username: {error}", + ); + } + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[tokio::test] +async fn real_tmux_compat_dash_creation_commands_are_not_flags() { + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server(); + guard.session("anchor").await.expect("session"); + let retained = server + .cmd( + Command::new("set-window-option") + .arg("-g") + .arg("remain-on-exit") + .arg("on"), + ) + .await + .expect("tmux answers"); + assert!(retained.success()); + + // The shell refuses -d; retaining exited panes makes its exact command + // observable without racing the process exit against the creation reply. + let session = server + .new_session(NewSessionOptions::new("created").command("-d")) + .await + .expect("the session command is positional"); + let first = session.panes().await.expect("panes").remove(0); + let window = session + .new_window(NewWindowOptions::new("created").command("-d")) + .await + .expect("the window command is positional"); + let second = window.panes().await.expect("panes").remove(0); + let third = window + .split(SplitOptions::new(SplitDirection::Below).command("-d")) + .await + .expect("the split command is positional"); + for pane in [first, second, third] { + assert_eq!( + pane.format("#{pane_start_command}") + .await + .expect("tmux reports the command") + .as_bytes(), + b"-d", + ); + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} + +#[cfg(feature = "plan")] +#[tokio::test] +async fn real_tmux_compat_dash_plan_commands_are_not_flags() { + use libtmux::plan::{NewWindow, Plan, Planner, SplitWindow}; + + let guard = TestServer::new().await.expect("tmux starts"); + let server = guard.server(); + let session = guard.session("plans").await.expect("session"); + let retained = server + .cmd( + Command::new("set-window-option") + .arg("-g") + .arg("remain-on-exit") + .arg("on"), + ) + .await + .expect("tmux answers"); + assert!(retained.success()); + + for planner in [Planner::Sequential, Planner::Folding, Planner::Marked] { + let mut plan = Plan::new(); + let window = plan.add(NewWindow::new(session.id().clone()).command("-d").focus()); + plan.add(SplitWindow::new(window).command("-d").focus()); + let result = plan.run(server, planner).await.expect("the plan runs"); + assert!(result.is_complete(), "{planner:?}: {result:?}"); + let id = result.created(0).expect("the window has an id"); + let window = session + .windows() + .await + .expect("windows") + .into_iter() + .find(|window| window.id().as_ref() == id) + .expect("the created window is listed"); + let panes = window.panes().await.expect("panes"); + assert_eq!(panes.len(), 2); + for pane in panes { + assert_eq!( + pane.format("#{pane_start_command}") + .await + .expect("tmux reports the command") + .as_bytes(), + b"-d", + "{planner:?}", + ); + } + } + + guard.shutdown().await.expect("tmux fixture shuts down"); +} From f4a648bd848200e7def8305e4a142ade7f72abb4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 19:56:18 -0500 Subject: [PATCH 116/117] Control(fix): Preserve bootstrap errors why: A closed command queue hid the actor's frame-limit failure while creating a control connection or pane stream. what: - Close and drain owned events before returning setup failures - Keep command refusals and partial-effect context during cleanup - Cover both constructors, cancellation, and real tmux output --- crates/libtmux/src/control.rs | 43 +++- crates/libtmux/src/control/lifecycle_tests.rs | 209 ++++++++++++++++-- crates/libtmux/src/control/tests.rs | 97 ++++++++ crates/libtmux/src/pane/observe.rs | 5 +- 4 files changed, 326 insertions(+), 28 deletions(-) diff --git a/crates/libtmux/src/control.rs b/crates/libtmux/src/control.rs index c8766cf7..7327c29b 100644 --- a/crates/libtmux/src/control.rs +++ b/crates/libtmux/src/control.rs @@ -530,23 +530,25 @@ impl ControlMode { pane_off_is_safe, identity: server.identity().clone(), }; + let events = ControlEvents { + events, + stop, + connection: Some(connection), + }; // Without this, tmux 3.8+ hands a control client the classic // `window_layout` string instead of JSON, disagreeing with a plain // client's snapshot; a release below 3.8 ignores the unknown flag. - sender + if let Err(error) = sender .send(Command::new("refresh-client").arg("-f").arg("new-layouts")) - .await? - .require_success("refresh-client")?; + .await + .and_then(|reply| reply.require_success("refresh-client")) + { + drop(sender); + return Err(events.shutdown_after_error(error).await); + } - Ok(Self { - sender, - events: ControlEvents { - events, - stop, - connection: Some(connection), - }, - }) + Ok(Self { sender, events }) } /// Separate the two halves so they can be used at the same time. @@ -1197,6 +1199,25 @@ impl ControlEvents { self.events.recv().await } + pub(crate) async fn shutdown_after_error(self, mut primary: Error) -> Error { + if let Err(terminal) = self.shutdown().await { + let mut cause = &mut primary; + while let Error::AfterEffect { source, .. } = cause { + cause = source.as_mut(); + } + if matches!( + cause, + Error::ControlMode { + kind: crate::ControlModeErrorKind::Closed, + .. + } + ) { + *cause = terminal; + } + } + primary + } + /// Return the next notification or terminal error, then `None`. /// /// Once `None` is returned, subsequent calls also return `None`. See diff --git a/crates/libtmux/src/control/lifecycle_tests.rs b/crates/libtmux/src/control/lifecycle_tests.rs index 8febfd12..1f692242 100644 --- a/crates/libtmux/src/control/lifecycle_tests.rs +++ b/crates/libtmux/src/control/lifecycle_tests.rs @@ -63,12 +63,16 @@ fn shell_quote(path: &Path) -> String { } fn write_script(directory: &Path, body: &str) -> PathBuf { + write_script_with_version(directory, body, "printf 'tmux 3.5a\\n'") +} + +fn write_script_with_version(directory: &Path, body: &str, version: &str) -> PathBuf { let path = directory.join("fake-tmux"); let staging = directory.join(format!(".fake-tmux.{}.tmp", process::id())); let mut file = fs::File::create(&staging).expect("staged script is creatable"); writeln!( file, - "#!/bin/sh\nif [ \"${{1-}}\" = \"-V\" ]; then\n printf 'tmux 3.5a\\n'\n exit 0\nfi\nset -eu\n{body}" + "#!/bin/sh\nif [ \"${{1-}}\" = \"-V\" ]; then\n {version}\n exit 0\nfi\nset -eu\n{body}" ) .expect("script is writable"); file.sync_all().expect("script contents are durable"); @@ -327,23 +331,40 @@ async fn a_short_reply_deadline_does_not_bound_attaching() { #[tokio::test] async fn cancelling_attach_reaps_the_process_group() { - let fixture = directory(); - let parent = fixture.path().join("parent.pid"); - let descendant = fixture.path().join("descendant.pid"); - let _guard = ProcessGuard::new([parent.clone(), descendant.clone()]); - let executable = write_script(fixture.path(), &process_script(&parent, &descendant, "")); - let server = basic_server(fixture.path(), executable, Duration::from_secs(30)); - let attached = server.clone(); - let task = tokio::spawn(async move { attach(&attached).await }); - let parent_pid = wait_for_pid(&parent).await; - let descendant_pid = wait_for_pid(&descendant).await; + for negotiate_layouts in [false, true] { + let fixture = directory(); + let parent = fixture.path().join("parent.pid"); + let descendant = fixture.path().join("descendant.pid"); + let layouts = fixture.path().join("layouts.pid"); + let _guard = ProcessGuard::new([parent.clone(), descendant.clone()]); + let prefix = if negotiate_layouts { + format!( + "printf '%%begin 0 1 0\\n%%end 0 1 0\\n'\nIFS= read -r layouts\nprintf '%s\\n' \"$$\" > {}", + shell_quote(&layouts), + ) + } else { + String::new() + }; + let executable = write_script( + fixture.path(), + &process_script(&parent, &descendant, &prefix), + ); + let server = basic_server(fixture.path(), executable, Duration::from_secs(30)); + let attached = server.clone(); + let task = tokio::spawn(async move { attach(&attached).await }); + let parent_pid = wait_for_pid(&parent).await; + let descendant_pid = wait_for_pid(&descendant).await; + if negotiate_layouts { + assert_eq!(wait_for_pid(&layouts).await, parent_pid); + } - task.abort(); - let _ = task.await; - assert_process_gone(parent_pid).await; - assert_process_gone(descendant_pid).await; + task.abort(); + assert!(task.await.expect_err("attach is cancelled").is_cancelled()); + assert_process_gone(parent_pid).await; + assert_process_gone(descendant_pid).await; - server.shutdown().await.expect("server shuts down"); + server.shutdown().await.expect("server shuts down"); + } } #[tokio::test] @@ -983,3 +1004,159 @@ async fn attach_after_server_shutdown_is_rejected() { .expect_err("shutdown closes persistent-client admission"); assert!(matches!(error, Error::ExecutorShutdown { .. })); } + +#[tokio::test] +async fn frame_error_during_bootstrap_remains_specific() { + for opening in ["", "%begin 0 1 0\n%end 0 1 0\n"] { + let fixture = directory(); + let payload = fixture.path().join("payload"); + fs::write(&payload, format!("{opening}{}\n", "x".repeat(256))) + .expect("payload is writable"); + let executable = write_script( + fixture.path(), + &format!( + "/bin/cat {}\nwhile IFS= read -r line; do :; done", + shell_quote(&payload) + ), + ); + let server = basic_server(fixture.path(), executable, TEST_TIMEOUT); + let error = ControlMode::attach_with_limits( + &server, + &session(), + crate::ControlLimits::default().max_line_bytes(64), + ) + .await + .expect_err("the oversized frame is rejected"); + server.shutdown().await.expect("server shuts down"); + assert!( + matches!( + error, + Error::ControlModeFrameTooLarge { + frame: "line", + limit: 64 + } + ), + "opening={opening:?}: got {error:?}", + ); + } +} + +#[cfg(feature = "test-support")] +#[tokio::test] +async fn real_tmux_bootstrap_cleanup_preserves_frame_failure() { + let guard = crate::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let server = guard.server(); + let session = server + .new_session("frame-closed") + .await + .expect("session starts"); + let pane = session.panes().await.expect("panes list").remove(0); + let (sender, events) = ControlMode::attach_with_limits( + server, + session.id(), + crate::ControlLimits::default().max_line_bytes(512), + ) + .await + .expect("a quiet connection attaches") + .split(); + let produced = server + .cmd( + Command::new("respawn-pane") + .arg("-k") + .arg("-t") + .arg(pane.id().as_ref()) + .arg("head -c 4096 /dev/zero | tr '\\0' A; exec cat"), + ) + .await + .expect("producer starts"); + assert!(produced.success(), "producer is accepted: {produced:?}"); + tokio::time::timeout(Duration::from_secs(1), sender.commands.closed()) + .await + .expect("oversized output closes admission"); + let send_error = sender + .watch_only(&[]) + .await + .expect_err("narrowing is refused"); + drop(sender); + assert!(matches!( + send_error, + Error::ControlMode { + kind: ControlModeErrorKind::Closed, + .. + } + )); + let error = events.shutdown_after_error(send_error).await; + guard.shutdown().await.expect("fixture shuts down"); + assert!( + matches!( + error, + Error::ControlModeFrameTooLarge { + frame: "line", + limit: 512 + } + ), + "got {error:?}" + ); +} + +#[cfg(feature = "test-support")] +#[tokio::test] +async fn initial_watch_failure_preserves_the_connection_error() { + let fixture = directory(); + let payload = fixture.path().join("payload"); + fs::write( + &payload, + format!("%begin 0 2 0\n%end 0 2 0\n{}\n", "x".repeat(256)), + ) + .expect("payload is writable"); + let guard = crate::test::TestServer::builder() + .start() + .await + .expect("tmux starts"); + let session = guard + .server() + .new_session("initial-watch") + .await + .expect("session starts"); + let pane = session.panes().await.expect("panes list").remove(0); + let tmux = shell_quote(Path::new(guard.server().tmux_executable())); + let executable = write_script_with_version( + fixture.path(), + &format!( + "for argument in \"$@\"; do\nif [ \"$argument\" = '-C' ]; then\nprintf '%%begin 0 1 0\\n%%end 0 1 0\\n'\nIFS= read -r layouts\n/bin/cat {}\nwhile IFS= read -r line; do :; done\nexit\nfi\ndone\nexec {tmux} \"$@\"", + shell_quote(&payload), + ), + &format!("exec {tmux} -V"), + ); + let wrapped = Server::builder() + .socket_path(guard.socket_path()) + .tmux_executable(executable) + .build() + .expect("wrapper server resolves"); + let observed = wrapped + .panes() + .await + .expect("pane lookup succeeds") + .into_iter() + .find(|candidate| candidate.id() == pane.id()) + .expect("pane exists"); + let error = observed + .stream_output_with_limits(crate::ControlLimits::default().max_line_bytes(64)) + .await + .expect_err("initial narrowing sees the frame violation"); + wrapped.shutdown().await.expect("wrapper shuts down"); + guard.shutdown().await.expect("fixture shuts down"); + assert!( + matches!( + error, + Error::ControlModeFrameTooLarge { + frame: "line", + limit: 64 + } + ), + "got {error:?}" + ); +} diff --git a/crates/libtmux/src/control/tests.rs b/crates/libtmux/src/control/tests.rs index 10c1241f..0d896880 100644 --- a/crates/libtmux/src/control/tests.rs +++ b/crates/libtmux/src/control/tests.rs @@ -25,6 +25,103 @@ fn reply(number: u64) -> BlockResult { } } +#[tokio::test] +async fn bootstrap_cleanup_drains_events_and_preserves_error_priority() { + let mut failed = reply(2); + failed.succeeded = false; + let refusal = || { + failed + .refusal_for("refresh-client") + .expect("command refused") + }; + for (primary, recover_terminal) in [ + (Error::control_mode_closed(), true), + ( + Error::control_mode_closed().after_effect("watch-only"), + true, + ), + (refusal(), false), + (refusal().after_effect("watch-only"), false), + ] { + let original = format!("{primary:?}"); + let after_effect = matches!(primary, Error::AfterEffect { .. }); + let (deliveries, received) = mpsc::channel(1); + deliveries + .send(Delivery::Boundary(super::Boundary(1))) + .await + .expect("queue has room"); + let (stop, _stopped) = watch::channel(()); + let connection = tokio::spawn(async move { + deliveries.closed().await; + Err(Error::control_mode_frame_too_large("line", 64)) + }); + let events = ControlEvents { + events: received, + stop, + connection: Some(connection), + }; + let error = + tokio::time::timeout(Duration::from_secs(1), events.shutdown_after_error(primary)) + .await + .expect("cleanup closes the unread event queue before joining"); + if !recover_terminal { + assert_eq!(format!("{error:?}"), original); + continue; + } + let cause = match error { + Error::AfterEffect { operation, source } => { + assert!(after_effect); + assert_eq!(operation, "watch-only"); + *source + } + error => { + assert!(!after_effect); + error + } + }; + assert!(matches!( + cause, + Error::ControlModeFrameTooLarge { + frame: "line", + limit: 64 + } + )); + } +} + +#[tokio::test] +async fn cancelling_bootstrap_cleanup_still_stops_the_connection() { + let (deliveries, received) = mpsc::channel(1); + let (stop, mut stopped) = watch::channel(()); + let (release, released) = oneshot::channel(); + let (finished, complete) = oneshot::channel(); + let connection = tokio::spawn(async move { + stopped.changed().await.expect("cleanup requested closure"); + drop(deliveries); + released.await.expect("cleanup is released"); + finished.send(()).expect("completion is observed"); + Ok(()) + }); + let events = ControlEvents { + events: received, + stop, + connection: Some(connection), + }; + { + let cleanup = events.shutdown_after_error(Error::control_mode_closed()); + tokio::select! { + biased; + _ = cleanup => panic!("the connection has not finished cleanup"), + () = std::future::ready(()) => {} + } + } + release.send(()).expect("the connection still owns cleanup"); + tokio::time::timeout(Duration::from_secs(1), complete) + .await + .expect("cancelled cleanup still stops the connection") + .expect("connection finished"); +} + #[tokio::test] async fn cancelling_a_pending_next_preserves_the_terminal_error() { let (deliveries, received) = mpsc::channel(1); diff --git a/crates/libtmux/src/pane/observe.rs b/crates/libtmux/src/pane/observe.rs index ea3c46df..6a2973e9 100644 --- a/crates/libtmux/src/pane/observe.rs +++ b/crates/libtmux/src/pane/observe.rs @@ -97,7 +97,10 @@ impl Pane { // One session can hold many panes, and the connection carries all of // them, so narrowing happens before the caller reads. - sender.watch_only(std::slice::from_ref(self.id())).await?; + if let Err(error) = sender.watch_only(std::slice::from_ref(self.id())).await { + drop(sender); + return Err(events.shutdown_after_error(error).await); + } Ok(crate::control::PaneOutput::new( self.id().clone(), From 698fa1756c2ef04319e67183408a35df175b2425 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 19 Sep 2026 17:33:43 -0500 Subject: [PATCH 117/117] Repo(docs[changelog]): Summarize API changes why: Readers need upgrade guidance for the final behavior. what: - Condense unreleased notes around observable changes - Preserve API migration guidance and released notes --- CHANGELOG.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28072c20..62a68103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,180 @@ full. ## Unreleased +### Fixed + +- Creation commands and plans, shell commands, messages, key bindings and + access rules treat caller text beginning with `-` as a value instead of a + tmux flag. (#28) + +- `PaneOutput` ends when its pane or window closes. (#28) + +- `Pane::stream_output` preserves frame-limit errors while opening its + output stream. (#28) + +- `ControlSender::resume_pane` and `unmute_pane` restore output after + muting. (#28) + +- `ControlSender::watch_only` leaves panes in other sessions unchanged. (#28) + +- `Server::lock_channel` and `wait_for_channel` preserve locks and signals + when a waiting call is cancelled or times out. (#28) + +- `Pane::capture_with` trims unwritten padding from joined lines on tmux + 3.2a, so equality checks see the text the pane printed. (#28) + +- `Server::environment_all` and `Session::environment_all` accept variable + names and values containing shell punctuation or newlines. (#28) + +- Replacing `set_hooks` no longer leaves a hook cleared but unwritten when + the call is cancelled. (#28) + +- Object listings decode the additional format escapes emitted by newer + tmux builds. (#28) + +- `tmux-mcp`'s `wait_for_text` ignores echoed input from `send_keys`, + `paste_text` and `run_shell_command` when matching command output. (#28) + +- `tmux-mcp`'s streamed captures and waits keep reading output after an + unterminated terminal escape string. (#28) + +- `tmux-mcp` reports tool refusals as tool errors, with structured reasons + for clients deciding whether to retry. (#28) + +- `tmux-mcp` accepts session IDs returned by its own listings. (#28) + +- `tmux-mcp` treats empty `TMUX` and `TMUX_PANE` as detached operation. (#28) + +- `tmux-mcp` accepts interrupt keys while `run_shell_command` owns a pane, + so callers can stop a running command. (#28) + +- `tmux-mcp` keeps its shared daemon alive while another instance uses it. + (#28) + +- `tmux-workspace` executes tmuxp's command shorthand and creates empty + panes from its blank-pane forms. (#28) + +- `tmux-workspace` resolves inherited and relative start directories and + expands environment variables as tmuxp does. (#28) + +- `tmux_workspace::freeze` records an idle shell as an empty pane, so + rebuilding it does not start a nested shell. (#28) + +### Added + +- `Server::over_control_mode` routes typed operations through an existing + control connection; blocking operations require the original server. (#28) + +- `Server::owns_control_client` and `Client::is_own` identify this process's + control clients. MCP attachment reports exclude those clients. (#28) + +- `plan::Pause` delays later operations in a plan. Control-mode plans + refuse pauses before running any operations. (#28) + +- `Server::typed_key_bindings` reads key bindings as structured values on + tmux 3.7 and newer. (#28) + +- `Pane::get`, `Window::get`, `Session::get` and `Client::get` read fields + already fetched by a listing, including fields without named getters. (#28) + +- `set_typed_option` validates option values before writing them. (#28) + +- `Server::load_buffer` and `save_buffer` transfer buffer contents through + files, including buffers too large for command arguments. (#28) + +- `Server::with_channel_lock` releases its lock when the operation ends, + including failure or cancellation. (#28) + +- `NewSessionOptions::environment` sets environment variables for a + session's first process. (#28) + +- `Pane::wait_until` waits for a predicate over captured lines and reports + arrival, pane death or timeout. (#28) + +- `TmuxVersion::has_behavior` checks capabilities on development builds + using the same rules as `Server::require`. (#28) + +- `Pane::stream_output_with_limits` lets callers bound streamed output. + (#28) + +### Changed + +- **Breaking.** `Session`, `Window`, `Client` and `ServerGeneration` + timestamp accessors return `SystemTime`; convert through `UNIX_EPOCH` + when Unix seconds are needed. (#28) + +- **Breaking.** Buffer names use `TmuxText` to preserve arbitrary bytes; + pass listed names directly to buffer reads and deletion. (#28) + +- **Breaking.** Pane and window respawning use `Respawn::Replacing` or + `Respawn::OnlyIfDead` in place of a boolean. (#28) + +- **Breaking.** `Server::display_menu` accepts `MenuItem` values in place + of tuples. (#28) + +- **Breaking.** ID field handles carry their ID type; update explicit + `TextField` annotations as shown in the migration guide. (#28) + +- **Breaking.** `ControlEvents` reports stream failures during iteration. + Handle each event's error before reading the event. (#28) + +- **Breaking.** `with_session`, `with_window` and `with_pane` report + `ScopeError`, preserving both operation and cleanup failures. (#28) + +- **Breaking.** Query iterators support owned values through + `matching_owned`; update explicit trait bounds using the migration guide. + (#28) + +- **Breaking.** `AccessRule::name` replaces `user`, and `principal` + distinguishes users from groups. (#28) + +- **Breaking.** `Pane::pid` can be absent for a dead pane; use `is_dead` + to check liveness. (#28) + +- `Window::select_layout` and layout operations in plans, workspaces and + MCP refuse invalid layouts before dispatch. Unique preset prefixes remain + accepted. (#28) + +- **Breaking.** `tmux-mcp` tool methods return `ToolError` for refusals. + (#28) + +- **Breaking.** `tmux-mcp`'s `wait_for_text` distinguishes output present + at entry, pending input and output received during the wait. (#28) + +- **Breaking.** MCP tool metadata omits repeated descriptions and schemas; + read them from the tool or `tmux://capabilities`. (#28) + +- `tmux-mcp` requires the `teardown` toolset for `set_history_limit`. (#28) + +- **Breaking.** `tmux-workspace` configuration errors include line and + column fields; update matches on `ConfigError`. (#28) + +- **Breaking.** `tmux-workspace` suppresses shell history by default; set + `suppress_history: false` to record commands. (#28) + +- **Breaking.** `PaneConfig::shell_commands` uses `ShellCommand` values to + support tmuxp's per-command Enter and delay settings. (#28) + +### Removed + +- **Breaking.** The `*_or_empty` listing helpers are removed. Handle + listing errors or apply `unwrap_or_default` at the call site. (#28) + +- **Breaking.** `get_option` and its global variants are removed; use + `typed_option` and the corresponding typed global readers. (#28) + +### Security + +- Names, titles and start directories are literal text by default. Drop + manual format escaping; use `TmuxArg::format` for intentional templates. + (#28) + +- `test::TestServer` limits inherited environment variables so tests do not + expose unrelated exported values through tmux. (#28) + +- **Breaking.** MCP environment tools withhold values unless their names + appear in `LIBTMUX_ENVIRONMENT_VALUES`. (#28) + ## 0.1.0-alpha.11 - 2026-09-12 `libtmux`, `libtmux-macros`, and `tmux-workspace` are 0.1.0-alpha.11;