From 3ee19ce2b65918072286f009f5a1a8ce1d13d2d3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:44:19 -0700 Subject: [PATCH 001/103] Forward mouse input to TUI applications --- cmux-tui/crates/cmux-tui/src/app.rs | 411 +++++++++++++++++++++++- cmux-tui/crates/cmux-tui/src/main.rs | 8 +- cmux-tui/crates/ghostty-vt/src/lib.rs | 2 + cmux-tui/crates/ghostty-vt/src/mouse.rs | 223 +++++++++++++ cmux-tui/docs/mouse.md | 8 +- cmux-tui/spec/plugins.md | 2 +- 6 files changed, 633 insertions(+), 21 deletions(-) create mode 100644 cmux-tui/crates/ghostty-vt/src/mouse.rs diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 1faf62f81477..3de2092664b8 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -26,7 +26,10 @@ use crossterm::event::{ use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; -use ghostty_vt::{KeyEncoder, RenderState, Screen}; +use ghostty_vt::{ + KeyEncoder, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseEncoder, MouseInput, + RenderState, Screen, +}; use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::CrosstermBackend; @@ -368,6 +371,8 @@ enum Drag { Select { content: Rect, auto_scroll: Option, col: u16 }, /// Browser mouse drag inside a pane's content rect. Browser { surface: SurfaceId, content: Rect }, + /// Mouse reporting owned by the PTY application in this pane. + PtyMouse { surface: SurfaceId, content: Rect, button: MouseButton }, /// Scrollbar thumb drag. Scrollbar { surface: SurfaceId, track: Rect, anchor_y: u16, anchor_offset: u64 }, /// Sidebar width override drag. @@ -422,6 +427,7 @@ pub struct App { browser_input: BrowserInputDispatcher, drag: Option, encoder: KeyEncoder, + mouse_encoder: MouseEncoder, encode_buf: Vec, quit: bool, } @@ -565,6 +571,7 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { }); session.ensure_initial(initial_size)?; let encoder = KeyEncoder::new()?; + let mouse_encoder = MouseEncoder::new()?; let stdout_lock = Arc::new(Mutex::new(())); let (tx, rx) = channel::(); @@ -666,6 +673,7 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { browser_input: BrowserInputDispatcher::spawn()?, drag: None, encoder, + mouse_encoder, encode_buf: Vec::with_capacity(64), quit: false, }; @@ -1997,33 +2005,312 @@ impl App { fn handle_mouse(&mut self, mouse: MouseEvent) -> anyhow::Result { match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { - self.handle_left_down(mouse.column, mouse.row) + self.handle_left_down(mouse.column, mouse.row, mouse.modifiers) } MouseEventKind::Drag(MouseButton::Left) => { - self.handle_left_drag(mouse.column, mouse.row) + if self.forward_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Left, + mouse.modifiers, + ) { + Ok(RenderAction::Draw) + } else { + self.handle_left_drag(mouse.column, mouse.row) + } + } + MouseEventKind::Up(MouseButton::Left) => { + if self.finish_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Left, + mouse.modifiers, + ) { + Ok(RenderAction::Draw) + } else { + self.handle_left_up(mouse.column, mouse.row) + } } - MouseEventKind::Up(MouseButton::Left) => self.handle_left_up(mouse.column, mouse.row), MouseEventKind::Down(MouseButton::Right) => { if self.prompt.is_some() { self.shake_frames = 6; return Ok(RenderAction::Draw); } + if self.begin_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Right, + mouse.modifiers, + ) { + return Ok(RenderAction::Draw); + } self.open_context_menu(mouse.column, mouse.row); Ok(RenderAction::Draw) } MouseEventKind::Drag(MouseButton::Right) => { - self.handle_right_drag(mouse.column, mouse.row) + if self.forward_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Right, + mouse.modifiers, + ) { + Ok(RenderAction::Draw) + } else { + self.handle_right_drag(mouse.column, mouse.row) + } + } + MouseEventKind::Up(MouseButton::Right) => { + if self.finish_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Right, + mouse.modifiers, + ) { + Ok(RenderAction::Draw) + } else { + self.handle_right_up(mouse.column, mouse.row) + } } - MouseEventKind::Up(MouseButton::Right) => self.handle_right_up(mouse.column, mouse.row), - MouseEventKind::Moved => self.handle_hover(mouse.column, mouse.row), + MouseEventKind::Down(MouseButton::Middle) => Ok( + if self.begin_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Middle, + mouse.modifiers, + ) { + RenderAction::Draw + } else { + RenderAction::None + }, + ), + MouseEventKind::Drag(MouseButton::Middle) => Ok( + if self.forward_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Middle, + mouse.modifiers, + ) { + RenderAction::Draw + } else { + RenderAction::None + }, + ), + MouseEventKind::Up(MouseButton::Middle) => Ok( + if self.finish_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Middle, + mouse.modifiers, + ) { + RenderAction::Draw + } else { + RenderAction::None + }, + ), + MouseEventKind::Moved => self.handle_hover(mouse.column, mouse.row, mouse.modifiers), MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { let down = matches!(mouse.kind, MouseEventKind::ScrollDown); - self.handle_scroll(mouse.column, mouse.row, down) + self.handle_scroll(mouse.column, mouse.row, down, mouse.modifiers) } _ => Ok(RenderAction::None), } } + fn begin_pty_mouse_drag( + &mut self, + x: u16, + y: u16, + button: MouseButton, + modifiers: KeyModifiers, + ) -> bool { + if modifiers.contains(KeyModifiers::SHIFT) || self.menu.is_some() || self.prompt.is_some() { + return false; + } + let Some(area) = self.pane_area_at(x, y).copied() else { return false }; + if !area.content.contains(x, y) + || self.surface_kind(area.surface) != SurfaceKind::Pty + || !self.pty_mouse_tracking(area.surface) + { + return false; + } + + self.session.focus_pane(area.pane); + self.selection = None; + self.forward_pty_mouse_to_surface( + area.surface, + area.content, + x, + y, + MouseAction::Press, + Some(Self::ghostty_mouse_button(button)), + modifiers, + true, + ); + self.drag = Some(Drag::PtyMouse { surface: area.surface, content: area.content, button }); + true + } + + fn forward_pty_mouse_drag( + &mut self, + x: u16, + y: u16, + button: MouseButton, + modifiers: KeyModifiers, + ) -> bool { + let Some(Drag::PtyMouse { surface, content, button: active_button }) = self.drag else { + return false; + }; + if active_button != button { + return false; + } + self.forward_pty_mouse_to_surface( + surface, + content, + x, + y, + MouseAction::Motion, + Some(Self::ghostty_mouse_button(button)), + modifiers, + true, + ); + true + } + + fn finish_pty_mouse_drag( + &mut self, + x: u16, + y: u16, + button: MouseButton, + modifiers: KeyModifiers, + ) -> bool { + let Some(Drag::PtyMouse { surface, content, button: active_button }) = self.drag else { + return false; + }; + if active_button != button { + return false; + } + self.drag = None; + self.forward_pty_mouse_to_surface( + surface, + content, + x, + y, + MouseAction::Release, + Some(Self::ghostty_mouse_button(button)), + modifiers, + false, + ); + true + } + + fn forward_pty_mouse_at( + &mut self, + x: u16, + y: u16, + action: MouseAction, + button: Option, + modifiers: KeyModifiers, + any_button_pressed: bool, + ) -> bool { + if modifiers.contains(KeyModifiers::SHIFT) { + return false; + } + let Some(area) = self.pane_area_at(x, y).copied() else { return false }; + if !area.content.contains(x, y) + || self.surface_kind(area.surface) != SurfaceKind::Pty + || !self.pty_mouse_tracking(area.surface) + { + return false; + } + self.forward_pty_mouse_to_surface( + area.surface, + area.content, + x, + y, + action, + button, + modifiers, + any_button_pressed, + ); + true + } + + #[allow(clippy::too_many_arguments)] + fn forward_pty_mouse_to_surface( + &mut self, + surface_id: SurfaceId, + content: Rect, + x: u16, + y: u16, + action: MouseAction, + button: Option, + modifiers: KeyModifiers, + any_button_pressed: bool, + ) { + let Some(surface) = self.session.surface(surface_id) else { return }; + let cell_width = u32::from(self.cell_pixels.0.max(1)); + let cell_height = u32::from(self.cell_pixels.1.max(1)); + let position = ( + (x as f32 - content.x as f32 + 0.5) * cell_width as f32, + (y as f32 - content.y as f32 + 0.5) * cell_height as f32, + ); + let input = MouseInput { + action, + button, + mods: Self::ghostty_mouse_mods(modifiers), + position, + screen_size: ( + u32::from(content.width).saturating_mul(cell_width), + u32::from(content.height).saturating_mul(cell_height), + ), + cell_size: (cell_width, cell_height), + any_button_pressed, + }; + + self.encode_buf.clear(); + let Some(encoded) = surface.with_terminal(|terminal| { + self.mouse_encoder.sync_from_terminal(terminal); + self.mouse_encoder.encode(input, &mut self.encode_buf) + }) else { + return; + }; + if encoded.is_ok() && !self.encode_buf.is_empty() { + surface.write_bytes(&self.encode_buf); + } + } + + fn pty_mouse_tracking(&self, surface_id: SurfaceId) -> bool { + self.session + .surface(surface_id) + .and_then(|surface| surface.with_terminal(|terminal| terminal.mouse_tracking())) + .unwrap_or(false) + } + + fn ghostty_mouse_button(button: MouseButton) -> GhosttyMouseButton { + match button { + MouseButton::Left => GhosttyMouseButton::Left, + MouseButton::Right => GhosttyMouseButton::Right, + MouseButton::Middle => GhosttyMouseButton::Middle, + } + } + + fn ghostty_mouse_mods(modifiers: KeyModifiers) -> Mods { + let mut mods = Mods::default(); + if modifiers.contains(KeyModifiers::SHIFT) { + mods = mods | Mods::SHIFT; + } + if modifiers.contains(KeyModifiers::CONTROL) { + mods = mods | Mods::CTRL; + } + if modifiers.contains(KeyModifiers::ALT) { + mods = mods | Mods::ALT; + } + if modifiers.contains(KeyModifiers::SUPER) { + mods = mods | Mods::SUPER; + } + mods + } + /// Whether the cell is over something clickable (any hit, a menu row, /// or a dialog button): these render the hand pointer. fn is_clickable(&self, x: u16, y: u16) -> bool { @@ -2068,7 +2355,12 @@ impl App { /// item, and track the mouse position so tab-bar controls (+, ‹, ›) /// and the scrollbar render a hover state. Only redraws when the /// hovered element actually changes. - fn handle_hover(&mut self, x: u16, y: u16) -> anyhow::Result { + fn handle_hover( + &mut self, + x: u16, + y: u16, + modifiers: KeyModifiers, + ) -> anyhow::Result { self.sync_pointer_shape(x, y); if let Some(menu) = self.menu.as_mut() && let Some(item) = menu.item_at(x, y) @@ -2080,6 +2372,7 @@ impl App { return Ok(RenderAction::None); } if self.menu.is_none() && self.prompt.is_none() && self.drag.is_none() { + let _ = self.forward_pty_mouse_at(x, y, MouseAction::Motion, None, modifiers, false); let mut over_browser = false; if let Some(area) = self .pane_areas @@ -2167,7 +2460,12 @@ impl App { Ok(RenderAction::Draw) } - fn handle_left_down(&mut self, x: u16, y: u16) -> anyhow::Result { + fn handle_left_down( + &mut self, + x: u16, + y: u16, + modifiers: KeyModifiers, + ) -> anyhow::Result { self.selection = None; self.drag = None; @@ -2291,6 +2589,8 @@ impl App { ); self.drag = Some(Drag::Browser { surface: area.surface, content: area.content }); + } else if self.begin_pty_mouse_drag(x, y, MouseButton::Left, modifiers) { + return Ok(RenderAction::Draw); } else { // Begin a text selection; it becomes visible once the // mouse moves to a second cell. @@ -2372,6 +2672,7 @@ impl App { ); Ok(RenderAction::Draw) } + Some(Drag::PtyMouse { .. }) => Ok(RenderAction::None), Some(Drag::Scrollbar { surface, track, anchor_y, anchor_offset }) => { let (surface, track, anchor_y, anchor_offset) = (*surface, *track, *anchor_y, *anchor_offset); @@ -2709,7 +3010,13 @@ impl App { } } - fn handle_scroll(&mut self, x: u16, y: u16, down: bool) -> anyhow::Result { + fn handle_scroll( + &mut self, + x: u16, + y: u16, + down: bool, + modifiers: KeyModifiers, + ) -> anyhow::Result { let Some(area) = self.pane_area_at(x, y).copied() else { return Ok(RenderAction::None) }; if self.active_pane() != Some(area.pane) { self.session.focus_pane(area.pane); @@ -2734,6 +3041,22 @@ impl App { } return Ok(RenderAction::None); } + if area.content.contains(x, y) + && self.forward_pty_mouse_at( + x, + y, + MouseAction::Press, + Some(if down { + GhosttyMouseButton::WheelDown + } else { + GhosttyMouseButton::WheelUp + }), + modifiers, + true, + ) + { + return Ok(RenderAction::Draw); + } let Some(sent_arrows) = surface.with_terminal(|term| { term.active_screen() == Screen::Alternate && !term.mouse_tracking() }) else { @@ -2868,14 +3191,15 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, PaneArea, browser_content_size_for_rect, browser_hover_forward_allowed, + App, Drag, PaneArea, browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use cmux_tui_core::{BrowserStatus, Mux, Node, Rect, SurfaceKind, SurfaceOptions}; - use ghostty_vt::{KeyEncoder, RenderState}; + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ghostty_vt::{KeyEncoder, MouseEncoder, RenderState}; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -2921,6 +3245,66 @@ mod tests { assert!(!browser_hover_forward_allowed(None, false)); } + #[test] + fn pty_mouse_tracking_forwards_click_release_and_wheel_with_shift_override() { + let mux = Mux::new( + "mouse-passthrough-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]), + ..Default::default() + }, + ); + let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1000h\x1b[?1006h")); + + let mut app = test_app(Session::Local(mux.clone())); + app.tree = app.session.tree(); + let pane = app.tree.active_screen().unwrap().active_pane; + let content = Rect { x: 2, y: 3, width: 20, height: 8 }; + app.pane_areas.push(PaneArea { + pane, + surface: surface.id, + rect: Rect { x: 1, y: 2, width: 23, height: 10 }, + bar: Some(Rect { x: 1, y: 2, width: 23, height: 1 }), + omnibar: None, + content, + track: None, + }); + + let event = |kind, modifiers| MouseEvent { + kind, + column: content.x + 4, + row: content.y + 2, + modifiers, + }; + + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<0;5;3M"); + assert!(app.selection.is_none()); + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Left, .. }))); + + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); + assert!(app.drag.is_none()); + + app.handle_mouse(event(MouseEventKind::ScrollDown, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<65;5;3M"); + + app.encode_buf.clear(); + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT)) + .unwrap(); + assert!(app.encode_buf.is_empty()); + assert!(app.selection.is_some()); + assert!(matches!(app.drag, Some(Drag::Select { .. }))); + + mux.close_surface(surface.id); + } + #[test] fn notify_unread_indicators_render_and_clear() { let mux = Mux::new( @@ -3011,6 +3395,7 @@ mod tests { browser_input: BrowserInputDispatcher::spawn().unwrap(), drag: None, encoder: KeyEncoder::new().unwrap(), + mouse_encoder: MouseEncoder::new().unwrap(), encode_buf: Vec::new(), quit: false, } diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index d05761c0b7d6..32f628daef04 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -70,10 +70,10 @@ KEYS (prefix: Ctrl-b) Ctrl-b send a literal Ctrl-b MOUSE - Right-click a pane for rename/new tab/split/close; right-click a - sidebar workspace or a status-bar screen for rename/close. Click - tab-bar entries to switch tabs (+ for a new tab), and status-bar - screen entries to switch screens (+ for a new screen). + Mouse-aware PTYs receive clicks, motion, and wheel events. Hold Shift + to select text or open the cmux pane menu. Click tab-bar entries to + switch tabs (+ for a new tab), and status-bar screen entries to switch + screens (+ for a new screen). CLI VERBS identify, ping, reload-config, set-window-title, clear-window-title, diff --git a/cmux-tui/crates/ghostty-vt/src/lib.rs b/cmux-tui/crates/ghostty-vt/src/lib.rs index f5c52c162ea9..db649d8795a7 100644 --- a/cmux-tui/crates/ghostty-vt/src/lib.rs +++ b/cmux-tui/crates/ghostty-vt/src/lib.rs @@ -5,6 +5,7 @@ //! rendered from this crate matches what a real Ghostty surface would show. mod key; +mod mouse; mod render; mod terminal; @@ -12,6 +13,7 @@ mod terminal; pub use ghostty_vt_sys as sys; pub use key::{KeyAction, KeyEncoder, KeyInput, Mods, key_input_from_chord}; +pub use mouse::{MouseAction, MouseButton, MouseEncoder, MouseInput}; pub use render::{Cell, ColorSpec, CursorInfo, CursorShape, Dirty, RenderState}; pub use terminal::{Callbacks, NotifyFn, PtyWriteFn, Rgb, Screen, Scrollbar, Terminal}; diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs new file mode 100644 index 000000000000..9d634511fd51 --- /dev/null +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -0,0 +1,223 @@ +use std::ffi::c_void; +use std::mem::size_of; +use std::ptr; + +use ghostty_vt_sys as sys; + +use crate::key::Mods; +use crate::terminal::Terminal; +use crate::{Result, check}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MouseAction { + Press, + Release, + Motion, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MouseButton { + Left, + Right, + Middle, + WheelUp, + WheelDown, +} + +#[derive(Debug, Clone, Copy)] +pub struct MouseInput { + pub action: MouseAction, + pub button: Option, + pub mods: Mods, + /// Position in surface-space pixels. Coordinates outside the screen + /// remain valid so a release can terminate a drag outside the pane. + pub position: (f32, f32), + pub screen_size: (u32, u32), + pub cell_size: (u32, u32), + pub any_button_pressed: bool, +} + +/// Encodes normalized pointer events with the mouse mode and wire format +/// requested by the application running in a terminal. +pub struct MouseEncoder { + encoder: sys::GhosttyMouseEncoder, + event: sys::GhosttyMouseEvent, +} + +impl MouseEncoder { + pub fn new() -> Result { + let mut encoder: sys::GhosttyMouseEncoder = ptr::null_mut(); + check(unsafe { sys::ghostty_mouse_encoder_new(ptr::null(), &mut encoder) })?; + let mut event: sys::GhosttyMouseEvent = ptr::null_mut(); + if let Err(error) = check(unsafe { sys::ghostty_mouse_event_new(ptr::null(), &mut event) }) + { + unsafe { sys::ghostty_mouse_encoder_free(encoder) }; + return Err(error); + } + Ok(Self { encoder, event }) + } + + pub fn sync_from_terminal(&mut self, terminal: &Terminal) { + unsafe { + sys::ghostty_mouse_encoder_setopt_from_terminal(self.encoder, terminal.raw()); + } + } + + pub fn encode(&mut self, input: MouseInput, out: &mut Vec) -> Result<()> { + let action = match input.action { + MouseAction::Press => sys::GHOSTTY_MOUSE_ACTION_PRESS, + MouseAction::Release => sys::GHOSTTY_MOUSE_ACTION_RELEASE, + MouseAction::Motion => sys::GHOSTTY_MOUSE_ACTION_MOTION, + }; + unsafe { + sys::ghostty_mouse_event_set_action(self.event, action); + if let Some(button) = input.button { + sys::ghostty_mouse_event_set_button(self.event, button.raw()); + } else { + sys::ghostty_mouse_event_clear_button(self.event); + } + sys::ghostty_mouse_event_set_mods(self.event, input.mods.0); + sys::ghostty_mouse_event_set_position( + self.event, + sys::GhosttyMousePosition { x: input.position.0, y: input.position.1 }, + ); + + let size = sys::GhosttyMouseEncoderSize { + size: size_of::(), + screen_width: input.screen_size.0, + screen_height: input.screen_size.1, + cell_width: input.cell_size.0.max(1), + cell_height: input.cell_size.1.max(1), + ..Default::default() + }; + sys::ghostty_mouse_encoder_setopt( + self.encoder, + sys::GHOSTTY_MOUSE_ENCODER_OPT_SIZE, + &size as *const _ as *const c_void, + ); + sys::ghostty_mouse_encoder_setopt( + self.encoder, + sys::GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED, + &input.any_button_pressed as *const _ as *const c_void, + ); + } + + let mut buf = [0u8; 64]; + let mut written = 0; + let result = unsafe { + sys::ghostty_mouse_encoder_encode( + self.encoder, + self.event, + buf.as_mut_ptr().cast(), + buf.len(), + &mut written, + ) + }; + if result == sys::GHOSTTY_OUT_OF_SPACE { + let mut big = vec![0u8; written.max(buf.len() * 2)]; + let mut big_written = 0; + check(unsafe { + sys::ghostty_mouse_encoder_encode( + self.encoder, + self.event, + big.as_mut_ptr().cast(), + big.len(), + &mut big_written, + ) + })?; + out.extend_from_slice(&big[..big_written]); + return Ok(()); + } + check(result)?; + out.extend_from_slice(&buf[..written]); + Ok(()) + } +} + +impl MouseButton { + fn raw(self) -> sys::GhosttyMouseButton { + match self { + MouseButton::Left => sys::GHOSTTY_MOUSE_BUTTON_LEFT, + MouseButton::Right => sys::GHOSTTY_MOUSE_BUTTON_RIGHT, + MouseButton::Middle => sys::GHOSTTY_MOUSE_BUTTON_MIDDLE, + MouseButton::WheelUp => sys::GHOSTTY_MOUSE_BUTTON_FOUR, + MouseButton::WheelDown => sys::GHOSTTY_MOUSE_BUTTON_FIVE, + } + } +} + +impl Drop for MouseEncoder { + fn drop(&mut self) { + unsafe { + sys::ghostty_mouse_event_free(self.event); + sys::ghostty_mouse_encoder_free(self.encoder); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Callbacks; + + fn input(action: MouseAction, button: Option) -> MouseInput { + MouseInput { + action, + button, + mods: Mods::default(), + position: (4.5, 2.5), + screen_size: (80, 24), + cell_size: (1, 1), + any_button_pressed: action != MouseAction::Release, + } + } + + #[test] + fn sgr_click_and_wheel_follow_terminal_modes() { + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1000h\x1b[?1006h"); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + + let mut out = Vec::new(); + encoder.encode(input(MouseAction::Press, Some(MouseButton::Left)), &mut out).unwrap(); + assert_eq!(out, b"\x1b[<0;5;3M"); + + out.clear(); + encoder.encode(input(MouseAction::Release, Some(MouseButton::Left)), &mut out).unwrap(); + assert_eq!(out, b"\x1b[<0;5;3m"); + + out.clear(); + encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelUp)), &mut out).unwrap(); + assert_eq!(out, b"\x1b[<64;5;3M"); + } + + #[test] + fn disabled_mouse_mode_suppresses_output() { + let terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + let mut out = Vec::new(); + + encoder.encode(input(MouseAction::Press, Some(MouseButton::Left)), &mut out).unwrap(); + + assert!(out.is_empty()); + } + + #[test] + fn sgr_pixels_uses_rendered_cell_geometry() { + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1000h\x1b[?1016h"); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + let mut event = input(MouseAction::Press, Some(MouseButton::Left)); + event.position = (36.0, 40.0); + event.screen_size = (640, 384); + event.cell_size = (8, 16); + let mut out = Vec::new(); + + encoder.encode(event, &mut out).unwrap(); + + assert_eq!(out, b"\x1b[<0;36;40M"); + } +} diff --git a/cmux-tui/docs/mouse.md b/cmux-tui/docs/mouse.md index 1408b044a090..6a68304af8f8 100644 --- a/cmux-tui/docs/mouse.md +++ b/cmux-tui/docs/mouse.md @@ -20,7 +20,7 @@ The scrollbar is visible only when a PTY surface can scroll. With the default `s The thumb is `▕` normally and `▐` while hovered or dragged. Clicking the thumb starts a drag without moving the viewport. Clicking the track outside the thumb jumps to that relative position, then starts a drag from the clicked anchor. -Wheel over a PTY pane focuses that pane first. On the normal screen it scrolls by three rows. On the alternate screen, if the inner app is not tracking mouse input, the TUI sends three up or down arrow keys. +Wheel over a PTY pane focuses that pane first. When the inner app enables terminal mouse tracking, wheel events are forwarded at the pointer position using the app's requested mouse protocol. Otherwise, the normal screen scrolls by three rows and the alternate screen receives three up or down arrow keys. ## Resize @@ -30,13 +30,15 @@ Drag the sidebar border to resize the sidebar for the current TUI session. The c ## Context Menus -Right-click a pane for rename tab, new tab, new browser tab, split right, split down, close tab, and close pane. Right-click a workspace row for rename or close. Right-click a screen in the status bar for rename or close. +Right-click a pane for rename tab, new tab, new browser tab, split right, split down, close tab, and close pane. When an inner PTY app enables mouse tracking, right-click is forwarded to the app; hold Shift while right-clicking to open the cmux menu. Right-click a workspace row for rename or close. Right-click a screen in the status bar for rename or close. Menus draw bordered overlays. Up and Down move the selected row, Enter activates it, and Esc closes the menu. A right press, drag to a row, and release activates that row. A plain right-click opens the menu and leaves it open. ## Selection and Clipboard -Drag inside a PTY pane to select text. Releasing copies non-empty selected text to the host clipboard with OSC 52. The selection stores absolute scrollback rows, so it remains stable while the viewport scrolls. +Clicks, releases, and motion inside a PTY pane are forwarded when the inner app enables terminal mouse tracking. cmux uses Ghostty's encoder so X10, UTF-8, SGR, URxvt, and SGR pixel modes follow the app's terminal state. Hold Shift to bypass mouse reporting and use cmux text selection. + +Drag inside a PTY pane to select text when mouse tracking is disabled or Shift is held. Releasing copies non-empty selected text to the host clipboard with OSC 52. The selection stores absolute scrollback rows, so it remains stable while the viewport scrolls. Holding a selection drag at the top or bottom content edge auto-scrolls and extends the selection. Typing clears the selection. If the selected surface exits, the selection is cleared. diff --git a/cmux-tui/spec/plugins.md b/cmux-tui/spec/plugins.md index 699380cd1995..66c6682a0f0d 100644 --- a/cmux-tui/spec/plugins.md +++ b/cmux-tui/spec/plugins.md @@ -54,7 +54,7 @@ While the sidebar is focused, key and paste input are forwarded as PTY bytes usi - `prefix ` leaves sidebar focus and runs the normal cmux prefixed command. - `prefix S` leaves sidebar focus when already focused. -Mouse input is not forwarded to sidebar plugins in this round because ordinary PTY pane mouse forwarding is not implemented in this TUI path. Clicking inside the plugin sidebar focuses it. +Mouse input is not forwarded to sidebar plugins in this round. PTY pane mouse forwarding applies only to pane content; clicking inside the plugin sidebar focuses it. ### Manifest From 8b3de53bc92e9055ad8fdfb09ec5f503a921f5a6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:59:08 -0700 Subject: [PATCH 002/103] Harden TUI mouse passthrough --- cmux-tui/crates/cmux-tui/src/app.rs | 211 +++++++++++++++------- cmux-tui/crates/cmux-tui/src/main.rs | 1 + cmux-tui/crates/cmux-tui/src/pty_input.rs | 99 ++++++++++ cmux-tui/crates/ghostty-vt/src/mouse.rs | 8 + 4 files changed, 249 insertions(+), 70 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui/src/pty_input.rs diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 3de2092664b8..b8d48ebf41a1 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -36,6 +36,7 @@ use ratatui::backend::CrosstermBackend; use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; use crate::config::{Action, Config, ScrollbarPosition}; use crate::keys; +use crate::pty_input::{PtyMouseInputDispatcher, PtyMouseInputEvent}; use crate::session::{Session, SurfaceHandle, TreeView}; use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::GraphicsWriter; @@ -425,6 +426,7 @@ pub struct App { /// Off-loop forwarder for browser input: CDP/socket round trips must /// never run on the event-loop thread (see `browser_input`). browser_input: BrowserInputDispatcher, + pty_mouse_input: PtyMouseInputDispatcher, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -596,6 +598,10 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { let mut stdout = std::io::stdout(); stdout.execute(EnterAlternateScreen)?; stdout.execute(EnableMouseCapture)?; + // Ask the host terminal to report Shift-modified mouse events so + // Shift remains cmux's selection/context-menu escape while the inner + // application owns ordinary mouse input. + write!(stdout, "\x1b[>1s")?; stdout.execute(EnableFocusChange)?; stdout.execute(EnableBracketedPaste)?; Ok(()) @@ -671,6 +677,7 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { pointer_shape: false, last_browser_hover: None, browser_input: BrowserInputDispatcher::spawn()?, + pty_mouse_input: PtyMouseInputDispatcher::spawn()?, drag: None, encoder, mouse_encoder, @@ -692,6 +699,8 @@ fn restore_terminal(stdout_lock: Option<&Arc>>) -> anyhow::Result<()> let mut stdout = std::io::stdout(); // Reset the mouse pointer shape in case we left it as a hand. let _ = write!(stdout, "\x1b]22;default\x07"); + // Restore the conventional host behavior where Shift bypasses capture. + let _ = write!(stdout, "\x1b[>0s"); let _ = stdout.execute(DisableBracketedPaste); let _ = stdout.execute(DisableFocusChange); let _ = stdout.execute(DisableMouseCapture); @@ -2003,29 +2012,27 @@ impl App { } fn handle_mouse(&mut self, mouse: MouseEvent) -> anyhow::Result { + // This TUI tracks one active pointer button. Ignore additional presses + // until its release so a second button cannot orphan the inner app's + // pressed state. + if matches!(mouse.kind, MouseEventKind::Down(_)) + && matches!(self.drag, Some(Drag::PtyMouse { .. })) + { + return Ok(RenderAction::None); + } match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.handle_left_down(mouse.column, mouse.row, mouse.modifiers) } MouseEventKind::Drag(MouseButton::Left) => { - if self.forward_pty_mouse_drag( - mouse.column, - mouse.row, - MouseButton::Left, - mouse.modifiers, - ) { + if self.forward_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { Ok(RenderAction::Draw) } else { self.handle_left_drag(mouse.column, mouse.row) } } MouseEventKind::Up(MouseButton::Left) => { - if self.finish_pty_mouse_drag( - mouse.column, - mouse.row, - MouseButton::Left, - mouse.modifiers, - ) { + if self.finish_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { Ok(RenderAction::Draw) } else { self.handle_left_up(mouse.column, mouse.row) @@ -2048,24 +2055,14 @@ impl App { Ok(RenderAction::Draw) } MouseEventKind::Drag(MouseButton::Right) => { - if self.forward_pty_mouse_drag( - mouse.column, - mouse.row, - MouseButton::Right, - mouse.modifiers, - ) { + if self.forward_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { Ok(RenderAction::Draw) } else { self.handle_right_drag(mouse.column, mouse.row) } } MouseEventKind::Up(MouseButton::Right) => { - if self.finish_pty_mouse_drag( - mouse.column, - mouse.row, - MouseButton::Right, - mouse.modifiers, - ) { + if self.finish_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { Ok(RenderAction::Draw) } else { self.handle_right_up(mouse.column, mouse.row) @@ -2083,36 +2080,32 @@ impl App { RenderAction::None }, ), - MouseEventKind::Drag(MouseButton::Middle) => Ok( - if self.forward_pty_mouse_drag( - mouse.column, - mouse.row, - MouseButton::Middle, - mouse.modifiers, - ) { + MouseEventKind::Drag(MouseButton::Middle) => { + Ok(if self.forward_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { RenderAction::Draw } else { RenderAction::None - }, - ), - MouseEventKind::Up(MouseButton::Middle) => Ok( - if self.finish_pty_mouse_drag( - mouse.column, - mouse.row, - MouseButton::Middle, - mouse.modifiers, - ) { + }) + } + MouseEventKind::Up(MouseButton::Middle) => { + Ok(if self.finish_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { RenderAction::Draw } else { RenderAction::None - }, - ), + }) + } MouseEventKind::Moved => self.handle_hover(mouse.column, mouse.row, mouse.modifiers), MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { let down = matches!(mouse.kind, MouseEventKind::ScrollDown); self.handle_scroll(mouse.column, mouse.row, down, mouse.modifiers) } - _ => Ok(RenderAction::None), + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => self + .handle_horizontal_scroll( + mouse.column, + mouse.row, + matches!(mouse.kind, MouseEventKind::ScrollRight), + mouse.modifiers, + ), } } @@ -2123,7 +2116,11 @@ impl App { button: MouseButton, modifiers: KeyModifiers, ) -> bool { - if modifiers.contains(KeyModifiers::SHIFT) || self.menu.is_some() || self.prompt.is_some() { + if modifiers.contains(KeyModifiers::SHIFT) + || self.menu.is_some() + || self.prompt.is_some() + || self.drag.is_some() + { return false; } let Some(area) = self.pane_area_at(x, y).copied() else { return false }; @@ -2150,45 +2147,29 @@ impl App { true } - fn forward_pty_mouse_drag( - &mut self, - x: u16, - y: u16, - button: MouseButton, - modifiers: KeyModifiers, - ) -> bool { + fn forward_pty_mouse_drag(&mut self, x: u16, y: u16, modifiers: KeyModifiers) -> bool { let Some(Drag::PtyMouse { surface, content, button: active_button }) = self.drag else { return false; }; - if active_button != button { - return false; - } + let content = self.current_pty_content(surface).unwrap_or(content); self.forward_pty_mouse_to_surface( surface, content, x, y, MouseAction::Motion, - Some(Self::ghostty_mouse_button(button)), + Some(Self::ghostty_mouse_button(active_button)), modifiers, true, ); true } - fn finish_pty_mouse_drag( - &mut self, - x: u16, - y: u16, - button: MouseButton, - modifiers: KeyModifiers, - ) -> bool { + fn finish_pty_mouse_drag(&mut self, x: u16, y: u16, modifiers: KeyModifiers) -> bool { let Some(Drag::PtyMouse { surface, content, button: active_button }) = self.drag else { return false; }; - if active_button != button { - return false; - } + let content = self.current_pty_content(surface).unwrap_or(content); self.drag = None; self.forward_pty_mouse_to_surface( surface, @@ -2196,7 +2177,7 @@ impl App { x, y, MouseAction::Release, - Some(Self::ghostty_mouse_button(button)), + Some(Self::ghostty_mouse_button(active_button)), modifiers, false, ); @@ -2212,7 +2193,7 @@ impl App { modifiers: KeyModifiers, any_button_pressed: bool, ) -> bool { - if modifiers.contains(KeyModifiers::SHIFT) { + if modifiers.contains(KeyModifiers::SHIFT) || self.menu.is_some() || self.prompt.is_some() { return false; } let Some(area) = self.pane_area_at(x, y).copied() else { return false }; @@ -2275,10 +2256,23 @@ impl App { return; }; if encoded.is_ok() && !self.encode_buf.is_empty() { - surface.write_bytes(&self.encode_buf); + if matches!(&surface, SurfaceHandle::Remote(_, _)) { + self.pty_mouse_input.enqueue(PtyMouseInputEvent { + surface_id, + surface, + bytes: self.encode_buf.clone(), + motion: action == MouseAction::Motion, + }); + } else { + surface.write_bytes(&self.encode_buf); + } } } + fn current_pty_content(&self, surface: SurfaceId) -> Option { + self.pane_areas.iter().find(|area| area.surface == surface).map(|area| area.content) + } + fn pty_mouse_tracking(&self, surface_id: SurfaceId) -> bool { self.session .surface(surface_id) @@ -3017,6 +3011,9 @@ impl App { down: bool, modifiers: KeyModifiers, ) -> anyhow::Result { + if self.menu.is_some() || self.prompt.is_some() { + return Ok(RenderAction::None); + } let Some(area) = self.pane_area_at(x, y).copied() else { return Ok(RenderAction::None) }; if self.active_pane() != Some(area.pane) { self.session.focus_pane(area.pane); @@ -3052,7 +3049,7 @@ impl App { GhosttyMouseButton::WheelUp }), modifiers, - true, + false, ) { return Ok(RenderAction::Draw); @@ -3074,6 +3071,41 @@ impl App { Ok(RenderAction::Draw) } + fn handle_horizontal_scroll( + &mut self, + x: u16, + y: u16, + right: bool, + modifiers: KeyModifiers, + ) -> anyhow::Result { + if self.menu.is_some() || self.prompt.is_some() { + return Ok(RenderAction::None); + } + let Some(area) = self.pane_area_at(x, y).copied() else { + return Ok(RenderAction::None); + }; + if self.active_pane() != Some(area.pane) { + self.session.focus_pane(area.pane); + } + if area.content.contains(x, y) + && self.forward_pty_mouse_at( + x, + y, + MouseAction::Press, + Some(if right { + GhosttyMouseButton::WheelRight + } else { + GhosttyMouseButton::WheelLeft + }), + modifiers, + false, + ) + { + return Ok(RenderAction::Draw); + } + Ok(RenderAction::None) + } + fn surface_kind(&self, surface: SurfaceId) -> SurfaceKind { self.session.surface(surface).map(|surface| surface.kind()).unwrap_or(SurfaceKind::Pty) } @@ -3205,6 +3237,7 @@ mod tests { use crate::browser_input::BrowserInputDispatcher; use crate::config::{Config, ScrollbarPosition}; + use crate::pty_input::PtyMouseInputDispatcher; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{Session, TreeView}; @@ -3259,7 +3292,7 @@ mod tests { }, ); let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); - surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1000h\x1b[?1006h")); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); let mut app = test_app(Session::Local(mux.clone())); app.tree = app.session.tree(); @@ -3295,6 +3328,43 @@ mod tests { app.handle_mouse(event(MouseEventKind::ScrollDown, KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<65;5;3M"); + app.handle_mouse(event(MouseEventKind::ScrollLeft, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<66;5;3M"); + + app.encode_buf.clear(); + app.open_context_menu(content.x + 4, content.y + 2); + app.handle_mouse(event(MouseEventKind::ScrollDown, KeyModifiers::NONE)).unwrap(); + assert!(app.encode_buf.is_empty()); + app.menu = None; + + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(); + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); + assert!(app.drag.is_none()); + + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(); + app.pane_areas[0].content.x += 3; + let moved_content = app.pane_areas[0].content; + let moved_event = MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: moved_content.x + 4, + row: moved_content.y + 2, + modifiers: KeyModifiers::NONE, + }; + app.handle_mouse(moved_event).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<32;5;3M"); + app.handle_mouse(MouseEvent { kind: MouseEventKind::Up(MouseButton::Left), ..moved_event }) + .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); + app.pane_areas[0].content = content; + app.encode_buf.clear(); app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT)) .unwrap(); @@ -3393,6 +3463,7 @@ mod tests { pointer_shape: false, last_browser_hover: None, browser_input: BrowserInputDispatcher::spawn().unwrap(), + pty_mouse_input: PtyMouseInputDispatcher::spawn().unwrap(), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 32f628daef04..8dfca305fe69 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -13,6 +13,7 @@ mod config; mod host_colors; mod keys; mod plugin_manager; +mod pty_input; mod session; mod ui; diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs new file mode 100644 index 000000000000..99cc8dbe17dd --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -0,0 +1,99 @@ +//! Off-loop PTY mouse forwarding for remote sessions. +//! +//! A remote `write_bytes` call waits for a control-socket response. Mouse +//! motion must not perform that round trip on the UI thread, so remote events +//! use this bounded worker. Consecutive motions on one surface are coalesced; +//! button and wheel events retain their order. + +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; + +use cmux_tui_core::SurfaceId; + +use crate::session::SurfaceHandle; + +const QUEUE_CAPACITY: usize = 512; + +pub struct PtyMouseInputEvent { + pub surface_id: SurfaceId, + pub surface: SurfaceHandle, + pub bytes: Vec, + pub motion: bool, +} + +pub struct PtyMouseInputDispatcher { + tx: SyncSender, +} + +impl PtyMouseInputDispatcher { + pub fn spawn() -> anyhow::Result { + let (tx, rx) = sync_channel(QUEUE_CAPACITY); + std::thread::Builder::new().name("mux-pty-mouse-input".into()).spawn(move || worker(rx))?; + Ok(Self { tx }) + } + + /// Queue an event without blocking the UI. A wedged remote endpoint may + /// fill the bounded queue, in which case new input is dropped. + pub fn enqueue(&self, event: PtyMouseInputEvent) { + let _ = self.tx.try_send(event); + } +} + +fn worker(rx: Receiver) { + while let Ok(event) = rx.recv() { + let mut batch = vec![event]; + while let Ok(next) = rx.try_recv() { + batch.push(next); + } + for event in coalesce_motion(batch) { + event.surface.write_bytes(&event.bytes); + } + } +} + +fn coalesce_motion(batch: Vec) -> Vec { + let mut coalesced: Vec = Vec::with_capacity(batch.len()); + for event in batch { + if event.motion + && coalesced + .last() + .is_some_and(|previous| previous.motion && previous.surface_id == event.surface_id) + { + *coalesced.last_mut().unwrap() = event; + } else { + coalesced.push(event); + } + } + coalesced +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(surface_id: SurfaceId, bytes: u8, motion: bool) -> PtyMouseInputEvent { + PtyMouseInputEvent { + surface_id, + surface: SurfaceHandle::RemoteBrowserUnsupported, + bytes: vec![bytes], + motion, + } + } + + #[test] + fn consecutive_motion_on_one_surface_keeps_latest() { + let events = coalesce_motion(vec![event(1, 1, true), event(1, 2, true)]); + assert_eq!(events.len(), 1); + assert_eq!(events[0].bytes, vec![2]); + } + + #[test] + fn ordered_events_and_other_surfaces_break_motion_coalescing() { + let events = coalesce_motion(vec![ + event(1, 1, true), + event(1, 2, false), + event(1, 3, true), + event(2, 4, true), + ]); + assert_eq!(events.iter().map(|event| event.bytes[0]).collect::>(), vec![1, 2, 3, 4]); + } +} diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index 9d634511fd51..f2d6d4131085 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -22,6 +22,8 @@ pub enum MouseButton { Middle, WheelUp, WheelDown, + WheelLeft, + WheelRight, } #[derive(Debug, Clone, Copy)] @@ -142,6 +144,8 @@ impl MouseButton { MouseButton::Middle => sys::GHOSTTY_MOUSE_BUTTON_MIDDLE, MouseButton::WheelUp => sys::GHOSTTY_MOUSE_BUTTON_FOUR, MouseButton::WheelDown => sys::GHOSTTY_MOUSE_BUTTON_FIVE, + MouseButton::WheelLeft => sys::GHOSTTY_MOUSE_BUTTON_SIX, + MouseButton::WheelRight => sys::GHOSTTY_MOUSE_BUTTON_SEVEN, } } } @@ -190,6 +194,10 @@ mod tests { out.clear(); encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelUp)), &mut out).unwrap(); assert_eq!(out, b"\x1b[<64;5;3M"); + + out.clear(); + encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelLeft)), &mut out).unwrap(); + assert_eq!(out, b"\x1b[<66;5;3M"); } #[test] From 71fc0ae8334a38d336907bc6c8412f3a444d31ad Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:05:49 -0700 Subject: [PATCH 003/103] Preserve PTY mouse release state --- cmux-tui/crates/cmux-tui/src/app.rs | 137 +++++++++++++++++---- cmux-tui/crates/cmux-tui/src/pty_input.rs | 138 ++++++++++++++++------ 2 files changed, 215 insertions(+), 60 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index b8d48ebf41a1..8bc62ef59a61 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -373,7 +373,13 @@ enum Drag { /// Browser mouse drag inside a pane's content rect. Browser { surface: SurfaceId, content: Rect }, /// Mouse reporting owned by the PTY application in this pane. - PtyMouse { surface: SurfaceId, content: Rect, button: MouseButton }, + PtyMouse { + surface: SurfaceId, + content: Rect, + button: MouseButton, + /// Extra presses observed while `button` remains the active owner. + ignored_buttons: u8, + }, /// Scrollbar thumb drag. Scrollbar { surface: SurfaceId, track: Rect, anchor_y: u16, anchor_offset: u64 }, /// Sidebar width override drag. @@ -2015,24 +2021,36 @@ impl App { // This TUI tracks one active pointer button. Ignore additional presses // until its release so a second button cannot orphan the inner app's // pressed state. - if matches!(mouse.kind, MouseEventKind::Down(_)) - && matches!(self.drag, Some(Drag::PtyMouse { .. })) - { - return Ok(RenderAction::None); + if let MouseEventKind::Down(button) = mouse.kind { + let bit = Self::mouse_button_bit(button); + if let Some(Drag::PtyMouse { ignored_buttons, .. }) = &mut self.drag { + *ignored_buttons |= bit; + return Ok(RenderAction::None); + } } match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.handle_left_down(mouse.column, mouse.row, mouse.modifiers) } MouseEventKind::Drag(MouseButton::Left) => { - if self.forward_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { + if self.forward_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Left, + mouse.modifiers, + ) { Ok(RenderAction::Draw) } else { self.handle_left_drag(mouse.column, mouse.row) } } MouseEventKind::Up(MouseButton::Left) => { - if self.finish_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { + if self.finish_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Left, + mouse.modifiers, + ) { Ok(RenderAction::Draw) } else { self.handle_left_up(mouse.column, mouse.row) @@ -2055,14 +2073,24 @@ impl App { Ok(RenderAction::Draw) } MouseEventKind::Drag(MouseButton::Right) => { - if self.forward_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { + if self.forward_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Right, + mouse.modifiers, + ) { Ok(RenderAction::Draw) } else { self.handle_right_drag(mouse.column, mouse.row) } } MouseEventKind::Up(MouseButton::Right) => { - if self.finish_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { + if self.finish_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Right, + mouse.modifiers, + ) { Ok(RenderAction::Draw) } else { self.handle_right_up(mouse.column, mouse.row) @@ -2080,20 +2108,30 @@ impl App { RenderAction::None }, ), - MouseEventKind::Drag(MouseButton::Middle) => { - Ok(if self.forward_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { + MouseEventKind::Drag(MouseButton::Middle) => Ok( + if self.forward_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Middle, + mouse.modifiers, + ) { RenderAction::Draw } else { RenderAction::None - }) - } - MouseEventKind::Up(MouseButton::Middle) => { - Ok(if self.finish_pty_mouse_drag(mouse.column, mouse.row, mouse.modifiers) { + }, + ), + MouseEventKind::Up(MouseButton::Middle) => Ok( + if self.finish_pty_mouse_drag( + mouse.column, + mouse.row, + MouseButton::Middle, + mouse.modifiers, + ) { RenderAction::Draw } else { RenderAction::None - }) - } + }, + ), MouseEventKind::Moved => self.handle_hover(mouse.column, mouse.row, mouse.modifiers), MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { let down = matches!(mouse.kind, MouseEventKind::ScrollDown); @@ -2132,6 +2170,7 @@ impl App { } self.session.focus_pane(area.pane); + self.sidebar_focused = false; self.selection = None; self.forward_pty_mouse_to_surface( area.surface, @@ -2143,14 +2182,32 @@ impl App { modifiers, true, ); - self.drag = Some(Drag::PtyMouse { surface: area.surface, content: area.content, button }); + self.drag = Some(Drag::PtyMouse { + surface: area.surface, + content: area.content, + button, + ignored_buttons: 0, + }); true } - fn forward_pty_mouse_drag(&mut self, x: u16, y: u16, modifiers: KeyModifiers) -> bool { - let Some(Drag::PtyMouse { surface, content, button: active_button }) = self.drag else { + fn forward_pty_mouse_drag( + &mut self, + x: u16, + y: u16, + reported_button: MouseButton, + modifiers: KeyModifiers, + ) -> bool { + let Some(Drag::PtyMouse { surface, content, button: active_button, ignored_buttons }) = + self.drag + else { return false; }; + if reported_button != active_button + && ignored_buttons & Self::mouse_button_bit(reported_button) != 0 + { + return true; + } let content = self.current_pty_content(surface).unwrap_or(content); self.forward_pty_mouse_to_surface( surface, @@ -2165,10 +2222,25 @@ impl App { true } - fn finish_pty_mouse_drag(&mut self, x: u16, y: u16, modifiers: KeyModifiers) -> bool { - let Some(Drag::PtyMouse { surface, content, button: active_button }) = self.drag else { + fn finish_pty_mouse_drag( + &mut self, + x: u16, + y: u16, + reported_button: MouseButton, + modifiers: KeyModifiers, + ) -> bool { + let Some(Drag::PtyMouse { surface, content, button: active_button, ignored_buttons }) = + self.drag + else { return false; }; + let reported_bit = Self::mouse_button_bit(reported_button); + if reported_button != active_button && ignored_buttons & reported_bit != 0 { + if let Some(Drag::PtyMouse { ignored_buttons, .. }) = &mut self.drag { + *ignored_buttons &= !reported_bit; + } + return true; + } let content = self.current_pty_content(surface).unwrap_or(content); self.drag = None; self.forward_pty_mouse_to_surface( @@ -2262,6 +2334,7 @@ impl App { surface, bytes: self.encode_buf.clone(), motion: action == MouseAction::Motion, + release: action == MouseAction::Release, }); } else { surface.write_bytes(&self.encode_buf); @@ -2288,6 +2361,14 @@ impl App { } } + fn mouse_button_bit(button: MouseButton) -> u8 { + match button { + MouseButton::Left => 1, + MouseButton::Right => 2, + MouseButton::Middle => 4, + } + } + fn ghostty_mouse_mods(modifiers: KeyModifiers) -> Mods { let mut mods = Mods::default(); if modifiers.contains(KeyModifiers::SHIFT) { @@ -3337,14 +3418,26 @@ mod tests { assert!(app.encode_buf.is_empty()); app.menu = None; + app.sidebar_focused = true; app.handle_mouse(event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE)) .unwrap(); + assert!(!app.sidebar_focused); assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Right), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); + assert!(app.drag.is_none()); + + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE)) + .unwrap(); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 99cc8dbe17dd..5cdc768663a9 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -2,10 +2,12 @@ //! //! A remote `write_bytes` call waits for a control-socket response. Mouse //! motion must not perform that round trip on the UI thread, so remote events -//! use this bounded worker. Consecutive motions on one surface are coalesced; -//! button and wheel events retain their order. +//! use this bounded worker. Consecutive motions on one surface are coalesced. +//! When the queue is full, ordered events displace stale motion and releases +//! are prioritized so an inner TUI is not left with a stuck button. -use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::collections::VecDeque; +use std::sync::{Arc, Condvar, Mutex}; use cmux_tui_core::SurfaceId; @@ -18,82 +20,142 @@ pub struct PtyMouseInputEvent { pub surface: SurfaceHandle, pub bytes: Vec, pub motion: bool, + pub release: bool, +} + +#[derive(Default)] +struct QueueState { + events: VecDeque, + closed: bool, +} + +#[derive(Default)] +struct SharedQueue { + state: Mutex, + ready: Condvar, } pub struct PtyMouseInputDispatcher { - tx: SyncSender, + queue: Arc, } impl PtyMouseInputDispatcher { pub fn spawn() -> anyhow::Result { - let (tx, rx) = sync_channel(QUEUE_CAPACITY); - std::thread::Builder::new().name("mux-pty-mouse-input".into()).spawn(move || worker(rx))?; - Ok(Self { tx }) + let queue = Arc::new(SharedQueue::default()); + let worker_queue = queue.clone(); + std::thread::Builder::new() + .name("mux-pty-mouse-input".into()) + .spawn(move || worker(worker_queue))?; + Ok(Self { queue }) } - /// Queue an event without blocking the UI. A wedged remote endpoint may - /// fill the bounded queue, in which case new input is dropped. pub fn enqueue(&self, event: PtyMouseInputEvent) { - let _ = self.tx.try_send(event); + let mut state = self.queue.state.lock().unwrap(); + if state.closed || !enqueue_bounded(&mut state.events, event, QUEUE_CAPACITY) { + return; + } + self.queue.ready.notify_one(); } } -fn worker(rx: Receiver) { - while let Ok(event) = rx.recv() { - let mut batch = vec![event]; - while let Ok(next) = rx.try_recv() { - batch.push(next); - } - for event in coalesce_motion(batch) { - event.surface.write_bytes(&event.bytes); - } +impl Drop for PtyMouseInputDispatcher { + fn drop(&mut self) { + let mut state = self.queue.state.lock().unwrap(); + state.closed = true; + self.queue.ready.notify_one(); } } -fn coalesce_motion(batch: Vec) -> Vec { - let mut coalesced: Vec = Vec::with_capacity(batch.len()); - for event in batch { - if event.motion - && coalesced - .last() - .is_some_and(|previous| previous.motion && previous.surface_id == event.surface_id) - { - *coalesced.last_mut().unwrap() = event; +fn enqueue_bounded( + events: &mut VecDeque, + event: PtyMouseInputEvent, + capacity: usize, +) -> bool { + if event.motion + && events + .back() + .is_some_and(|previous| previous.motion && previous.surface_id == event.surface_id) + { + *events.back_mut().unwrap() = event; + return true; + } + + if events.len() >= capacity { + if let Some(index) = events.iter().position(|queued| queued.motion) { + events.remove(index); + } else if event.release { + // A release is the recovery edge for the inner application's + // pressed state, so keep the newest one under extreme saturation. + events.pop_front(); } else { - coalesced.push(event); + return false; } } - coalesced + events.push_back(event); + true +} + +fn worker(queue: Arc) { + loop { + let event = { + let mut state = queue.state.lock().unwrap(); + while state.events.is_empty() && !state.closed { + state = queue.ready.wait(state).unwrap(); + } + if state.events.is_empty() && state.closed { + return; + } + state.events.pop_front().unwrap() + }; + event.surface.write_bytes(&event.bytes); + } } #[cfg(test)] mod tests { use super::*; - fn event(surface_id: SurfaceId, bytes: u8, motion: bool) -> PtyMouseInputEvent { + fn event(surface_id: SurfaceId, bytes: u8, motion: bool, release: bool) -> PtyMouseInputEvent { PtyMouseInputEvent { surface_id, surface: SurfaceHandle::RemoteBrowserUnsupported, bytes: vec![bytes], motion, + release, } } #[test] fn consecutive_motion_on_one_surface_keeps_latest() { - let events = coalesce_motion(vec![event(1, 1, true), event(1, 2, true)]); + let mut events = VecDeque::new(); + assert!(enqueue_bounded(&mut events, event(1, 1, true, false), 4)); + assert!(enqueue_bounded(&mut events, event(1, 2, true, false), 4)); assert_eq!(events.len(), 1); assert_eq!(events[0].bytes, vec![2]); } #[test] fn ordered_events_and_other_surfaces_break_motion_coalescing() { - let events = coalesce_motion(vec![ - event(1, 1, true), - event(1, 2, false), - event(1, 3, true), - event(2, 4, true), - ]); + let mut events = VecDeque::new(); + for item in [ + event(1, 1, true, false), + event(1, 2, false, false), + event(1, 3, true, false), + event(2, 4, true, false), + ] { + assert!(enqueue_bounded(&mut events, item, 8)); + } assert_eq!(events.iter().map(|event| event.bytes[0]).collect::>(), vec![1, 2, 3, 4]); } + + #[test] + fn release_displaces_motion_when_queue_is_full() { + let mut events = VecDeque::from([ + event(1, 1, false, false), + event(1, 2, true, false), + event(1, 3, false, false), + ]); + assert!(enqueue_bounded(&mut events, event(1, 4, false, true), 3)); + assert_eq!(events.iter().map(|event| event.bytes[0]).collect::>(), vec![1, 3, 4]); + } } From 982b45ad80ffc7ce30b42c0a470cb038fe982f76 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:14:45 -0700 Subject: [PATCH 004/103] Order remote PTY input delivery --- cmux-tui/crates/cmux-tui/src/app.rs | 199 ++++++++++++++-------- cmux-tui/crates/cmux-tui/src/pty_input.rs | 104 ++++++----- 2 files changed, 182 insertions(+), 121 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 8bc62ef59a61..6b3435fdd941 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -36,7 +36,7 @@ use ratatui::backend::CrosstermBackend; use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; use crate::config::{Action, Config, ScrollbarPosition}; use crate::keys; -use crate::pty_input::{PtyMouseInputDispatcher, PtyMouseInputEvent}; +use crate::pty_input::{PtyInputDispatcher, PtyInputEvent, PtyInputKind}; use crate::session::{Session, SurfaceHandle, TreeView}; use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::GraphicsWriter; @@ -377,8 +377,8 @@ enum Drag { surface: SurfaceId, content: Rect, button: MouseButton, - /// Extra presses observed while `button` remains the active owner. - ignored_buttons: u8, + position: (u16, u16), + modifiers: KeyModifiers, }, /// Scrollbar thumb drag. Scrollbar { surface: SurfaceId, track: Rect, anchor_y: u16, anchor_offset: u64 }, @@ -432,7 +432,7 @@ pub struct App { /// Off-loop forwarder for browser input: CDP/socket round trips must /// never run on the event-loop thread (see `browser_input`). browser_input: BrowserInputDispatcher, - pty_mouse_input: PtyMouseInputDispatcher, + pty_input: PtyInputDispatcher, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -683,7 +683,7 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { pointer_shape: false, last_browser_hover: None, browser_input: BrowserInputDispatcher::spawn()?, - pty_mouse_input: PtyMouseInputDispatcher::spawn()?, + pty_input: PtyInputDispatcher::spawn()?, drag: None, encoder, mouse_encoder, @@ -1516,20 +1516,26 @@ impl App { return; }; let buffer = tab.name.clone().unwrap_or_default(); - self.prompt = Some(Prompt::new("Rename tab", buffer, PromptTarget::Surface(tab.surface))); + let prompt = Prompt::new("Rename tab", buffer, PromptTarget::Surface(tab.surface)); + self.cancel_pty_mouse_drag(); + self.prompt = Some(prompt); } fn open_rename_workspace_prompt(&mut self) { let Some(ws) = self.tree.active_workspace() else { return }; - self.prompt = - Some(Prompt::new("Rename workspace", ws.name.clone(), PromptTarget::Workspace(ws.id))); + let prompt = + Prompt::new("Rename workspace", ws.name.clone(), PromptTarget::Workspace(ws.id)); + self.cancel_pty_mouse_drag(); + self.prompt = Some(prompt); } fn open_rename_screen_prompt(&mut self) { let Some(ws) = self.tree.active_workspace() else { return }; let Some(screen) = ws.active_screen_ref() else { return }; let buffer = screen.name.clone().unwrap_or_default(); - self.prompt = Some(Prompt::new("Rename screen", buffer, PromptTarget::Screen(screen.id))); + let prompt = Prompt::new("Rename screen", buffer, PromptTarget::Screen(screen.id)); + self.cancel_pty_mouse_drag(); + self.prompt = Some(prompt); } fn browser_tab_size_hint(&self, pane: Option) -> Option<(u16, u16)> { @@ -1813,7 +1819,7 @@ impl App { return; } let Some(input) = keys::key_input_from(key) else { return }; - let Some(surface) = self.active_surface_handle() else { return }; + let Some((surface_id, surface)) = self.active_surface_with_handle() else { return }; self.encode_buf.clear(); let _ = surface.scroll_to_bottom(); let Some(encoded) = surface.with_terminal(|term| { @@ -1823,12 +1829,18 @@ impl App { return; }; if encoded.is_ok() && !self.encode_buf.is_empty() { - surface.write_bytes(&self.encode_buf); + let _ = self.write_pty_bytes( + surface_id, + surface, + self.encode_buf.clone(), + PtyInputKind::Ordered, + ); } } fn forward_sidebar_key(&mut self, key: &KeyEvent) { let Some(input) = keys::key_input_from(key) else { return }; + let Some(surface_id) = self.sidebar_plugin_surface else { return }; let Some(surface) = self.sidebar_surface_handle() else { return }; self.encode_buf.clear(); let _ = surface.scroll_to_bottom(); @@ -1839,7 +1851,12 @@ impl App { return; }; if encoded.is_ok() && !self.encode_buf.is_empty() { - surface.write_bytes(&self.encode_buf); + let _ = self.write_pty_bytes( + surface_id, + surface, + self.encode_buf.clone(), + PtyInputKind::Ordered, + ); } } @@ -1908,13 +1925,19 @@ impl App { bytes.extend_from_slice(b"\x1b[200~"); bytes.extend_from_slice(text.as_bytes()); bytes.extend_from_slice(b"\x1b[201~"); - surface.write_bytes(&bytes); + let _ = self.write_pty_bytes(surface_id, surface, bytes, PtyInputKind::Ordered); } else { - surface.write_bytes(text.as_bytes()); + let _ = self.write_pty_bytes( + surface_id, + surface, + text.as_bytes().to_vec(), + PtyInputKind::Ordered, + ); } } fn paste_sidebar(&mut self, text: &str) { + let Some(surface_id) = self.sidebar_plugin_surface else { return }; let Some(surface) = self.sidebar_surface_handle() else { return }; let Some(bracketed) = surface.with_terminal(|t| t.mode(2004, false)) else { return; @@ -1924,9 +1947,14 @@ impl App { bytes.extend_from_slice(b"\x1b[200~"); bytes.extend_from_slice(text.as_bytes()); bytes.extend_from_slice(b"\x1b[201~"); - surface.write_bytes(&bytes); + let _ = self.write_pty_bytes(surface_id, surface, bytes, PtyInputKind::Ordered); } else { - surface.write_bytes(text.as_bytes()); + let _ = self.write_pty_bytes( + surface_id, + surface, + text.as_bytes().to_vec(), + PtyInputKind::Ordered, + ); } } @@ -2021,12 +2049,10 @@ impl App { // This TUI tracks one active pointer button. Ignore additional presses // until its release so a second button cannot orphan the inner app's // pressed state. - if let MouseEventKind::Down(button) = mouse.kind { - let bit = Self::mouse_button_bit(button); - if let Some(Drag::PtyMouse { ignored_buttons, .. }) = &mut self.drag { - *ignored_buttons |= bit; - return Ok(RenderAction::None); - } + if matches!(mouse.kind, MouseEventKind::Down(_)) + && matches!(self.drag, Some(Drag::PtyMouse { .. })) + { + return Ok(RenderAction::None); } match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { @@ -2172,7 +2198,7 @@ impl App { self.session.focus_pane(area.pane); self.sidebar_focused = false; self.selection = None; - self.forward_pty_mouse_to_surface( + if !self.forward_pty_mouse_to_surface( area.surface, area.content, x, @@ -2181,12 +2207,15 @@ impl App { Some(Self::ghostty_mouse_button(button)), modifiers, true, - ); + ) { + return false; + } self.drag = Some(Drag::PtyMouse { surface: area.surface, content: area.content, button, - ignored_buttons: 0, + position: (x, y), + modifiers, }); true } @@ -2198,18 +2227,22 @@ impl App { reported_button: MouseButton, modifiers: KeyModifiers, ) -> bool { - let Some(Drag::PtyMouse { surface, content, button: active_button, ignored_buttons }) = - self.drag - else { + let Some(Drag::PtyMouse { surface, content, button: active_button, .. }) = self.drag else { return false; }; - if reported_button != active_button - && ignored_buttons & Self::mouse_button_bit(reported_button) != 0 - { + if reported_button != active_button { + return true; + } + if self.menu.is_some() || self.prompt.is_some() { + self.cancel_pty_mouse_drag(); return true; } let content = self.current_pty_content(surface).unwrap_or(content); - self.forward_pty_mouse_to_surface( + if let Some(Drag::PtyMouse { position, modifiers: stored_modifiers, .. }) = &mut self.drag { + *position = (x, y); + *stored_modifiers = modifiers; + } + let _ = self.forward_pty_mouse_to_surface( surface, content, x, @@ -2229,21 +2262,15 @@ impl App { reported_button: MouseButton, modifiers: KeyModifiers, ) -> bool { - let Some(Drag::PtyMouse { surface, content, button: active_button, ignored_buttons }) = - self.drag - else { + let Some(Drag::PtyMouse { surface, content, button: active_button, .. }) = self.drag else { return false; }; - let reported_bit = Self::mouse_button_bit(reported_button); - if reported_button != active_button && ignored_buttons & reported_bit != 0 { - if let Some(Drag::PtyMouse { ignored_buttons, .. }) = &mut self.drag { - *ignored_buttons &= !reported_bit; - } + if reported_button != active_button { return true; } let content = self.current_pty_content(surface).unwrap_or(content); self.drag = None; - self.forward_pty_mouse_to_surface( + let _ = self.forward_pty_mouse_to_surface( surface, content, x, @@ -2256,6 +2283,25 @@ impl App { true } + fn cancel_pty_mouse_drag(&mut self) { + let Some(Drag::PtyMouse { surface, content, button, position, modifiers }) = self.drag + else { + return; + }; + let content = self.current_pty_content(surface).unwrap_or(content); + self.drag = None; + let _ = self.forward_pty_mouse_to_surface( + surface, + content, + position.0, + position.1, + MouseAction::Release, + Some(Self::ghostty_mouse_button(button)), + modifiers, + false, + ); + } + fn forward_pty_mouse_at( &mut self, x: u16, @@ -2284,8 +2330,7 @@ impl App { button, modifiers, any_button_pressed, - ); - true + ) } #[allow(clippy::too_many_arguments)] @@ -2299,8 +2344,8 @@ impl App { button: Option, modifiers: KeyModifiers, any_button_pressed: bool, - ) { - let Some(surface) = self.session.surface(surface_id) else { return }; + ) -> bool { + let Some(surface) = self.session.surface(surface_id) else { return false }; let cell_width = u32::from(self.cell_pixels.0.max(1)); let cell_height = u32::from(self.cell_pixels.1.max(1)); let position = ( @@ -2325,20 +2370,31 @@ impl App { self.mouse_encoder.sync_from_terminal(terminal); self.mouse_encoder.encode(input, &mut self.encode_buf) }) else { - return; + return false; }; if encoded.is_ok() && !self.encode_buf.is_empty() { - if matches!(&surface, SurfaceHandle::Remote(_, _)) { - self.pty_mouse_input.enqueue(PtyMouseInputEvent { - surface_id, - surface, - bytes: self.encode_buf.clone(), - motion: action == MouseAction::Motion, - release: action == MouseAction::Release, - }); - } else { - surface.write_bytes(&self.encode_buf); - } + let kind = match action { + MouseAction::Press => PtyInputKind::Ordered, + MouseAction::Release => PtyInputKind::Release, + MouseAction::Motion => PtyInputKind::Motion, + }; + return self.write_pty_bytes(surface_id, surface, self.encode_buf.clone(), kind); + } + false + } + + fn write_pty_bytes( + &self, + surface_id: SurfaceId, + surface: SurfaceHandle, + bytes: Vec, + kind: PtyInputKind, + ) -> bool { + if matches!(&surface, SurfaceHandle::Remote(_, _)) { + self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }) + } else { + surface.write_bytes(&bytes); + true } } @@ -2361,14 +2417,6 @@ impl App { } } - fn mouse_button_bit(button: MouseButton) -> u8 { - match button { - MouseButton::Left => 1, - MouseButton::Right => 2, - MouseButton::Middle => 4, - } - } - fn ghostty_mouse_mods(modifiers: KeyModifiers) -> Mods { let mut mods = Mods::default(); if modifiers.contains(KeyModifiers::SHIFT) { @@ -3031,6 +3079,7 @@ impl App { } fn open_context_menu(&mut self, x: u16, y: u16) { + self.cancel_pty_mouse_drag(); self.menu = None; self.omnibar = None; match self.hit_at(x, y) { @@ -3145,7 +3194,7 @@ impl App { // Alt-screen apps without mouse support get arrow keys // (the usual alternate-scroll behavior). let seq: &[u8] = if down { b"\x1b[B\x1b[B\x1b[B" } else { b"\x1b[A\x1b[A\x1b[A" }; - surface.write_bytes(seq); + let _ = self.write_pty_bytes(surface_id, surface, seq.to_vec(), PtyInputKind::Ordered); } else { let _ = surface.scroll_delta(if down { 3 } else { -3 }); } @@ -3318,7 +3367,7 @@ mod tests { use crate::browser_input::BrowserInputDispatcher; use crate::config::{Config, ScrollbarPosition}; - use crate::pty_input::PtyMouseInputDispatcher; + use crate::pty_input::PtyInputDispatcher; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{Session, TreeView}; @@ -3435,12 +3484,6 @@ mod tests { assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); - app.handle_mouse(event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE)) - .unwrap(); - app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); - assert!(app.drag.is_none()); - app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) .unwrap(); app.pane_areas[0].content.x += 3; @@ -3458,6 +3501,14 @@ mod tests { assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); app.pane_areas[0].content = content; + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(); + app.open_rename_tab_prompt(Some(pane)); + assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); + assert!(app.drag.is_none()); + assert!(app.prompt.is_some()); + app.prompt = None; + app.encode_buf.clear(); app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT)) .unwrap(); @@ -3556,7 +3607,7 @@ mod tests { pointer_shape: false, last_browser_hover: None, browser_input: BrowserInputDispatcher::spawn().unwrap(), - pty_mouse_input: PtyMouseInputDispatcher::spawn().unwrap(), + pty_input: PtyInputDispatcher::spawn().unwrap(), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 5cdc768663a9..34533e31f05a 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -1,10 +1,9 @@ -//! Off-loop PTY mouse forwarding for remote sessions. +//! Ordered, off-loop PTY input forwarding for remote sessions. //! -//! A remote `write_bytes` call waits for a control-socket response. Mouse -//! motion must not perform that round trip on the UI thread, so remote events -//! use this bounded worker. Consecutive motions on one surface are coalesced. -//! When the queue is full, ordered events displace stale motion and releases -//! are prioritized so an inner TUI is not left with a stuck button. +//! Remote `write_bytes` waits for a control-socket response. All remote PTY +//! bytes use this single lane so mouse and keyboard input stay ordered without +//! blocking the UI. Motion is coalesced, and part of the bounded queue is +//! reserved for releases so stale motion can never strand a pressed button. use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; @@ -14,18 +13,25 @@ use cmux_tui_core::SurfaceId; use crate::session::SurfaceHandle; const QUEUE_CAPACITY: usize = 512; +const RELEASE_RESERVE: usize = 16; -pub struct PtyMouseInputEvent { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PtyInputKind { + Ordered, + Motion, + Release, +} + +pub struct PtyInputEvent { pub surface_id: SurfaceId, pub surface: SurfaceHandle, pub bytes: Vec, - pub motion: bool, - pub release: bool, + pub kind: PtyInputKind, } #[derive(Default)] struct QueueState { - events: VecDeque, + events: VecDeque, closed: bool, } @@ -35,30 +41,31 @@ struct SharedQueue { ready: Condvar, } -pub struct PtyMouseInputDispatcher { +pub struct PtyInputDispatcher { queue: Arc, } -impl PtyMouseInputDispatcher { +impl PtyInputDispatcher { pub fn spawn() -> anyhow::Result { let queue = Arc::new(SharedQueue::default()); let worker_queue = queue.clone(); std::thread::Builder::new() - .name("mux-pty-mouse-input".into()) + .name("mux-pty-input".into()) .spawn(move || worker(worker_queue))?; Ok(Self { queue }) } - pub fn enqueue(&self, event: PtyMouseInputEvent) { + pub fn enqueue(&self, event: PtyInputEvent) -> bool { let mut state = self.queue.state.lock().unwrap(); if state.closed || !enqueue_bounded(&mut state.events, event, QUEUE_CAPACITY) { - return; + return false; } self.queue.ready.notify_one(); + true } } -impl Drop for PtyMouseInputDispatcher { +impl Drop for PtyInputDispatcher { fn drop(&mut self) { let mut state = self.queue.state.lock().unwrap(); state.closed = true; @@ -67,26 +74,28 @@ impl Drop for PtyMouseInputDispatcher { } fn enqueue_bounded( - events: &mut VecDeque, - event: PtyMouseInputEvent, + events: &mut VecDeque, + event: PtyInputEvent, capacity: usize, ) -> bool { - if event.motion - && events - .back() - .is_some_and(|previous| previous.motion && previous.surface_id == event.surface_id) + if event.kind == PtyInputKind::Motion + && events.back().is_some_and(|previous| { + previous.kind == PtyInputKind::Motion && previous.surface_id == event.surface_id + }) { *events.back_mut().unwrap() = event; return true; } - if events.len() >= capacity { - if let Some(index) = events.iter().position(|queued| queued.motion) { + let reserve = RELEASE_RESERVE.min(capacity); + let limit = if event.kind == PtyInputKind::Release { + capacity + } else { + capacity.saturating_sub(reserve) + }; + if events.len() >= limit { + if let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) { events.remove(index); - } else if event.release { - // A release is the recovery edge for the inner application's - // pressed state, so keep the newest one under extreme saturation. - events.pop_front(); } else { return false; } @@ -115,21 +124,20 @@ fn worker(queue: Arc) { mod tests { use super::*; - fn event(surface_id: SurfaceId, bytes: u8, motion: bool, release: bool) -> PtyMouseInputEvent { - PtyMouseInputEvent { + fn event(surface_id: SurfaceId, bytes: u8, kind: PtyInputKind) -> PtyInputEvent { + PtyInputEvent { surface_id, surface: SurfaceHandle::RemoteBrowserUnsupported, bytes: vec![bytes], - motion, - release, + kind, } } #[test] fn consecutive_motion_on_one_surface_keeps_latest() { let mut events = VecDeque::new(); - assert!(enqueue_bounded(&mut events, event(1, 1, true, false), 4)); - assert!(enqueue_bounded(&mut events, event(1, 2, true, false), 4)); + assert!(enqueue_bounded(&mut events, event(1, 1, PtyInputKind::Motion), 32)); + assert!(enqueue_bounded(&mut events, event(1, 2, PtyInputKind::Motion), 32)); assert_eq!(events.len(), 1); assert_eq!(events[0].bytes, vec![2]); } @@ -138,24 +146,26 @@ mod tests { fn ordered_events_and_other_surfaces_break_motion_coalescing() { let mut events = VecDeque::new(); for item in [ - event(1, 1, true, false), - event(1, 2, false, false), - event(1, 3, true, false), - event(2, 4, true, false), + event(1, 1, PtyInputKind::Motion), + event(1, 2, PtyInputKind::Ordered), + event(1, 3, PtyInputKind::Motion), + event(2, 4, PtyInputKind::Motion), ] { - assert!(enqueue_bounded(&mut events, item, 8)); + assert!(enqueue_bounded(&mut events, item, 32)); } assert_eq!(events.iter().map(|event| event.bytes[0]).collect::>(), vec![1, 2, 3, 4]); } #[test] - fn release_displaces_motion_when_queue_is_full() { - let mut events = VecDeque::from([ - event(1, 1, false, false), - event(1, 2, true, false), - event(1, 3, false, false), - ]); - assert!(enqueue_bounded(&mut events, event(1, 4, false, true), 3)); - assert_eq!(events.iter().map(|event| event.bytes[0]).collect::>(), vec![1, 3, 4]); + fn release_uses_reserved_capacity_without_evicting_ordered_input() { + let mut events = VecDeque::new(); + let ordered_limit = 20 - RELEASE_RESERVE; + for byte in 0..ordered_limit as u8 { + assert!(enqueue_bounded(&mut events, event(1, byte, PtyInputKind::Ordered), 20,)); + } + assert!(!enqueue_bounded(&mut events, event(1, 99, PtyInputKind::Ordered), 20,)); + assert!(enqueue_bounded(&mut events, event(1, 100, PtyInputKind::Release), 20,)); + assert_eq!(events.front().unwrap().bytes, vec![0]); + assert_eq!(events.back().unwrap().bytes, vec![100]); } } From ef5473dece70757e2282db4473a236635e891ab9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:23:06 -0700 Subject: [PATCH 005/103] Guarantee remote mouse release delivery --- cmux-tui/crates/cmux-tui/src/app.rs | 72 ++++++++-- cmux-tui/crates/cmux-tui/src/pty_input.rs | 166 ++++++++++++++++------ 2 files changed, 185 insertions(+), 53 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 6b3435fdd941..d7648611ba4f 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -692,6 +692,8 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { }; let result = app.event_loop(&mut terminal, rx); + app.cancel_pty_mouse_drag(); + let _ = app.pty_input.shutdown(Duration::from_secs(3)); if let Some(writer) = app.graphics_writer.as_mut() { writer.shutdown(Duration::from_millis(200)); } @@ -984,6 +986,9 @@ impl App { Ok(RenderAction::None) } AppEvent::Mux(MuxEvent::SurfaceExited(id)) => { + if matches!(&self.drag, Some(Drag::PtyMouse { surface, .. }) if *surface == id) { + self.drag = None; + } self.render_states.remove(&id); self.session.forget_surface(id); if self.sidebar_plugin_surface == Some(id) { @@ -1056,13 +1061,16 @@ impl App { self.reassert_visible_surface_sizes(); Ok(RenderAction::Draw) } + AppEvent::Input(Event::FocusLost) => { + self.cancel_pty_mouse_drag(); + Ok(RenderAction::None) + } AppEvent::Input(Event::Resize(_, _)) => { self.refresh_cell_pixels(false); self.render_states.clear(); self.sidebar_plugin_surface = None; Ok(RenderAction::Draw) } - AppEvent::Input(_) => Ok(RenderAction::None), } } @@ -2268,9 +2276,12 @@ impl App { if reported_button != active_button { return true; } + if !self.pty_mouse_tracking(surface) { + self.drag = None; + return true; + } let content = self.current_pty_content(surface).unwrap_or(content); - self.drag = None; - let _ = self.forward_pty_mouse_to_surface( + let delivered = self.forward_pty_mouse_to_surface( surface, content, x, @@ -2280,6 +2291,9 @@ impl App { modifiers, false, ); + if delivered { + self.drag = None; + } true } @@ -2288,9 +2302,12 @@ impl App { else { return; }; + if !self.pty_mouse_tracking(surface) { + self.drag = None; + return; + } let content = self.current_pty_content(surface).unwrap_or(content); - self.drag = None; - let _ = self.forward_pty_mouse_to_surface( + let delivered = self.forward_pty_mouse_to_surface( surface, content, position.0, @@ -2300,6 +2317,9 @@ impl App { modifiers, false, ); + if delivered { + self.drag = None; + } } fn forward_pty_mouse_at( @@ -2321,7 +2341,7 @@ impl App { { return false; } - self.forward_pty_mouse_to_surface( + let _ = self.forward_pty_mouse_to_surface( area.surface, area.content, x, @@ -2330,7 +2350,8 @@ impl App { button, modifiers, any_button_pressed, - ) + ); + true } #[allow(clippy::too_many_arguments)] @@ -2374,6 +2395,18 @@ impl App { }; if encoded.is_ok() && !self.encode_buf.is_empty() { let kind = match action { + MouseAction::Press + if matches!( + button, + Some( + GhosttyMouseButton::Left + | GhosttyMouseButton::Right + | GhosttyMouseButton::Middle + ) + ) => + { + PtyInputKind::Press + } MouseAction::Press => PtyInputKind::Ordered, MouseAction::Release => PtyInputKind::Release, MouseAction::Motion => PtyInputKind::Motion, @@ -2384,14 +2417,23 @@ impl App { } fn write_pty_bytes( - &self, + &mut self, surface_id: SurfaceId, surface: SurfaceHandle, bytes: Vec, kind: PtyInputKind, ) -> bool { if matches!(&surface, SurfaceHandle::Remote(_, _)) { - self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }) + let accepted = + self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }); + if !accepted { + // Saturation means ordered input can no longer be represented + // safely. Disconnect instead of silently dropping or replaying + // a partial stream after recovery. + self.pty_input.abort(); + self.quit = true; + } + accepted } else { surface.write_bytes(&bytes); true @@ -3353,14 +3395,14 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, Drag, PaneArea, browser_content_size_for_rect, browser_hover_forward_allowed, - pane_parts_for_rect, + App, AppEvent, Drag, PaneArea, browser_content_size_for_rect, + browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use cmux_tui_core::{BrowserStatus, Mux, Node, Rect, SurfaceKind, SurfaceOptions}; - use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use crossterm::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ghostty_vt::{KeyEncoder, MouseEncoder, RenderState}; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -3509,6 +3551,12 @@ mod tests { assert!(app.prompt.is_some()); app.prompt = None; + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(); + app.handle(AppEvent::Input(Event::FocusLost)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); + assert!(app.drag.is_none()); + app.encode_buf.clear(); app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT)) .unwrap(); diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 34533e31f05a..08d48d31c11d 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -1,23 +1,25 @@ //! Ordered, off-loop PTY input forwarding for remote sessions. //! //! Remote `write_bytes` waits for a control-socket response. All remote PTY -//! bytes use this single lane so mouse and keyboard input stay ordered without -//! blocking the UI. Motion is coalesced, and part of the bounded queue is -//! reserved for releases so stale motion can never strand a pressed button. +//! bytes use one lane so mouse and keyboard input stay ordered without +//! blocking the UI. Consecutive byte-stream writes are batched, motion is +//! coalesced, and every accepted mouse press reserves its release capacity. use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use cmux_tui_core::SurfaceId; use crate::session::SurfaceHandle; const QUEUE_CAPACITY: usize = 512; -const RELEASE_RESERVE: usize = 16; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PtyInputKind { Ordered, + Press, Motion, Release, } @@ -32,36 +34,73 @@ pub struct PtyInputEvent { #[derive(Default)] struct QueueState { events: VecDeque, + release_reservations: usize, + in_flight: bool, closed: bool, } #[derive(Default)] struct SharedQueue { state: Mutex, - ready: Condvar, + changed: Condvar, } pub struct PtyInputDispatcher { queue: Arc, + worker: Option>, } impl PtyInputDispatcher { pub fn spawn() -> anyhow::Result { let queue = Arc::new(SharedQueue::default()); let worker_queue = queue.clone(); - std::thread::Builder::new() + let worker = std::thread::Builder::new() .name("mux-pty-input".into()) .spawn(move || worker(worker_queue))?; - Ok(Self { queue }) + Ok(Self { queue, worker: Some(worker) }) } pub fn enqueue(&self, event: PtyInputEvent) -> bool { let mut state = self.queue.state.lock().unwrap(); - if state.closed || !enqueue_bounded(&mut state.events, event, QUEUE_CAPACITY) { + if state.closed { return false; } - self.queue.ready.notify_one(); - true + let QueueState { events, release_reservations, .. } = &mut *state; + let accepted = enqueue_bounded(events, release_reservations, event, QUEUE_CAPACITY); + if accepted { + self.queue.changed.notify_one(); + } + accepted + } + + /// Stop accepting work and discard queued bytes after an explicit input + /// saturation disconnect. The in-flight request is allowed to return. + pub fn abort(&self) { + let mut state = self.queue.state.lock().unwrap(); + state.closed = true; + state.events.clear(); + state.release_reservations = 0; + self.queue.changed.notify_all(); + } + + /// Drain queued writes during detach/normal shutdown, bounded so a + /// half-open remote session cannot hang terminal restoration forever. + pub fn shutdown(&mut self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let mut state = self.queue.state.lock().unwrap(); + while (!state.events.is_empty() || state.in_flight) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let (next, _) = self.queue.changed.wait_timeout(state, remaining).unwrap(); + state = next; + } + let drained = state.events.is_empty() && !state.in_flight; + state.closed = true; + self.queue.changed.notify_all(); + drop(state); + if drained && let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + drained } } @@ -69,13 +108,14 @@ impl Drop for PtyInputDispatcher { fn drop(&mut self) { let mut state = self.queue.state.lock().unwrap(); state.closed = true; - self.queue.ready.notify_one(); + self.queue.changed.notify_all(); } } fn enqueue_bounded( events: &mut VecDeque, - event: PtyInputEvent, + release_reservations: &mut usize, + mut event: PtyInputEvent, capacity: usize, ) -> bool { if event.kind == PtyInputKind::Motion @@ -87,20 +127,38 @@ fn enqueue_bounded( return true; } - let reserve = RELEASE_RESERVE.min(capacity); - let limit = if event.kind == PtyInputKind::Release { - capacity - } else { - capacity.saturating_sub(reserve) - }; - if events.len() >= limit { - if let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) { - events.remove(index); - } else { + let merge_stream = event.kind != PtyInputKind::Motion + && events.back().is_some_and(|previous| { + previous.kind != PtyInputKind::Motion && previous.surface_id == event.surface_id + }); + let consumes_reservation = event.kind == PtyInputKind::Release && *release_reservations > 0; + let mut projected = events.len() + + *release_reservations + + usize::from(!merge_stream) + + usize::from(event.kind == PtyInputKind::Press); + if consumes_reservation { + projected -= 1; + } + while projected > capacity { + let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) + else { return false; - } + }; + events.remove(index); + projected -= 1; + } + + if event.kind == PtyInputKind::Press { + *release_reservations += 1; + } else if consumes_reservation { + *release_reservations -= 1; + } + + if merge_stream { + events.back_mut().unwrap().bytes.append(&mut event.bytes); + } else { + events.push_back(event); } - events.push_back(event); true } @@ -109,14 +167,18 @@ fn worker(queue: Arc) { let event = { let mut state = queue.state.lock().unwrap(); while state.events.is_empty() && !state.closed { - state = queue.ready.wait(state).unwrap(); + state = queue.changed.wait(state).unwrap(); } if state.events.is_empty() && state.closed { return; } + state.in_flight = true; state.events.pop_front().unwrap() }; event.surface.write_bytes(&event.bytes); + let mut state = queue.state.lock().unwrap(); + state.in_flight = false; + queue.changed.notify_all(); } } @@ -136,36 +198,58 @@ mod tests { #[test] fn consecutive_motion_on_one_surface_keeps_latest() { let mut events = VecDeque::new(); - assert!(enqueue_bounded(&mut events, event(1, 1, PtyInputKind::Motion), 32)); - assert!(enqueue_bounded(&mut events, event(1, 2, PtyInputKind::Motion), 32)); + let mut releases = 0; + assert!(enqueue_bounded(&mut events, &mut releases, event(1, 1, PtyInputKind::Motion), 8,)); + assert!(enqueue_bounded(&mut events, &mut releases, event(1, 2, PtyInputKind::Motion), 8,)); assert_eq!(events.len(), 1); assert_eq!(events[0].bytes, vec![2]); } #[test] - fn ordered_events_and_other_surfaces_break_motion_coalescing() { + fn ordered_bytes_batch_without_crossing_motion_or_surfaces() { let mut events = VecDeque::new(); + let mut releases = 0; for item in [ - event(1, 1, PtyInputKind::Motion), + event(1, 1, PtyInputKind::Ordered), event(1, 2, PtyInputKind::Ordered), event(1, 3, PtyInputKind::Motion), - event(2, 4, PtyInputKind::Motion), + event(2, 4, PtyInputKind::Ordered), ] { - assert!(enqueue_bounded(&mut events, item, 32)); + assert!(enqueue_bounded(&mut events, &mut releases, item, 8)); } - assert_eq!(events.iter().map(|event| event.bytes[0]).collect::>(), vec![1, 2, 3, 4]); + assert_eq!(events.len(), 3); + assert_eq!(events[0].bytes, vec![1, 2]); + assert_eq!(events[1].bytes, vec![3]); + assert_eq!(events[2].bytes, vec![4]); } #[test] - fn release_uses_reserved_capacity_without_evicting_ordered_input() { + fn accepted_press_guarantees_its_release_slot() { let mut events = VecDeque::new(); - let ordered_limit = 20 - RELEASE_RESERVE; - for byte in 0..ordered_limit as u8 { - assert!(enqueue_bounded(&mut events, event(1, byte, PtyInputKind::Ordered), 20,)); - } - assert!(!enqueue_bounded(&mut events, event(1, 99, PtyInputKind::Ordered), 20,)); - assert!(enqueue_bounded(&mut events, event(1, 100, PtyInputKind::Release), 20,)); - assert_eq!(events.front().unwrap().bytes, vec![0]); - assert_eq!(events.back().unwrap().bytes, vec![100]); + let mut releases = 0; + assert!(enqueue_bounded(&mut events, &mut releases, event(1, 1, PtyInputKind::Press), 3,)); + assert_eq!(releases, 1); + assert!( + enqueue_bounded(&mut events, &mut releases, event(2, 2, PtyInputKind::Ordered), 3,) + ); + assert!(!enqueue_bounded( + &mut events, + &mut releases, + event(3, 3, PtyInputKind::Ordered), + 3, + )); + assert!( + enqueue_bounded(&mut events, &mut releases, event(1, 4, PtyInputKind::Release), 3,) + ); + assert_eq!(releases, 0); + assert_eq!(events.len(), 3); + assert_eq!(events.back().unwrap().bytes, vec![4]); + } + + #[test] + fn shutdown_drains_and_joins_the_worker() { + let mut dispatcher = PtyInputDispatcher::spawn().unwrap(); + assert!(dispatcher.enqueue(event(1, 1, PtyInputKind::Ordered))); + assert!(dispatcher.shutdown(Duration::from_secs(1))); } } From 5939e784f02e8545ab18b5d1d8fc584a4a73b272 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:29:46 -0700 Subject: [PATCH 006/103] Bound remote PTY input buffering --- cmux-tui/crates/cmux-tui/src/app.rs | 68 ++++++++---- cmux-tui/crates/cmux-tui/src/pty_input.rs | 120 +++++++++++++++++++--- 2 files changed, 151 insertions(+), 37 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index d7648611ba4f..a65dd86f6048 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -987,6 +987,7 @@ impl App { } AppEvent::Mux(MuxEvent::SurfaceExited(id)) => { if matches!(&self.drag, Some(Drag::PtyMouse { surface, .. }) if *surface == id) { + self.cancel_pty_release_reservation(id); self.drag = None; } self.render_states.remove(&id); @@ -2203,7 +2204,9 @@ impl App { return false; } - self.session.focus_pane(area.pane); + if self.active_pane() != Some(area.pane) { + self.session.focus_pane(area.pane); + } self.sidebar_focused = false; self.selection = None; if !self.forward_pty_mouse_to_surface( @@ -2277,6 +2280,7 @@ impl App { return true; } if !self.pty_mouse_tracking(surface) { + self.cancel_pty_release_reservation(surface); self.drag = None; return true; } @@ -2303,6 +2307,7 @@ impl App { return; }; if !self.pty_mouse_tracking(surface) { + self.cancel_pty_release_reservation(surface); self.drag = None; return; } @@ -2393,27 +2398,33 @@ impl App { }) else { return false; }; - if encoded.is_ok() && !self.encode_buf.is_empty() { - let kind = match action { - MouseAction::Press - if matches!( - button, - Some( - GhosttyMouseButton::Left - | GhosttyMouseButton::Right - | GhosttyMouseButton::Middle - ) - ) => - { - PtyInputKind::Press - } - MouseAction::Press => PtyInputKind::Ordered, - MouseAction::Release => PtyInputKind::Release, - MouseAction::Motion => PtyInputKind::Motion, - }; - return self.write_pty_bytes(surface_id, surface, self.encode_buf.clone(), kind); + if encoded.is_err() { + return false; } - false + if self.encode_buf.is_empty() { + if action == MouseAction::Release && matches!(&surface, SurfaceHandle::Remote(_, _)) { + self.pty_input.cancel_release_reservation(); + } + return true; + } + let kind = match action { + MouseAction::Press + if matches!( + button, + Some( + GhosttyMouseButton::Left + | GhosttyMouseButton::Right + | GhosttyMouseButton::Middle + ) + ) => + { + PtyInputKind::Press + } + MouseAction::Press => PtyInputKind::Ordered, + MouseAction::Release => PtyInputKind::Release, + MouseAction::Motion => PtyInputKind::Motion, + }; + self.write_pty_bytes(surface_id, surface, self.encode_buf.clone(), kind) } fn write_pty_bytes( @@ -2444,6 +2455,12 @@ impl App { self.pane_areas.iter().find(|area| area.surface == surface).map(|area| area.content) } + fn cancel_pty_release_reservation(&self, surface: SurfaceId) { + if matches!(self.session.surface(surface), Some(SurfaceHandle::Remote(_, _))) { + self.pty_input.cancel_release_reservation(); + } + } + fn pty_mouse_tracking(&self, surface_id: SurfaceId) -> bool { self.session .surface(surface_id) @@ -2742,9 +2759,11 @@ impl App { } if let Some(area) = self.pane_area_at(x, y).copied() { - self.session.focus_pane(area.pane); if area.content.contains(x, y) { if self.surface_kind(area.surface) == SurfaceKind::Browser { + if self.active_pane() != Some(area.pane) { + self.session.focus_pane(area.pane); + } self.send_browser_mouse( area.surface, area.content, @@ -2757,6 +2776,9 @@ impl App { } else if self.begin_pty_mouse_drag(x, y, MouseButton::Left, modifiers) { return Ok(RenderAction::Draw); } else { + if self.active_pane() != Some(area.pane) { + self.session.focus_pane(area.pane); + } // Begin a text selection; it becomes visible once the // mouse moves to a second cell. let offset = self.surface_scroll_offset(area.surface); @@ -2769,6 +2791,8 @@ impl App { col: x - area.content.x, }); } + } else if self.active_pane() != Some(area.pane) { + self.session.focus_pane(area.pane); } return Ok(RenderAction::Draw); } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 08d48d31c11d..603a0f2b66ac 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -15,6 +15,8 @@ use cmux_tui_core::SurfaceId; use crate::session::SurfaceHandle; const QUEUE_CAPACITY: usize = 512; +const MAX_QUEUED_BYTES: usize = 4 * 1024 * 1024; +const RESERVED_RELEASE_BYTES: usize = 64; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PtyInputKind { @@ -34,6 +36,7 @@ pub struct PtyInputEvent { #[derive(Default)] struct QueueState { events: VecDeque, + queued_bytes: usize, release_reservations: usize, in_flight: bool, closed: bool, @@ -65,20 +68,34 @@ impl PtyInputDispatcher { if state.closed { return false; } - let QueueState { events, release_reservations, .. } = &mut *state; - let accepted = enqueue_bounded(events, release_reservations, event, QUEUE_CAPACITY); + let QueueState { events, queued_bytes, release_reservations, .. } = &mut *state; + let accepted = enqueue_bounded( + events, + queued_bytes, + release_reservations, + event, + QUEUE_CAPACITY, + MAX_QUEUED_BYTES, + ); if accepted { self.queue.changed.notify_one(); } accepted } + pub fn cancel_release_reservation(&self) { + let mut state = self.queue.state.lock().unwrap(); + state.release_reservations = state.release_reservations.saturating_sub(1); + self.queue.changed.notify_all(); + } + /// Stop accepting work and discard queued bytes after an explicit input /// saturation disconnect. The in-flight request is allowed to return. pub fn abort(&self) { let mut state = self.queue.state.lock().unwrap(); state.closed = true; state.events.clear(); + state.queued_bytes = 0; state.release_reservations = 0; self.queue.changed.notify_all(); } @@ -114,15 +131,25 @@ impl Drop for PtyInputDispatcher { fn enqueue_bounded( events: &mut VecDeque, + queued_bytes: &mut usize, release_reservations: &mut usize, mut event: PtyInputEvent, capacity: usize, + max_bytes: usize, ) -> bool { if event.kind == PtyInputKind::Motion && events.back().is_some_and(|previous| { previous.kind == PtyInputKind::Motion && previous.surface_id == event.surface_id }) { + let previous_len = events.back().unwrap().bytes.len(); + let projected_bytes = queued_bytes.saturating_sub(previous_len) + + event.bytes.len() + + *release_reservations * RESERVED_RELEASE_BYTES; + if projected_bytes > max_bytes { + return false; + } + *queued_bytes = queued_bytes.saturating_sub(previous_len) + event.bytes.len(); *events.back_mut().unwrap() = event; return true; } @@ -139,13 +166,22 @@ fn enqueue_bounded( if consumes_reservation { projected -= 1; } - while projected > capacity { + let mut projected_bytes = *queued_bytes + + event.bytes.len() + + (*release_reservations + usize::from(event.kind == PtyInputKind::Press)) + * RESERVED_RELEASE_BYTES; + if consumes_reservation { + projected_bytes = projected_bytes.saturating_sub(RESERVED_RELEASE_BYTES); + } + while projected > capacity || projected_bytes > max_bytes { let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) else { return false; }; - events.remove(index); + let removed = events.remove(index).unwrap(); + *queued_bytes = queued_bytes.saturating_sub(removed.bytes.len()); projected -= 1; + projected_bytes = projected_bytes.saturating_sub(removed.bytes.len()); } if event.kind == PtyInputKind::Press { @@ -155,8 +191,10 @@ fn enqueue_bounded( } if merge_stream { + *queued_bytes += event.bytes.len(); events.back_mut().unwrap().bytes.append(&mut event.bytes); } else { + *queued_bytes += event.bytes.len(); events.push_back(event); } true @@ -173,7 +211,9 @@ fn worker(queue: Arc) { return; } state.in_flight = true; - state.events.pop_front().unwrap() + let event = state.events.pop_front().unwrap(); + state.queued_bytes = state.queued_bytes.saturating_sub(event.bytes.len()); + event }; event.surface.write_bytes(&event.bytes); let mut state = queue.state.lock().unwrap(); @@ -198,9 +238,24 @@ mod tests { #[test] fn consecutive_motion_on_one_surface_keeps_latest() { let mut events = VecDeque::new(); + let mut queued_bytes = 0; let mut releases = 0; - assert!(enqueue_bounded(&mut events, &mut releases, event(1, 1, PtyInputKind::Motion), 8,)); - assert!(enqueue_bounded(&mut events, &mut releases, event(1, 2, PtyInputKind::Motion), 8,)); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 1, PtyInputKind::Motion), + 8, + 1024, + )); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 2, PtyInputKind::Motion), + 8, + 1024, + )); assert_eq!(events.len(), 1); assert_eq!(events[0].bytes, vec![2]); } @@ -208,6 +263,7 @@ mod tests { #[test] fn ordered_bytes_batch_without_crossing_motion_or_surfaces() { let mut events = VecDeque::new(); + let mut queued_bytes = 0; let mut releases = 0; for item in [ event(1, 1, PtyInputKind::Ordered), @@ -215,7 +271,7 @@ mod tests { event(1, 3, PtyInputKind::Motion), event(2, 4, PtyInputKind::Ordered), ] { - assert!(enqueue_bounded(&mut events, &mut releases, item, 8)); + assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, item, 8, 1024,)); } assert_eq!(events.len(), 3); assert_eq!(events[0].bytes, vec![1, 2]); @@ -226,26 +282,60 @@ mod tests { #[test] fn accepted_press_guarantees_its_release_slot() { let mut events = VecDeque::new(); + let mut queued_bytes = 0; let mut releases = 0; - assert!(enqueue_bounded(&mut events, &mut releases, event(1, 1, PtyInputKind::Press), 3,)); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 1, PtyInputKind::Press), + 3, + 1024, + )); assert_eq!(releases, 1); - assert!( - enqueue_bounded(&mut events, &mut releases, event(2, 2, PtyInputKind::Ordered), 3,) - ); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(2, 2, PtyInputKind::Ordered), + 3, + 1024, + )); assert!(!enqueue_bounded( &mut events, + &mut queued_bytes, &mut releases, event(3, 3, PtyInputKind::Ordered), 3, + 1024, + )); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 4, PtyInputKind::Release), + 3, + 1024, )); - assert!( - enqueue_bounded(&mut events, &mut releases, event(1, 4, PtyInputKind::Release), 3,) - ); assert_eq!(releases, 0); assert_eq!(events.len(), 3); assert_eq!(events.back().unwrap().bytes, vec![4]); } + #[test] + fn ordered_batch_respects_byte_budget() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = 0; + let mut first = event(1, 1, PtyInputKind::Ordered); + first.bytes = vec![1; 8]; + assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, first, 8, 10,)); + let mut overflow = event(1, 2, PtyInputKind::Ordered); + overflow.bytes = vec![2; 3]; + assert!(!enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, overflow, 8, 10,)); + assert_eq!(queued_bytes, 8); + } + #[test] fn shutdown_drains_and_joins_the_worker() { let mut dispatcher = PtyInputDispatcher::spawn().unwrap(); From 6c826500522f04484045d054ad2c9e433b7a5ffa Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:45:06 -0700 Subject: [PATCH 007/103] Add regression coverage for remote input findings --- cmux-tui/crates/cmux-tui/src/app.rs | 3 +++ cmux-tui/crates/cmux-tui/src/pty_input.rs | 31 +++++++++++++++++++++++ cmux-tui/crates/ghostty-vt/src/mouse.rs | 4 +++ 3 files changed, 38 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index a65dd86f6048..19c6347310ce 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3525,6 +3525,9 @@ mod tests { assert_eq!(app.encode_buf, b"\x1b[<65;5;3M"); app.handle_mouse(event(MouseEventKind::ScrollLeft, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<67;5;3M"); + + app.handle_mouse(event(MouseEventKind::ScrollRight, KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<66;5;3M"); app.encode_buf.clear(); diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 603a0f2b66ac..84875c6cc65b 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -342,4 +342,35 @@ mod tests { assert!(dispatcher.enqueue(event(1, 1, PtyInputKind::Ordered))); assert!(dispatcher.shutdown(Duration::from_secs(1))); } + + #[test] + fn shutdown_timeout_discards_pending_input() { + let queue = Arc::new(SharedQueue::default()); + { + let mut state = queue.state.lock().unwrap(); + state.events.push_back(event(1, 1, PtyInputKind::Press)); + state.events.push_back(event(1, 2, PtyInputKind::Release)); + state.queued_bytes = 2; + state.release_reservations = 1; + state.in_flight = true; + } + let mut dispatcher = PtyInputDispatcher { queue: queue.clone(), worker: None }; + + assert!(!dispatcher.shutdown(Duration::ZERO)); + + let state = queue.state.lock().unwrap(); + assert!(state.closed); + assert!(state.events.is_empty()); + assert_eq!(state.queued_bytes, 0); + assert_eq!(state.release_reservations, 0); + } + + #[test] + fn oversized_input_is_distinguished_from_queue_saturation() { + let dispatcher = PtyInputDispatcher::spawn().unwrap(); + let mut oversized = event(1, 1, PtyInputKind::Ordered); + oversized.bytes = vec![1; MAX_QUEUED_BYTES + 1]; + + assert_eq!(dispatcher.enqueue(oversized), PtyInputEnqueueResult::Oversized); + } } diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index f2d6d4131085..cd511ef9e460 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -197,6 +197,10 @@ mod tests { out.clear(); encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelLeft)), &mut out).unwrap(); + assert_eq!(out, b"\x1b[<67;5;3M"); + + out.clear(); + encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelRight)), &mut out).unwrap(); assert_eq!(out, b"\x1b[<66;5;3M"); } From f0a377faaec1729869a20c5481673bf91ce8ee7e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:47:19 -0700 Subject: [PATCH 008/103] Fix reviewed remote PTY input edge cases --- cmux-tui/crates/cmux-tui/src/app.rs | 55 ++++++++++++++++++----- cmux-tui/crates/cmux-tui/src/pty_input.rs | 31 +++++++++++-- cmux-tui/crates/ghostty-vt/src/mouse.rs | 6 ++- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 19c6347310ce..bf2200c1f9b2 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -36,7 +36,7 @@ use ratatui::backend::CrosstermBackend; use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; use crate::config::{Action, Config, ScrollbarPosition}; use crate::keys; -use crate::pty_input::{PtyInputDispatcher, PtyInputEvent, PtyInputKind}; +use crate::pty_input::{PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind}; use crate::session::{Session, SurfaceHandle, TreeView}; use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::GraphicsWriter; @@ -1042,6 +1042,7 @@ impl App { self.handle_mouse(mouse) } AppEvent::Input(Event::Paste(text)) => { + self.status_message = None; self.reassert_visible_surface_sizes(); if let Some(prompt) = self.prompt.as_mut() { prompt.input.insert_str(&text); @@ -1052,10 +1053,18 @@ impl App { Ok(RenderAction::Draw) } else if self.sidebar_focused { self.paste_sidebar(&text); - Ok(RenderAction::None) + Ok(if self.status_message.is_some() { + RenderAction::Draw + } else { + RenderAction::None + }) } else { self.paste(&text); - Ok(RenderAction::None) + Ok(if self.status_message.is_some() { + RenderAction::Draw + } else { + RenderAction::None + }) } } AppEvent::Input(Event::FocusGained) => { @@ -2435,19 +2444,30 @@ impl App { kind: PtyInputKind, ) -> bool { if matches!(&surface, SurfaceHandle::Remote(_, _)) { - let accepted = - self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }); - if !accepted { + let result = self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }); + self.handle_pty_enqueue_result(result) + } else { + surface.write_bytes(&bytes); + true + } + } + + fn handle_pty_enqueue_result(&mut self, result: PtyInputEnqueueResult) -> bool { + match result { + PtyInputEnqueueResult::Accepted => true, + PtyInputEnqueueResult::Oversized => { + self.status_message = + Some("Input exceeds the 4 MiB remote buffer limit".to_string()); + false + } + PtyInputEnqueueResult::Saturated => { // Saturation means ordered input can no longer be represented // safely. Disconnect instead of silently dropping or replaying // a partial stream after recovery. self.pty_input.abort(); self.quit = true; + false } - accepted - } else { - surface.write_bytes(&bytes); - true } } @@ -3433,7 +3453,7 @@ mod tests { use crate::browser_input::BrowserInputDispatcher; use crate::config::{Config, ScrollbarPosition}; - use crate::pty_input::PtyInputDispatcher; + use crate::pty_input::{PtyInputDispatcher, PtyInputEnqueueResult}; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{Session, TreeView}; @@ -3594,6 +3614,19 @@ mod tests { mux.close_surface(surface.id); } + #[test] + fn oversized_remote_input_shows_an_error_without_disconnecting() { + let mux = Mux::new("oversized-input-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + + assert!(!app.handle_pty_enqueue_result(PtyInputEnqueueResult::Oversized)); + assert_eq!( + app.status_message.as_deref(), + Some("Input exceeds the 4 MiB remote buffer limit") + ); + assert!(!app.quit); + } + #[test] fn notify_unread_indicators_render_and_clear() { let mux = Mux::new( diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 84875c6cc65b..e97e9678fbab 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -26,6 +26,13 @@ pub enum PtyInputKind { Release, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PtyInputEnqueueResult { + Accepted, + Oversized, + Saturated, +} + pub struct PtyInputEvent { pub surface_id: SurfaceId, pub surface: SurfaceHandle, @@ -63,10 +70,13 @@ impl PtyInputDispatcher { Ok(Self { queue, worker: Some(worker) }) } - pub fn enqueue(&self, event: PtyInputEvent) -> bool { + pub fn enqueue(&self, event: PtyInputEvent) -> PtyInputEnqueueResult { let mut state = self.queue.state.lock().unwrap(); if state.closed { - return false; + return PtyInputEnqueueResult::Saturated; + } + if event.bytes.len() > MAX_QUEUED_BYTES { + return PtyInputEnqueueResult::Oversized; } let QueueState { events, queued_bytes, release_reservations, .. } = &mut *state; let accepted = enqueue_bounded( @@ -79,8 +89,10 @@ impl PtyInputDispatcher { ); if accepted { self.queue.changed.notify_one(); + PtyInputEnqueueResult::Accepted + } else { + PtyInputEnqueueResult::Saturated } - accepted } pub fn cancel_release_reservation(&self) { @@ -112,6 +124,11 @@ impl PtyInputDispatcher { } let drained = state.events.is_empty() && !state.in_flight; state.closed = true; + if !drained { + state.events.clear(); + state.queued_bytes = 0; + state.release_reservations = 0; + } self.queue.changed.notify_all(); drop(state); if drained && let Some(worker) = self.worker.take() { @@ -125,6 +142,9 @@ impl Drop for PtyInputDispatcher { fn drop(&mut self) { let mut state = self.queue.state.lock().unwrap(); state.closed = true; + state.events.clear(); + state.queued_bytes = 0; + state.release_reservations = 0; self.queue.changed.notify_all(); } } @@ -339,7 +359,10 @@ mod tests { #[test] fn shutdown_drains_and_joins_the_worker() { let mut dispatcher = PtyInputDispatcher::spawn().unwrap(); - assert!(dispatcher.enqueue(event(1, 1, PtyInputKind::Ordered))); + assert_eq!( + dispatcher.enqueue(event(1, 1, PtyInputKind::Ordered)), + PtyInputEnqueueResult::Accepted + ); assert!(dispatcher.shutdown(Duration::from_secs(1))); } diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index cd511ef9e460..e0a032dade6c 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -144,8 +144,10 @@ impl MouseButton { MouseButton::Middle => sys::GHOSTTY_MOUSE_BUTTON_MIDDLE, MouseButton::WheelUp => sys::GHOSTTY_MOUSE_BUTTON_FOUR, MouseButton::WheelDown => sys::GHOSTTY_MOUSE_BUTTON_FIVE, - MouseButton::WheelLeft => sys::GHOSTTY_MOUSE_BUTTON_SIX, - MouseButton::WheelRight => sys::GHOSTTY_MOUSE_BUTTON_SEVEN, + // Ghostty normalizes a positive horizontal wheel delta (right) + // as button six and a negative delta (left) as button seven. + MouseButton::WheelLeft => sys::GHOSTTY_MOUSE_BUTTON_SEVEN, + MouseButton::WheelRight => sys::GHOSTTY_MOUSE_BUTTON_SIX, } } } From 43377d9b7d9df3bb787bb8cdc3c5eb5e243aad43 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:55:43 -0700 Subject: [PATCH 009/103] Correct horizontal wheel regression expectations --- cmux-tui/crates/cmux-tui/src/app.rs | 4 ++-- cmux-tui/crates/ghostty-vt/src/mouse.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index bf2200c1f9b2..66803f4abcdb 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3545,10 +3545,10 @@ mod tests { assert_eq!(app.encode_buf, b"\x1b[<65;5;3M"); app.handle_mouse(event(MouseEventKind::ScrollLeft, KeyModifiers::NONE)).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<67;5;3M"); + assert_eq!(app.encode_buf, b"\x1b[<66;5;3M"); app.handle_mouse(event(MouseEventKind::ScrollRight, KeyModifiers::NONE)).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<66;5;3M"); + assert_eq!(app.encode_buf, b"\x1b[<67;5;3M"); app.encode_buf.clear(); app.open_context_menu(content.x + 4, content.y + 2); diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index e0a032dade6c..239770559db2 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -199,11 +199,11 @@ mod tests { out.clear(); encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelLeft)), &mut out).unwrap(); - assert_eq!(out, b"\x1b[<67;5;3M"); + assert_eq!(out, b"\x1b[<66;5;3M"); out.clear(); encoder.encode(input(MouseAction::Press, Some(MouseButton::WheelRight)), &mut out).unwrap(); - assert_eq!(out, b"\x1b[<66;5;3M"); + assert_eq!(out, b"\x1b[<67;5;3M"); } #[test] From 0aa52d95db3b2a7ecd15f98a649db5ba0f8d8c7e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:56:35 -0700 Subject: [PATCH 010/103] Align horizontal wheel input and local writes --- cmux-tui/crates/cmux-tui/src/app.rs | 32 +++++++++++++++---------- cmux-tui/crates/ghostty-vt/src/mouse.rs | 6 ++--- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 66803f4abcdb..1bc00bcca28c 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1847,12 +1847,7 @@ impl App { return; }; if encoded.is_ok() && !self.encode_buf.is_empty() { - let _ = self.write_pty_bytes( - surface_id, - surface, - self.encode_buf.clone(), - PtyInputKind::Ordered, - ); + let _ = self.write_encoded_pty_bytes(surface_id, surface, PtyInputKind::Ordered); } } @@ -1869,12 +1864,7 @@ impl App { return; }; if encoded.is_ok() && !self.encode_buf.is_empty() { - let _ = self.write_pty_bytes( - surface_id, - surface, - self.encode_buf.clone(), - PtyInputKind::Ordered, - ); + let _ = self.write_encoded_pty_bytes(surface_id, surface, PtyInputKind::Ordered); } } @@ -2433,7 +2423,23 @@ impl App { MouseAction::Release => PtyInputKind::Release, MouseAction::Motion => PtyInputKind::Motion, }; - self.write_pty_bytes(surface_id, surface, self.encode_buf.clone(), kind) + self.write_encoded_pty_bytes(surface_id, surface, kind) + } + + fn write_encoded_pty_bytes( + &mut self, + surface_id: SurfaceId, + surface: SurfaceHandle, + kind: PtyInputKind, + ) -> bool { + if matches!(&surface, SurfaceHandle::Remote(_, _)) { + // The worker outlives this event-loop turn and must own the + // encoded bytes. Local writes can borrow the reusable buffer. + self.write_pty_bytes(surface_id, surface, self.encode_buf.clone(), kind) + } else { + surface.write_bytes(&self.encode_buf); + true + } } fn write_pty_bytes( diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index 239770559db2..d7927c4847ca 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -144,10 +144,8 @@ impl MouseButton { MouseButton::Middle => sys::GHOSTTY_MOUSE_BUTTON_MIDDLE, MouseButton::WheelUp => sys::GHOSTTY_MOUSE_BUTTON_FOUR, MouseButton::WheelDown => sys::GHOSTTY_MOUSE_BUTTON_FIVE, - // Ghostty normalizes a positive horizontal wheel delta (right) - // as button six and a negative delta (left) as button seven. - MouseButton::WheelLeft => sys::GHOSTTY_MOUSE_BUTTON_SEVEN, - MouseButton::WheelRight => sys::GHOSTTY_MOUSE_BUTTON_SIX, + MouseButton::WheelLeft => sys::GHOSTTY_MOUSE_BUTTON_SIX, + MouseButton::WheelRight => sys::GHOSTTY_MOUSE_BUTTON_SEVEN, } } } From 3c76bc5d4e93cf80ba899766c5b42b7f6896d29f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:05:25 -0700 Subject: [PATCH 011/103] Cover ambiguous PTY mouse button events --- cmux-tui/crates/cmux-tui/src/app.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 1bc00bcca28c..c4d8efe8dabe 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3571,11 +3571,10 @@ mod tests { .unwrap(); assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); - app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); - assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); - assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); - app.handle_mouse(event(MouseEventKind::Up(MouseButton::Right), KeyModifiers::NONE)) + app.handle_mouse(event(MouseEventKind::Drag(MouseButton::Left), KeyModifiers::NONE)) .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<34;5;3M"); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); From 1337b586d3f5d6ee6a64b0e958f002a5283df523 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:09:09 -0700 Subject: [PATCH 012/103] Keep PTY mouse input off the event loop --- cmux-tui/Cargo.lock | 87 ++++++++++++----------- cmux-tui/Cargo.toml | 1 + cmux-tui/crates/cmux-tui/Cargo.toml | 1 + cmux-tui/crates/cmux-tui/src/app.rs | 76 +++++++++----------- cmux-tui/crates/cmux-tui/src/pty_input.rs | 38 +++++----- 5 files changed, 99 insertions(+), 104 deletions(-) diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index 653742190675..b446b79ca8ad 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -189,6 +189,50 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cmux-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "cmux-tui-core", + "crossterm", + "ghostty-vt", + "libc", + "ratatui", + "serde", + "serde_json", + "smallvec", + "toml", + "unicode-width", +] + +[[package]] +name = "cmux-tui-cdp" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde_json", + "tungstenite", +] + +[[package]] +name = "cmux-tui-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "cmux-tui-cdp", + "ghostty-vt", + "libc", + "portable-pty", + "regex", + "serde", + "serde_json", + "tungstenite", + "uds_windows", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -800,49 +844,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "cmux-tui-cdp" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde_json", - "tungstenite", -] - -[[package]] -name = "cmux-tui-core" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "ghostty-vt", - "libc", - "cmux-tui-cdp", - "portable-pty", - "regex", - "serde", - "serde_json", - "tungstenite", - "uds_windows", -] - -[[package]] -name = "cmux-tui" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "crossterm", - "ghostty-vt", - "libc", - "cmux-tui-core", - "ratatui", - "serde", - "serde_json", - "toml", - "unicode-width", -] - [[package]] name = "nix" version = "0.28.0" diff --git a/cmux-tui/Cargo.toml b/cmux-tui/Cargo.toml index 67392bad3b4b..c464aa9d6ad9 100644 --- a/cmux-tui/Cargo.toml +++ b/cmux-tui/Cargo.toml @@ -44,6 +44,7 @@ tungstenite = { version = "0.29", default-features = false, features = ["handsha uds_windows = "1.2" regex = "1" bindgen = "0.72" +smallvec = "1" [profile.release] lto = "thin" diff --git a/cmux-tui/crates/cmux-tui/Cargo.toml b/cmux-tui/crates/cmux-tui/Cargo.toml index 2b576bf0188f..b71a9e213ac5 100644 --- a/cmux-tui/crates/cmux-tui/Cargo.toml +++ b/cmux-tui/crates/cmux-tui/Cargo.toml @@ -26,3 +26,4 @@ serde_json.workspace = true toml.workspace = true base64.workspace = true libc.workspace = true +smallvec.workspace = true diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index c4d8efe8dabe..5078224461da 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -36,7 +36,9 @@ use ratatui::backend::CrosstermBackend; use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; use crate::config::{Action, Config, ScrollbarPosition}; use crate::keys; -use crate::pty_input::{PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind}; +use crate::pty_input::{ + PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind, +}; use crate::session::{Session, SurfaceHandle, TreeView}; use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::GraphicsWriter; @@ -987,7 +989,7 @@ impl App { } AppEvent::Mux(MuxEvent::SurfaceExited(id)) => { if matches!(&self.drag, Some(Drag::PtyMouse { surface, .. }) if *surface == id) { - self.cancel_pty_release_reservation(id); + self.cancel_pty_release_reservation(); self.drag = None; } self.render_states.remove(&id); @@ -1933,12 +1935,12 @@ impl App { bytes.extend_from_slice(b"\x1b[200~"); bytes.extend_from_slice(text.as_bytes()); bytes.extend_from_slice(b"\x1b[201~"); - let _ = self.write_pty_bytes(surface_id, surface, bytes, PtyInputKind::Ordered); + let _ = self.write_pty_bytes(surface_id, surface, bytes.into(), PtyInputKind::Ordered); } else { let _ = self.write_pty_bytes( surface_id, surface, - text.as_bytes().to_vec(), + PtyInputBytes::from_slice(text.as_bytes()), PtyInputKind::Ordered, ); } @@ -1955,12 +1957,12 @@ impl App { bytes.extend_from_slice(b"\x1b[200~"); bytes.extend_from_slice(text.as_bytes()); bytes.extend_from_slice(b"\x1b[201~"); - let _ = self.write_pty_bytes(surface_id, surface, bytes, PtyInputKind::Ordered); + let _ = self.write_pty_bytes(surface_id, surface, bytes.into(), PtyInputKind::Ordered); } else { let _ = self.write_pty_bytes( surface_id, surface, - text.as_bytes().to_vec(), + PtyInputBytes::from_slice(text.as_bytes()), PtyInputKind::Ordered, ); } @@ -2234,15 +2236,14 @@ impl App { &mut self, x: u16, y: u16, - reported_button: MouseButton, + _reported_button: MouseButton, modifiers: KeyModifiers, ) -> bool { let Some(Drag::PtyMouse { surface, content, button: active_button, .. }) = self.drag else { return false; }; - if reported_button != active_button { - return true; - } + // Some host protocols report a drag as left regardless of the + // pressed button. This TUI owns one active button, so it is authoritative. if self.menu.is_some() || self.prompt.is_some() { self.cancel_pty_mouse_drag(); return true; @@ -2269,17 +2270,16 @@ impl App { &mut self, x: u16, y: u16, - reported_button: MouseButton, + _reported_button: MouseButton, modifiers: KeyModifiers, ) -> bool { let Some(Drag::PtyMouse { surface, content, button: active_button, .. }) = self.drag else { return false; }; - if reported_button != active_button { - return true; - } + // Xterm SGR releases carry no button identity and crossterm exposes + // them as left. Release the one button this TUI accepted. if !self.pty_mouse_tracking(surface) { - self.cancel_pty_release_reservation(surface); + self.cancel_pty_release_reservation(); self.drag = None; return true; } @@ -2306,7 +2306,7 @@ impl App { return; }; if !self.pty_mouse_tracking(surface) { - self.cancel_pty_release_reservation(surface); + self.cancel_pty_release_reservation(); self.drag = None; return; } @@ -2401,7 +2401,7 @@ impl App { return false; } if self.encode_buf.is_empty() { - if action == MouseAction::Release && matches!(&surface, SurfaceHandle::Remote(_, _)) { + if action == MouseAction::Release { self.pty_input.cancel_release_reservation(); } return true; @@ -2432,38 +2432,26 @@ impl App { surface: SurfaceHandle, kind: PtyInputKind, ) -> bool { - if matches!(&surface, SurfaceHandle::Remote(_, _)) { - // The worker outlives this event-loop turn and must own the - // encoded bytes. Local writes can borrow the reusable buffer. - self.write_pty_bytes(surface_id, surface, self.encode_buf.clone(), kind) - } else { - surface.write_bytes(&self.encode_buf); - true - } + let bytes = PtyInputBytes::from_slice(&self.encode_buf); + self.write_pty_bytes(surface_id, surface, bytes, kind) } fn write_pty_bytes( &mut self, surface_id: SurfaceId, surface: SurfaceHandle, - bytes: Vec, + bytes: PtyInputBytes, kind: PtyInputKind, ) -> bool { - if matches!(&surface, SurfaceHandle::Remote(_, _)) { - let result = self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }); - self.handle_pty_enqueue_result(result) - } else { - surface.write_bytes(&bytes); - true - } + let result = self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }); + self.handle_pty_enqueue_result(result) } fn handle_pty_enqueue_result(&mut self, result: PtyInputEnqueueResult) -> bool { match result { PtyInputEnqueueResult::Accepted => true, PtyInputEnqueueResult::Oversized => { - self.status_message = - Some("Input exceeds the 4 MiB remote buffer limit".to_string()); + self.status_message = Some("Input exceeds the 4 MiB PTY buffer limit".to_string()); false } PtyInputEnqueueResult::Saturated => { @@ -2481,10 +2469,8 @@ impl App { self.pane_areas.iter().find(|area| area.surface == surface).map(|area| area.content) } - fn cancel_pty_release_reservation(&self, surface: SurfaceId) { - if matches!(self.session.surface(surface), Some(SurfaceHandle::Remote(_, _))) { - self.pty_input.cancel_release_reservation(); - } + fn cancel_pty_release_reservation(&self) { + self.pty_input.cancel_release_reservation(); } fn pty_mouse_tracking(&self, surface_id: SurfaceId) -> bool { @@ -3286,7 +3272,12 @@ impl App { // Alt-screen apps without mouse support get arrow keys // (the usual alternate-scroll behavior). let seq: &[u8] = if down { b"\x1b[B\x1b[B\x1b[B" } else { b"\x1b[A\x1b[A\x1b[A" }; - let _ = self.write_pty_bytes(surface_id, surface, seq.to_vec(), PtyInputKind::Ordered); + let _ = self.write_pty_bytes( + surface_id, + surface, + PtyInputBytes::from_slice(seq), + PtyInputKind::Ordered, + ); } else { let _ = surface.scroll_delta(if down { 3 } else { -3 }); } @@ -3625,10 +3616,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); assert!(!app.handle_pty_enqueue_result(PtyInputEnqueueResult::Oversized)); - assert_eq!( - app.status_message.as_deref(), - Some("Input exceeds the 4 MiB remote buffer limit") - ); + assert_eq!(app.status_message.as_deref(), Some("Input exceeds the 4 MiB PTY buffer limit")); assert!(!app.quit); } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index e97e9678fbab..3ef48f2193b6 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -1,9 +1,9 @@ -//! Ordered, off-loop PTY input forwarding for remote sessions. +//! Ordered, off-loop PTY input forwarding. //! -//! Remote `write_bytes` waits for a control-socket response. All remote PTY -//! bytes use one lane so mouse and keyboard input stay ordered without -//! blocking the UI. Consecutive byte-stream writes are batched, motion is -//! coalesced, and every accepted mouse press reserves its release capacity. +//! All PTY bytes use one lane so local writer locks and remote control-socket +//! responses cannot block the UI. Consecutive byte-stream writes are batched, +//! motion is coalesced, and every accepted mouse press reserves its release +//! capacity. use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; @@ -11,6 +11,7 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use cmux_tui_core::SurfaceId; +use smallvec::SmallVec; use crate::session::SurfaceHandle; @@ -18,6 +19,8 @@ const QUEUE_CAPACITY: usize = 512; const MAX_QUEUED_BYTES: usize = 4 * 1024 * 1024; const RESERVED_RELEASE_BYTES: usize = 64; +pub type PtyInputBytes = SmallVec<[u8; 64]>; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PtyInputKind { Ordered, @@ -36,7 +39,7 @@ pub enum PtyInputEnqueueResult { pub struct PtyInputEvent { pub surface_id: SurfaceId, pub surface: SurfaceHandle, - pub bytes: Vec, + pub bytes: PtyInputBytes, pub kind: PtyInputKind, } @@ -153,7 +156,7 @@ fn enqueue_bounded( events: &mut VecDeque, queued_bytes: &mut usize, release_reservations: &mut usize, - mut event: PtyInputEvent, + event: PtyInputEvent, capacity: usize, max_bytes: usize, ) -> bool { @@ -212,7 +215,7 @@ fn enqueue_bounded( if merge_stream { *queued_bytes += event.bytes.len(); - events.back_mut().unwrap().bytes.append(&mut event.bytes); + events.back_mut().unwrap().bytes.extend_from_slice(&event.bytes); } else { *queued_bytes += event.bytes.len(); events.push_back(event); @@ -250,7 +253,7 @@ mod tests { PtyInputEvent { surface_id, surface: SurfaceHandle::RemoteBrowserUnsupported, - bytes: vec![bytes], + bytes: SmallVec::from_slice(&[bytes]), kind, } } @@ -277,7 +280,7 @@ mod tests { 1024, )); assert_eq!(events.len(), 1); - assert_eq!(events[0].bytes, vec![2]); + assert_eq!(events[0].bytes.as_slice(), &[2]); } #[test] @@ -294,9 +297,10 @@ mod tests { assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, item, 8, 1024,)); } assert_eq!(events.len(), 3); - assert_eq!(events[0].bytes, vec![1, 2]); - assert_eq!(events[1].bytes, vec![3]); - assert_eq!(events[2].bytes, vec![4]); + assert_eq!(events[0].bytes.as_slice(), &[1, 2]); + assert!(!events[0].bytes.spilled()); + assert_eq!(events[1].bytes.as_slice(), &[3]); + assert_eq!(events[2].bytes.as_slice(), &[4]); } #[test] @@ -339,7 +343,7 @@ mod tests { )); assert_eq!(releases, 0); assert_eq!(events.len(), 3); - assert_eq!(events.back().unwrap().bytes, vec![4]); + assert_eq!(events.back().unwrap().bytes.as_slice(), &[4]); } #[test] @@ -348,10 +352,10 @@ mod tests { let mut queued_bytes = 0; let mut releases = 0; let mut first = event(1, 1, PtyInputKind::Ordered); - first.bytes = vec![1; 8]; + first.bytes = vec![1; 8].into(); assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, first, 8, 10,)); let mut overflow = event(1, 2, PtyInputKind::Ordered); - overflow.bytes = vec![2; 3]; + overflow.bytes = vec![2; 3].into(); assert!(!enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, overflow, 8, 10,)); assert_eq!(queued_bytes, 8); } @@ -392,7 +396,7 @@ mod tests { fn oversized_input_is_distinguished_from_queue_saturation() { let dispatcher = PtyInputDispatcher::spawn().unwrap(); let mut oversized = event(1, 1, PtyInputKind::Ordered); - oversized.bytes = vec![1; MAX_QUEUED_BYTES + 1]; + oversized.bytes = vec![1; MAX_QUEUED_BYTES + 1].into(); assert_eq!(dispatcher.enqueue(oversized), PtyInputEnqueueResult::Oversized); } From 68d548feb94c648034dbfb6782c939bfdac77a8a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:17:06 -0700 Subject: [PATCH 013/103] Cover PTY input mutation barrier --- cmux-tui/crates/cmux-tui/src/pty_input.rs | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 3ef48f2193b6..36179044d4f1 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -392,6 +392,31 @@ mod tests { assert_eq!(state.release_reservations, 0); } + #[test] + fn flush_waits_until_prior_input_completes() { + let queue = Arc::new(SharedQueue::default()); + queue.state.lock().unwrap().in_flight = true; + let dispatcher = Arc::new(PtyInputDispatcher { queue: queue.clone(), worker: None }); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let flush_dispatcher = dispatcher.clone(); + let flush_thread = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + done_tx.send(flush_dispatcher.flush(Duration::from_secs(1))).unwrap(); + }); + + started_rx.recv().unwrap(); + assert_eq!(done_rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)); + { + let mut state = queue.state.lock().unwrap(); + state.in_flight = false; + queue.changed.notify_all(); + } + + assert!(done_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + flush_thread.join().unwrap(); + } + #[test] fn oversized_input_is_distinguished_from_queue_saturation() { let dispatcher = PtyInputDispatcher::spawn().unwrap(); From a87d4f3e59fb7add9a51485fa813bb095f245823 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:23:11 -0700 Subject: [PATCH 014/103] Order PTY input before session mutations --- cmux-tui/crates/cmux-tui/src/app.rs | 180 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/pty_input.rs | 27 ++- cmux-tui/crates/cmux-tui/src/session/mod.rs | 21 ++ .../crates/cmux-tui/src/session/remote.rs | 4 + 4 files changed, 198 insertions(+), 34 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5078224461da..75b8d5e7acb7 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -582,6 +582,8 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { session.ensure_initial(initial_size)?; let encoder = KeyEncoder::new()?; let mouse_encoder = MouseEncoder::new()?; + let browser_input = BrowserInputDispatcher::spawn()?; + let pty_input = PtyInputDispatcher::spawn()?; let stdout_lock = Arc::new(Mutex::new(())); let (tx, rx) = channel::(); @@ -684,8 +686,8 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { cell_pixels, pointer_shape: false, last_browser_hover: None, - browser_input: BrowserInputDispatcher::spawn()?, - pty_input: PtyInputDispatcher::spawn()?, + browser_input, + pty_input, drag: None, encoder, mouse_encoder, @@ -865,6 +867,9 @@ impl App { fn refresh_cell_pixels(&mut self, query_fallback: bool) { let next = crate::ui::graphics::detect_cell_pixels(query_fallback); if self.cell_pixels != next { + if !self.flush_pty_input_before_mutation() { + return; + } self.cell_pixels = next; self.session.set_cell_pixel_size(next.0, next.1); } @@ -918,7 +923,7 @@ impl App { .unwrap_or_default(); self.pane_areas.clear(); - let Some(screen) = self.tree.active_screen() else { return }; + let Some(screen) = self.tree.active_screen().cloned() else { return }; for (pane_id, rect) in layout.panes { let Some(pane) = screen.pane(pane_id) else { continue }; let Some(surface_id) = pane.active_surface() else { continue }; @@ -944,7 +949,14 @@ impl App { // server-side resize, so no post-attach reflow artifacts). let size = Some((content.width, content.height)); for tab in &pane.tabs { - if let Some(surface) = self.session.surface_sized(tab.surface, size) { + if !self.session.has_surface(tab.surface) && !self.flush_pty_input_before_mutation() + { + return; + } + if let Some(surface) = self.session.surface_sized(tab.surface, size) + && surface.resize_needed(content.width, content.height, false) + && self.flush_pty_input_before_mutation() + { surface.resize(content.width, content.height); } } @@ -972,6 +984,9 @@ impl App { if rect.width == 0 || rect.height == 0 { return; } + if relaunch && !self.flush_pty_input_before_mutation() { + return; + } let status = self.session.sidebar_plugin((rect.width, rect.height), relaunch); self.sidebar_plugin_surface = status.surface_id; self.sidebar_plugin_error = status.error; @@ -1107,7 +1122,7 @@ impl App { self.tree.active_screen().map(|screen| screen.id) } - fn reassert_visible_surface_sizes(&self) { + fn reassert_visible_surface_sizes(&mut self) { if self.config.sidebar.plugin.is_some() && self.sidebar_visible && self.sidebar_width >= 3 @@ -1115,18 +1130,28 @@ impl App { { let rect = self.sidebar_plugin_rect(); if rect.width > 0 && rect.height > 0 { - surface.reassert_size(rect.width, rect.height); + let needs_barrier = surface.resize_needed(rect.width, rect.height, true); + if !needs_barrier || self.flush_pty_input_before_mutation() { + surface.reassert_size(rect.width, rect.height); + } } } - for area in &self.pane_areas { + for area in self.pane_areas.clone() { if area.content.width == 0 || area.content.height == 0 { continue; } + if !self.session.has_surface(area.surface) && !self.flush_pty_input_before_mutation() { + return; + } if let Some(surface) = self .session .surface_sized(area.surface, Some((area.content.width, area.content.height))) { - surface.reassert_size(area.content.width, area.content.height); + let needs_barrier = + surface.resize_needed(area.content.width, area.content.height, true); + if !needs_barrier || self.flush_pty_input_before_mutation() { + surface.reassert_size(area.content.width, area.content.height); + } } } } @@ -1193,6 +1218,9 @@ impl App { fn split_pane(&mut self, pane: PaneId, dir: SplitDir) -> anyhow::Result<()> { let hint = self.split_size_hint(pane, dir); + if !self.flush_pty_input_before_mutation() { + return Ok(()); + } self.session.split(pane, dir, hint) } @@ -1206,10 +1234,16 @@ impl App { } fn new_workspace(&mut self) -> anyhow::Result<()> { + if !self.flush_pty_input_before_mutation() { + return Ok(()); + } self.session.new_workspace(self.size_of_rect(self.content_area)) } fn new_screen(&mut self) -> anyhow::Result<()> { + if !self.flush_pty_input_before_mutation() { + return Ok(()); + } self.session.new_screen(self.size_of_rect(self.content_area)) } @@ -1252,6 +1286,9 @@ impl App { fn commit_prompt(&mut self) { let Some(prompt) = self.take_prompt() else { return }; let input = prompt.input.as_str().to_string(); + if !self.flush_pty_input_before_mutation() { + return; + } match prompt.target { PromptTarget::Workspace(id) => { if !input.is_empty() { @@ -1345,6 +1382,9 @@ impl App { return Ok(RenderAction::Draw); } let url = cmux_tui_core::normalize_url(input); + if !self.flush_pty_input_before_mutation() { + return Ok(RenderAction::None); + } match self.session.surface(state.surface) { Some(handle) => { self.status_message = @@ -1420,6 +1460,9 @@ impl App { /// Execute one bound action. Shared by the (configurable) prefix keys /// and any future command surface. fn run_action(&mut self, action: Action) -> anyhow::Result { + if action_requires_pty_input_barrier(action) && !self.flush_pty_input_before_mutation() { + return Ok(RenderAction::None); + } let pane = self.active_pane(); match action { Action::NewTab => { @@ -1573,6 +1616,9 @@ impl App { } fn create_browser_tab_for_edit(&mut self, pane: Option) -> anyhow::Result<()> { + if !self.flush_pty_input_before_mutation() { + return Ok(()); + } self.session.new_browser_tab( "about:blank".to_string(), pane, @@ -1670,6 +1716,10 @@ impl App { } fn activate_menu(&mut self, action: MenuAction) -> anyhow::Result<()> { + if menu_action_requires_pty_input_barrier(action) && !self.flush_pty_input_before_mutation() + { + return Ok(()); + } match action { MenuAction::RenameWorkspace(id) => { let buffer = self @@ -1755,14 +1805,14 @@ impl App { Ok(()) } - fn move_focus(&self, dx: i32, dy: i32) { + fn move_focus(&mut self, dx: i32, dy: i32) { let Some(active) = self.active_pane() else { return }; // Re-derive the layout geometry from the frame's pane areas. let layout = cmux_tui_core::LayoutResult { panes: self.pane_areas.iter().map(|a| (a.pane, a.rect)).collect(), }; if let Some(next) = layout.neighbor(active, dx, dy) { - self.session.focus_pane(next); + self.focus_pane_after_input(next); } } @@ -1785,15 +1835,17 @@ impl App { panes.get(next).copied() } - fn focus_next_pane(&self) { + fn focus_next_pane(&mut self) { if let Some(next) = self.adjacent_pane_by_order(1) { - self.session.focus_pane(next); + self.focus_pane_after_input(next); } } - fn swap_pane_by_order(&self, delta: isize) { + fn swap_pane_by_order(&mut self, delta: isize) { let Some(active) = self.active_pane() else { return }; - if let Some(target) = self.adjacent_pane_by_order(delta) { + if let Some(target) = self.adjacent_pane_by_order(delta) + && self.flush_pty_input_before_mutation() + { self.session.swap_pane(active, target); } } @@ -2206,7 +2258,7 @@ impl App { } if self.active_pane() != Some(area.pane) { - self.session.focus_pane(area.pane); + self.focus_pane_after_input(area.pane); } self.sidebar_focused = false; self.selection = None; @@ -2465,6 +2517,26 @@ impl App { } } + fn flush_pty_input_before_mutation(&mut self) -> bool { + self.cancel_pty_mouse_drag(); + if self.quit || matches!(self.drag, Some(Drag::PtyMouse { .. })) { + return false; + } + if self.pty_input.flush(Duration::from_secs(3)) { + return true; + } + self.pty_input.abort(); + self.status_message = Some("PTY input did not finish before the session mutation".into()); + self.quit = true; + false + } + + fn focus_pane_after_input(&mut self, pane: PaneId) { + if self.flush_pty_input_before_mutation() { + self.session.focus_pane(pane); + } + } + fn current_pty_content(&self, surface: SurfaceId) -> Option { self.pane_areas.iter().find(|area| area.surface == surface).map(|area| area.content) } @@ -2680,7 +2752,7 @@ impl App { } if let Some((pane, hit)) = self.omnibar_hit_at(x, y) { - self.session.focus_pane(pane); + self.focus_pane_after_input(pane); if let Some(state) = &self.omnibar { if state.pane == pane { return Ok(RenderAction::Draw); @@ -2741,7 +2813,9 @@ impl App { } Hit::NewWorkspace => self.new_workspace()?, Hit::ScreenEntry { index, .. } => { - self.session.select_screen(Some(index), None); + if self.flush_pty_input_before_mutation() { + self.session.select_screen(Some(index), None); + } } Hit::NewScreen => self.new_screen()?, Hit::Tab { pane, index } => { @@ -2755,8 +2829,10 @@ impl App { } } Hit::NewTab { pane } => { - self.session.focus_pane(pane); - self.session.new_tab(Some(pane), None)?; + self.focus_pane_after_input(pane); + if self.flush_pty_input_before_mutation() { + self.session.new_tab(Some(pane), None)?; + } } Hit::Scrollbar { surface, track } => { self.start_scrollbar_drag(surface, track, y); @@ -2774,7 +2850,7 @@ impl App { if area.content.contains(x, y) { if self.surface_kind(area.surface) == SurfaceKind::Browser { if self.active_pane() != Some(area.pane) { - self.session.focus_pane(area.pane); + self.focus_pane_after_input(area.pane); } self.send_browser_mouse( area.surface, @@ -2789,7 +2865,7 @@ impl App { return Ok(RenderAction::Draw); } else { if self.active_pane() != Some(area.pane) { - self.session.focus_pane(area.pane); + self.focus_pane_after_input(area.pane); } // Begin a text selection; it becomes visible once the // mouse moves to a second cell. @@ -2804,7 +2880,7 @@ impl App { }); } } else if self.active_pane() != Some(area.pane) { - self.session.focus_pane(area.pane); + self.focus_pane_after_input(area.pane); } return Ok(RenderAction::Draw); } @@ -2906,28 +2982,36 @@ impl App { if let Some(Drag::TabArm { surface, .. }) = self.drag { self.drag = None; if let Some((pane, index)) = self.tab_location(surface) { - self.session.focus_pane(pane); - self.session.select_tab(Some(pane), Some(index), None); + self.focus_pane_after_input(pane); + if self.flush_pty_input_before_mutation() { + self.session.select_tab(Some(pane), Some(index), None); + } } return Ok(RenderAction::Draw); } if let Some(Drag::Tab { surface, .. }) = self.drag { self.drag = None; - if let Some((pane, index)) = self.tab_drop_target_at(x, y) { + if let Some((pane, index)) = self.tab_drop_target_at(x, y) + && self.flush_pty_input_before_mutation() + { self.session.move_tab(surface, pane, index); } return Ok(RenderAction::Draw); } if let Some(Drag::WorkspaceArm { workspace, .. }) = self.drag { self.drag = None; - if let Some(index) = self.workspace_index(workspace) { + if let Some(index) = self.workspace_index(workspace) + && self.flush_pty_input_before_mutation() + { self.session.select_workspace(Some(index), None); } return Ok(RenderAction::Draw); } if let Some(Drag::Workspace { workspace, .. }) = self.drag { self.drag = None; - if let Some(index) = self.workspace_drop_target_at(x, y) { + if let Some(index) = self.workspace_drop_target_at(x, y) + && self.flush_pty_input_before_mutation() + { self.session.move_workspace(workspace, index); } return Ok(RenderAction::Draw); @@ -3124,7 +3208,13 @@ impl App { 1.0, ), }; - self.session.set_ratio(target.set_pane, dir, (current + delta * sign).clamp(0.05, 0.95)); + if self.flush_pty_input_before_mutation() { + self.session.set_ratio( + target.set_pane, + dir, + (current + delta * sign).clamp(0.05, 0.95), + ); + } } fn resize_split(&mut self, pane: PaneId, edge: PaneEdge, x: u16, y: u16) { @@ -3153,7 +3243,9 @@ impl App { return; } let ratio = (coord.saturating_sub(start) as f32 / extent as f32).clamp(0.05, 0.95); - self.session.set_ratio(target.set_pane, dir, ratio); + if self.flush_pty_input_before_mutation() { + self.session.set_ratio(target.set_pane, dir, ratio); + } } fn open_context_menu(&mut self, x: u16, y: u16) { @@ -3224,7 +3316,7 @@ impl App { } let Some(area) = self.pane_area_at(x, y).copied() else { return Ok(RenderAction::None) }; if self.active_pane() != Some(area.pane) { - self.session.focus_pane(area.pane); + self.focus_pane_after_input(area.pane); } // Wheel over the tab bar scrolls the tabs, not the terminal. if area.bar.is_some_and(|bar| bar.contains(x, y)) { @@ -3298,7 +3390,7 @@ impl App { return Ok(RenderAction::None); }; if self.active_pane() != Some(area.pane) { - self.session.focus_pane(area.pane); + self.focus_pane_after_input(area.pane); } if area.content.contains(x, y) && self.forward_pty_mouse_at( @@ -3393,6 +3485,32 @@ fn browser_only_action(action: Action) -> bool { ) } +fn action_requires_pty_input_barrier(action: Action) -> bool { + !matches!( + action, + Action::RenameTab + | Action::RenameScreen + | Action::RenameWorkspace + | Action::ScrollUp + | Action::ScrollDown + | Action::BrowserEditUrl + ) +} + +fn menu_action_requires_pty_input_barrier(action: MenuAction) -> bool { + !matches!( + action, + MenuAction::RenameWorkspace(_) + | MenuAction::CopyWorkspaceId(_) + | MenuAction::RenameScreen(_) + | MenuAction::BrowserEditUrl(_) + | MenuAction::BrowserCopyUrl(_) + | MenuAction::RenameTab(_) + | MenuAction::CopyTabId(_) + | MenuAction::CopyPaneId(_) + ) +} + fn browser_hover_forward_allowed(status: Option, editing_same_pane: bool) -> bool { !editing_same_pane && matches!(status, Some(BrowserStatus::Live)) } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 36179044d4f1..c667eaac9696 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -104,6 +104,19 @@ impl PtyInputDispatcher { self.queue.changed.notify_all(); } + /// Wait until every input accepted before this call has completed. + /// This is an ordering barrier for infrequent surface/session mutations. + pub fn flush(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let mut state = self.queue.state.lock().unwrap(); + while (!state.events.is_empty() || state.in_flight) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let (next, _) = self.queue.changed.wait_timeout(state, remaining).unwrap(); + state = next; + } + state.events.is_empty() && !state.in_flight + } + /// Stop accepting work and discard queued bytes after an explicit input /// saturation disconnect. The in-flight request is allowed to return. pub fn abort(&self) { @@ -393,20 +406,27 @@ mod tests { } #[test] - fn flush_waits_until_prior_input_completes() { + fn accepted_input_completes_before_following_mutation() { let queue = Arc::new(SharedQueue::default()); queue.state.lock().unwrap().in_flight = true; let dispatcher = Arc::new(PtyInputDispatcher { queue: queue.clone(), worker: None }); + let mutated = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (started_tx, started_rx) = std::sync::mpsc::channel(); let (done_tx, done_rx) = std::sync::mpsc::channel(); - let flush_dispatcher = dispatcher.clone(); + let flush_dispatcher = dispatcher; + let mutation = mutated.clone(); let flush_thread = std::thread::spawn(move || { started_tx.send(()).unwrap(); - done_tx.send(flush_dispatcher.flush(Duration::from_secs(1))).unwrap(); + let flushed = flush_dispatcher.flush(Duration::from_secs(1)); + if flushed { + mutation.store(true, std::sync::atomic::Ordering::Release); + } + done_tx.send(flushed).unwrap(); }); started_rx.recv().unwrap(); assert_eq!(done_rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)); + assert!(!mutated.load(std::sync::atomic::Ordering::Acquire)); { let mut state = queue.state.lock().unwrap(); state.in_flight = false; @@ -414,6 +434,7 @@ mod tests { } assert!(done_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + assert!(mutated.load(std::sync::atomic::Ordering::Acquire)); flush_thread.join().unwrap(); } diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 79d4f9a3ecd4..08a04c803b54 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -170,6 +170,13 @@ impl Session { self.surface_sized(id, None) } + pub fn has_surface(&self, id: SurfaceId) -> bool { + match self { + Session::Local(mux) => mux.surface(id).is_some(), + Session::Remote(remote) => remote.has_surface(id), + } + } + /// Like [`Session::surface`], but passes the render size for remote /// mirrors created on first use (the server surface is resized before /// the attach replay, so the replay arrives at final geometry). @@ -505,6 +512,20 @@ impl SurfaceHandle { } } + pub fn resize_needed(&self, cols: u16, rows: u16, user_interaction: bool) -> bool { + let desired = (cols.max(1), rows.max(1)); + match self { + SurfaceHandle::Local(surface) => surface.size() != desired, + SurfaceHandle::Remote(surface, _) => resize_action( + desired, + surface.asserted_size(), + surface.server_size(), + user_interaction, + ), + SurfaceHandle::RemoteBrowserUnsupported => false, + } + } + pub fn reassert_size(&self, cols: u16, rows: u16) { let desired = (cols.max(1), rows.max(1)); match self { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index e34e63aad942..52f9af0dc9f0 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -182,6 +182,10 @@ pub struct RemoteSession { } impl RemoteSession { + pub(super) fn has_surface(&self, id: SurfaceId) -> bool { + self.surfaces.lock().unwrap().contains_key(&id) + } + pub fn connect(path: &Path) -> anyhow::Result> { let stream = transport::connect(path).map_err(|e| { anyhow::anyhow!("cannot connect to session socket {}: {e}", path.display()) From 674d6a6b849a172536b6e3f92d7cd54024e1a658 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:40:30 -0700 Subject: [PATCH 015/103] Cover PTY input backpressure recovery --- cmux-tui/crates/cmux-tui/src/app.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 75b8d5e7acb7..8b35f26fd6b0 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3736,6 +3736,13 @@ mod tests { assert!(!app.handle_pty_enqueue_result(PtyInputEnqueueResult::Oversized)); assert_eq!(app.status_message.as_deref(), Some("Input exceeds the 4 MiB PTY buffer limit")); assert!(!app.quit); + + assert!(!app.handle_pty_enqueue_result(PtyInputEnqueueResult::Saturated)); + assert_eq!( + app.status_message.as_deref(), + Some("PTY input queue is full; input was not sent") + ); + assert!(!app.quit); } #[test] From 4bada3732b2881d91250a3baf86b38af1238800d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:40:50 -0700 Subject: [PATCH 016/103] Serialize PTY input and session mutations --- cmux-tui/crates/cmux-tui/src/app.rs | 337 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/pty_input.rs | 326 ++++++++++++----- cmux-tui/crates/cmux-tui/src/session/mod.rs | 13 +- .../crates/cmux-tui/src/session/remote.rs | 4 +- 4 files changed, 538 insertions(+), 142 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 8b35f26fd6b0..cc676149ac17 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::io::Write; +use std::ops::Deref; use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -38,6 +39,7 @@ use crate::config::{Action, Config, ScrollbarPosition}; use crate::keys; use crate::pty_input::{ PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind, + PtyInputSender, PtyOperationFailure, }; use crate::session::{Session, SurfaceHandle, TreeView}; use crate::ui::graphics::GraphicPlacement; @@ -48,6 +50,214 @@ use crate::ui::thumb_geometry; pub enum AppEvent { Mux(MuxEvent), Input(Event), + PtyOperationFailed(PtyOperationFailure), +} + +/// Read access stays synchronous, while every UI-originated session mutation +/// enters the same ordered worker as PTY input. Accepted keys, mouse releases, +/// resizes, and closes therefore have one execution order without blocking the +/// event loop. +pub struct OrderedSession { + inner: Session, + operations: PtyInputSender, +} + +impl OrderedSession { + fn new(inner: Session, operations: PtyInputSender) -> Self { + Self { inner, operations } + } + + fn enqueue( + &self, + label: &'static str, + operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, + ) { + let session = self.inner.clone(); + self.operations.enqueue_mutation(label, move || operation(session)); + } + + fn resize_surface( + &self, + surface_id: SurfaceId, + surface: SurfaceHandle, + cols: u16, + rows: u16, + reassert: bool, + ) { + self.operations.enqueue_coalescing_mutation( + "resize PTY surface", + ("surface resize", surface_id), + move || { + if reassert { + surface.reassert_size(cols, rows); + } else { + surface.resize(cols, rows); + } + Ok(()) + }, + ); + } + + pub fn new_tab(&self, pane: Option, size: Option<(u16, u16)>) -> anyhow::Result<()> { + self.enqueue("create tab", move |session| session.new_tab(pane, size)); + Ok(()) + } + + pub fn set_cell_pixel_size(&self, width: u16, height: u16) { + self.enqueue("set cell pixel size", move |session| { + session.set_cell_pixel_size(width, height); + Ok(()) + }); + } + + pub fn new_workspace(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { + self.enqueue("create workspace", move |session| session.new_workspace(size)); + Ok(()) + } + + pub fn new_screen(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { + self.enqueue("create screen", move |session| session.new_screen(size)); + Ok(()) + } + + pub fn close_screen(&self, screen: cmux_tui_core::ScreenId) { + self.enqueue("close screen", move |session| { + session.close_screen(screen); + Ok(()) + }); + } + + pub fn rename_screen(&self, screen: cmux_tui_core::ScreenId, name: String) { + self.enqueue("rename screen", move |session| { + session.rename_screen(screen, name); + Ok(()) + }); + } + + pub fn select_screen(&self, index: Option, delta: Option) { + self.enqueue("select screen", move |session| { + session.select_screen(index, delta); + Ok(()) + }); + } + + pub fn zoom_pane(&self, pane: Option) { + self.enqueue("zoom pane", move |session| { + session.zoom_pane(pane); + Ok(()) + }); + } + + pub fn split( + &self, + pane: PaneId, + dir: SplitDir, + size: Option<(u16, u16)>, + ) -> anyhow::Result<()> { + self.enqueue("split pane", move |session| session.split(pane, dir, size)); + Ok(()) + } + + pub fn set_ratio(&self, pane: PaneId, dir: SplitDir, ratio: f32) { + let session = self.inner.clone(); + let direction = match dir { + SplitDir::Right => "horizontal split ratio", + SplitDir::Down => "vertical split ratio", + }; + self.operations.enqueue_coalescing_mutation( + "resize pane split", + (direction, pane), + move || { + session.set_ratio(pane, dir, ratio); + Ok(()) + }, + ); + } + + pub fn close_surface(&self, surface: SurfaceId) { + self.enqueue("close tab", move |session| { + session.close_surface(surface); + Ok(()) + }); + } + + pub fn close_pane(&self, pane: PaneId) { + self.enqueue("close pane", move |session| { + session.close_pane(pane); + Ok(()) + }); + } + + pub fn swap_pane(&self, pane: PaneId, target: PaneId) { + self.enqueue("swap panes", move |session| { + session.swap_pane(pane, target); + Ok(()) + }); + } + + pub fn close_workspace(&self, workspace: WorkspaceId) { + self.enqueue("close workspace", move |session| { + session.close_workspace(workspace); + Ok(()) + }); + } + + pub fn rename_surface(&self, surface: SurfaceId, name: String) { + self.enqueue("rename tab", move |session| { + session.rename_surface(surface, name); + Ok(()) + }); + } + + pub fn rename_workspace(&self, workspace: WorkspaceId, name: String) { + self.enqueue("rename workspace", move |session| { + session.rename_workspace(workspace, name); + Ok(()) + }); + } + + pub fn focus_pane(&self, pane: PaneId) { + self.enqueue("focus pane", move |session| { + session.focus_pane(pane); + Ok(()) + }); + } + + pub fn select_tab(&self, pane: Option, index: Option, delta: Option) { + self.enqueue("select tab", move |session| { + session.select_tab(pane, index, delta); + Ok(()) + }); + } + + pub fn select_workspace(&self, index: Option, delta: Option) { + self.enqueue("select workspace", move |session| { + session.select_workspace(index, delta); + Ok(()) + }); + } + + pub fn move_tab(&self, surface: SurfaceId, pane: PaneId, index: usize) { + self.enqueue("move tab", move |session| { + session.move_tab(surface, pane, index); + Ok(()) + }); + } + + pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) { + self.enqueue("move workspace", move |session| { + session.move_workspace(workspace, index); + Ok(()) + }); + } +} + +impl Deref for OrderedSession { + type Target = Session; + + fn deref(&self) -> &Self::Target { + &self.inner + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -391,7 +601,7 @@ enum Drag { } pub struct App { - pub session: Session, + pub session: OrderedSession, pub config: Config, pub tree: TreeView, pub render_states: HashMap, @@ -582,12 +792,15 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { session.ensure_initial(initial_size)?; let encoder = KeyEncoder::new()?; let mouse_encoder = MouseEncoder::new()?; + let (tx, rx) = channel::(); let browser_input = BrowserInputDispatcher::spawn()?; - let pty_input = PtyInputDispatcher::spawn()?; + let failure_tx = tx.clone(); + let pty_input = PtyInputDispatcher::spawn(move |failure| { + let _ = failure_tx.send(AppEvent::PtyOperationFailed(failure)); + })?; + let session = OrderedSession::new(session, pty_input.sender()); let stdout_lock = Arc::new(Mutex::new(())); - let (tx, rx) = channel::(); - // Session events → app channel. let session_events = session.events(); std::thread::Builder::new().name("mux-events".into()).spawn({ @@ -867,7 +1080,7 @@ impl App { fn refresh_cell_pixels(&mut self, query_fallback: bool) { let next = crate::ui::graphics::detect_cell_pixels(query_fallback); if self.cell_pixels != next { - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return; } self.cell_pixels = next; @@ -949,15 +1162,22 @@ impl App { // server-side resize, so no post-attach reflow artifacts). let size = Some((content.width, content.height)); for tab in &pane.tabs { - if !self.session.has_surface(tab.surface) && !self.flush_pty_input_before_mutation() + if !self.session.has_surface(tab.surface) + && !self.prepare_pty_input_before_mutation() { return; } if let Some(surface) = self.session.surface_sized(tab.surface, size) && surface.resize_needed(content.width, content.height, false) - && self.flush_pty_input_before_mutation() + && self.prepare_pty_input_before_mutation() { - surface.resize(content.width, content.height); + self.session.resize_surface( + tab.surface, + surface, + content.width, + content.height, + false, + ); } } } @@ -984,7 +1204,7 @@ impl App { if rect.width == 0 || rect.height == 0 { return; } - if relaunch && !self.flush_pty_input_before_mutation() { + if relaunch && !self.prepare_pty_input_before_mutation() { return; } let status = self.session.sidebar_plugin((rect.width, rect.height), relaunch); @@ -1048,6 +1268,15 @@ impl App { } } AppEvent::Mux(_) => Ok(RenderAction::Draw), + AppEvent::PtyOperationFailed(failure) => { + if failure.surface_id.is_some_and(|surface| { + matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) + }) { + self.drag = None; + } + self.status_message = Some(format!("{} failed: {}", failure.label, failure.error)); + Ok(RenderAction::Draw) + } AppEvent::Input(Event::Key(key)) => { if key.kind != KeyEventKind::Release { self.reassert_visible_surface_sizes(); @@ -1131,8 +1360,12 @@ impl App { let rect = self.sidebar_plugin_rect(); if rect.width > 0 && rect.height > 0 { let needs_barrier = surface.resize_needed(rect.width, rect.height, true); - if !needs_barrier || self.flush_pty_input_before_mutation() { + if !needs_barrier { surface.reassert_size(rect.width, rect.height); + } else if self.prepare_pty_input_before_mutation() + && let Some(surface_id) = self.sidebar_plugin_surface + { + self.session.resize_surface(surface_id, surface, rect.width, rect.height, true); } } } @@ -1140,7 +1373,8 @@ impl App { if area.content.width == 0 || area.content.height == 0 { continue; } - if !self.session.has_surface(area.surface) && !self.flush_pty_input_before_mutation() { + if !self.session.has_surface(area.surface) && !self.prepare_pty_input_before_mutation() + { return; } if let Some(surface) = self @@ -1149,8 +1383,16 @@ impl App { { let needs_barrier = surface.resize_needed(area.content.width, area.content.height, true); - if !needs_barrier || self.flush_pty_input_before_mutation() { + if !needs_barrier { surface.reassert_size(area.content.width, area.content.height); + } else if self.prepare_pty_input_before_mutation() { + self.session.resize_surface( + area.surface, + surface, + area.content.width, + area.content.height, + true, + ); } } } @@ -1218,7 +1460,7 @@ impl App { fn split_pane(&mut self, pane: PaneId, dir: SplitDir) -> anyhow::Result<()> { let hint = self.split_size_hint(pane, dir); - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return Ok(()); } self.session.split(pane, dir, hint) @@ -1234,14 +1476,14 @@ impl App { } fn new_workspace(&mut self) -> anyhow::Result<()> { - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return Ok(()); } self.session.new_workspace(self.size_of_rect(self.content_area)) } fn new_screen(&mut self) -> anyhow::Result<()> { - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return Ok(()); } self.session.new_screen(self.size_of_rect(self.content_area)) @@ -1286,7 +1528,7 @@ impl App { fn commit_prompt(&mut self) { let Some(prompt) = self.take_prompt() else { return }; let input = prompt.input.as_str().to_string(); - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return; } match prompt.target { @@ -1382,7 +1624,7 @@ impl App { return Ok(RenderAction::Draw); } let url = cmux_tui_core::normalize_url(input); - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return Ok(RenderAction::None); } match self.session.surface(state.surface) { @@ -1460,7 +1702,7 @@ impl App { /// Execute one bound action. Shared by the (configurable) prefix keys /// and any future command surface. fn run_action(&mut self, action: Action) -> anyhow::Result { - if action_requires_pty_input_barrier(action) && !self.flush_pty_input_before_mutation() { + if action_prepares_pty_release(action) && !self.prepare_pty_input_before_mutation() { return Ok(RenderAction::None); } let pane = self.active_pane(); @@ -1616,7 +1858,7 @@ impl App { } fn create_browser_tab_for_edit(&mut self, pane: Option) -> anyhow::Result<()> { - if !self.flush_pty_input_before_mutation() { + if !self.prepare_pty_input_before_mutation() { return Ok(()); } self.session.new_browser_tab( @@ -1716,8 +1958,7 @@ impl App { } fn activate_menu(&mut self, action: MenuAction) -> anyhow::Result<()> { - if menu_action_requires_pty_input_barrier(action) && !self.flush_pty_input_before_mutation() - { + if menu_action_prepares_pty_release(action) && !self.prepare_pty_input_before_mutation() { return Ok(()); } match action { @@ -1844,7 +2085,7 @@ impl App { fn swap_pane_by_order(&mut self, delta: isize) { let Some(active) = self.active_pane() else { return }; if let Some(target) = self.adjacent_pane_by_order(delta) - && self.flush_pty_input_before_mutation() + && self.prepare_pty_input_before_mutation() { self.session.swap_pane(active, target); } @@ -2495,7 +2736,7 @@ impl App { bytes: PtyInputBytes, kind: PtyInputKind, ) -> bool { - let result = self.pty_input.enqueue(PtyInputEvent { surface_id, surface, bytes, kind }); + let result = self.pty_input.enqueue(PtyInputEvent::input(surface_id, surface, bytes, kind)); self.handle_pty_enqueue_result(result) } @@ -2507,32 +2748,20 @@ impl App { false } PtyInputEnqueueResult::Saturated => { - // Saturation means ordered input can no longer be represented - // safely. Disconnect instead of silently dropping or replaying - // a partial stream after recovery. - self.pty_input.abort(); - self.quit = true; + self.status_message = + Some("PTY input queue is full; input was not sent".to_string()); false } } } - fn flush_pty_input_before_mutation(&mut self) -> bool { + fn prepare_pty_input_before_mutation(&mut self) -> bool { self.cancel_pty_mouse_drag(); - if self.quit || matches!(self.drag, Some(Drag::PtyMouse { .. })) { - return false; - } - if self.pty_input.flush(Duration::from_secs(3)) { - return true; - } - self.pty_input.abort(); - self.status_message = Some("PTY input did not finish before the session mutation".into()); - self.quit = true; - false + !matches!(self.drag, Some(Drag::PtyMouse { .. })) } fn focus_pane_after_input(&mut self, pane: PaneId) { - if self.flush_pty_input_before_mutation() { + if self.prepare_pty_input_before_mutation() { self.session.focus_pane(pane); } } @@ -2813,7 +3042,7 @@ impl App { } Hit::NewWorkspace => self.new_workspace()?, Hit::ScreenEntry { index, .. } => { - if self.flush_pty_input_before_mutation() { + if self.prepare_pty_input_before_mutation() { self.session.select_screen(Some(index), None); } } @@ -2830,7 +3059,7 @@ impl App { } Hit::NewTab { pane } => { self.focus_pane_after_input(pane); - if self.flush_pty_input_before_mutation() { + if self.prepare_pty_input_before_mutation() { self.session.new_tab(Some(pane), None)?; } } @@ -2983,7 +3212,7 @@ impl App { self.drag = None; if let Some((pane, index)) = self.tab_location(surface) { self.focus_pane_after_input(pane); - if self.flush_pty_input_before_mutation() { + if self.prepare_pty_input_before_mutation() { self.session.select_tab(Some(pane), Some(index), None); } } @@ -2992,7 +3221,7 @@ impl App { if let Some(Drag::Tab { surface, .. }) = self.drag { self.drag = None; if let Some((pane, index)) = self.tab_drop_target_at(x, y) - && self.flush_pty_input_before_mutation() + && self.prepare_pty_input_before_mutation() { self.session.move_tab(surface, pane, index); } @@ -3001,7 +3230,7 @@ impl App { if let Some(Drag::WorkspaceArm { workspace, .. }) = self.drag { self.drag = None; if let Some(index) = self.workspace_index(workspace) - && self.flush_pty_input_before_mutation() + && self.prepare_pty_input_before_mutation() { self.session.select_workspace(Some(index), None); } @@ -3010,7 +3239,7 @@ impl App { if let Some(Drag::Workspace { workspace, .. }) = self.drag { self.drag = None; if let Some(index) = self.workspace_drop_target_at(x, y) - && self.flush_pty_input_before_mutation() + && self.prepare_pty_input_before_mutation() { self.session.move_workspace(workspace, index); } @@ -3208,7 +3437,7 @@ impl App { 1.0, ), }; - if self.flush_pty_input_before_mutation() { + if self.prepare_pty_input_before_mutation() { self.session.set_ratio( target.set_pane, dir, @@ -3243,7 +3472,7 @@ impl App { return; } let ratio = (coord.saturating_sub(start) as f32 / extent as f32).clamp(0.05, 0.95); - if self.flush_pty_input_before_mutation() { + if self.prepare_pty_input_before_mutation() { self.session.set_ratio(target.set_pane, dir, ratio); } } @@ -3485,7 +3714,7 @@ fn browser_only_action(action: Action) -> bool { ) } -fn action_requires_pty_input_barrier(action: Action) -> bool { +fn action_prepares_pty_release(action: Action) -> bool { !matches!( action, Action::RenameTab @@ -3497,7 +3726,7 @@ fn action_requires_pty_input_barrier(action: Action) -> bool { ) } -fn menu_action_requires_pty_input_barrier(action: MenuAction) -> bool { +fn menu_action_prepares_pty_release(action: MenuAction) -> bool { !matches!( action, MenuAction::RenameWorkspace(_) @@ -3554,7 +3783,7 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, AppEvent, Drag, PaneArea, browser_content_size_for_rect, + App, AppEvent, Drag, OrderedSession, PaneArea, browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::HashMap; @@ -3729,7 +3958,7 @@ mod tests { } #[test] - fn oversized_remote_input_shows_an_error_without_disconnecting() { + fn rejected_input_shows_an_error_without_disconnecting() { let mux = Mux::new("oversized-input-test", SurfaceOptions::default()); let mut app = test_app(Session::Local(mux)); @@ -3800,6 +4029,8 @@ mod tests { } fn test_app(session: Session) -> App { + let pty_input = PtyInputDispatcher::spawn(|_| {}).unwrap(); + let session = OrderedSession::new(session, pty_input.sender()); App { session, config: Config::default(), @@ -3833,7 +4064,7 @@ mod tests { pointer_shape: false, last_browser_hover: None, browser_input: BrowserInputDispatcher::spawn().unwrap(), - pty_input: PtyInputDispatcher::spawn().unwrap(), + pty_input, drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index c667eaac9696..f3df8076734f 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -27,6 +27,7 @@ pub enum PtyInputKind { Press, Motion, Release, + Mutation, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -41,6 +42,51 @@ pub struct PtyInputEvent { pub surface: SurfaceHandle, pub bytes: PtyInputBytes, pub kind: PtyInputKind, + mutation: Option anyhow::Result<()> + Send>>, + label: &'static str, + coalesce_key: Option<(&'static str, u64)>, +} + +impl PtyInputEvent { + pub fn input( + surface_id: SurfaceId, + surface: SurfaceHandle, + bytes: PtyInputBytes, + kind: PtyInputKind, + ) -> Self { + Self { + surface_id, + surface, + bytes, + kind, + mutation: None, + label: "PTY input", + coalesce_key: None, + } + } + + fn mutation( + label: &'static str, + coalesce_key: Option<(&'static str, u64)>, + operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, + ) -> Self { + Self { + surface_id: 0, + surface: SurfaceHandle::RemoteBrowserUnsupported, + bytes: PtyInputBytes::new(), + kind: PtyInputKind::Mutation, + mutation: Some(Box::new(operation)), + label, + coalesce_key, + } + } +} + +#[derive(Debug, Clone)] +pub struct PtyOperationFailure { + pub surface_id: Option, + pub label: &'static str, + pub error: String, } #[derive(Default)] @@ -59,20 +105,69 @@ struct SharedQueue { } pub struct PtyInputDispatcher { - queue: Arc, + sender: PtyInputSender, worker: Option>, } +#[derive(Clone)] +pub struct PtyInputSender { + queue: Arc, + on_failure: Arc, +} + impl PtyInputDispatcher { - pub fn spawn() -> anyhow::Result { + pub fn spawn( + on_failure: impl Fn(PtyOperationFailure) + Send + Sync + 'static, + ) -> anyhow::Result { let queue = Arc::new(SharedQueue::default()); let worker_queue = queue.clone(); + let on_failure = Arc::new(on_failure); + let worker_failure = on_failure.clone(); let worker = std::thread::Builder::new() .name("mux-pty-input".into()) - .spawn(move || worker(worker_queue))?; - Ok(Self { queue, worker: Some(worker) }) + .spawn(move || worker(worker_queue, worker_failure))?; + Ok(Self { sender: PtyInputSender { queue, on_failure }, worker: Some(worker) }) + } + + pub fn enqueue(&self, event: PtyInputEvent) -> PtyInputEnqueueResult { + self.sender.enqueue(event) + } + + pub fn sender(&self) -> PtyInputSender { + self.sender.clone() + } + + pub fn cancel_release_reservation(&self) { + self.sender.cancel_release_reservation(); + } + + /// Drain queued writes during detach/normal shutdown, bounded so a + /// half-open remote session cannot hang terminal restoration forever. + pub fn shutdown(&mut self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let mut state = self.sender.queue.state.lock().unwrap(); + while (!state.events.is_empty() || state.in_flight) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let (next, _) = self.sender.queue.changed.wait_timeout(state, remaining).unwrap(); + state = next; + } + let drained = state.events.is_empty() && !state.in_flight; + state.closed = true; + if !drained { + state.events.clear(); + state.queued_bytes = 0; + state.release_reservations = 0; + } + self.sender.queue.changed.notify_all(); + drop(state); + if drained && let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + drained } +} +impl PtyInputSender { pub fn enqueue(&self, event: PtyInputEvent) -> PtyInputEnqueueResult { let mut state = self.queue.state.lock().unwrap(); if state.closed { @@ -104,64 +199,49 @@ impl PtyInputDispatcher { self.queue.changed.notify_all(); } - /// Wait until every input accepted before this call has completed. - /// This is an ordering barrier for infrequent surface/session mutations. - pub fn flush(&self, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - let mut state = self.queue.state.lock().unwrap(); - while (!state.events.is_empty() || state.in_flight) && Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let (next, _) = self.queue.changed.wait_timeout(state, remaining).unwrap(); - state = next; - } - state.events.is_empty() && !state.in_flight + pub fn enqueue_mutation( + &self, + label: &'static str, + operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, + ) { + self.enqueue_mutation_with_key(label, None, operation); } - /// Stop accepting work and discard queued bytes after an explicit input - /// saturation disconnect. The in-flight request is allowed to return. - pub fn abort(&self) { - let mut state = self.queue.state.lock().unwrap(); - state.closed = true; - state.events.clear(); - state.queued_bytes = 0; - state.release_reservations = 0; - self.queue.changed.notify_all(); + pub fn enqueue_coalescing_mutation( + &self, + label: &'static str, + key: (&'static str, u64), + operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, + ) { + self.enqueue_mutation_with_key(label, Some(key), operation); } - /// Drain queued writes during detach/normal shutdown, bounded so a - /// half-open remote session cannot hang terminal restoration forever. - pub fn shutdown(&mut self, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - let mut state = self.queue.state.lock().unwrap(); - while (!state.events.is_empty() || state.in_flight) && Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let (next, _) = self.queue.changed.wait_timeout(state, remaining).unwrap(); - state = next; - } - let drained = state.events.is_empty() && !state.in_flight; - state.closed = true; - if !drained { - state.events.clear(); - state.queued_bytes = 0; - state.release_reservations = 0; - } - self.queue.changed.notify_all(); - drop(state); - if drained && let Some(worker) = self.worker.take() { - let _ = worker.join(); + fn enqueue_mutation_with_key( + &self, + label: &'static str, + key: Option<(&'static str, u64)>, + operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, + ) { + if self.enqueue(PtyInputEvent::mutation(label, key, operation)) + != PtyInputEnqueueResult::Accepted + { + (self.on_failure)(PtyOperationFailure { + surface_id: None, + label, + error: "operation queue is full; the session was left unchanged".into(), + }); } - drained } } impl Drop for PtyInputDispatcher { fn drop(&mut self) { - let mut state = self.queue.state.lock().unwrap(); + let mut state = self.sender.queue.state.lock().unwrap(); state.closed = true; state.events.clear(); state.queued_bytes = 0; state.release_reservations = 0; - self.queue.changed.notify_all(); + self.sender.queue.changed.notify_all(); } } @@ -173,6 +253,12 @@ fn enqueue_bounded( capacity: usize, max_bytes: usize, ) -> bool { + if event.coalesce_key.is_some() + && events.back().is_some_and(|previous| previous.coalesce_key == event.coalesce_key) + { + *events.back_mut().unwrap() = event; + return true; + } if event.kind == PtyInputKind::Motion && events.back().is_some_and(|previous| { previous.kind == PtyInputKind::Motion && previous.surface_id == event.surface_id @@ -190,9 +276,9 @@ fn enqueue_bounded( return true; } - let merge_stream = event.kind != PtyInputKind::Motion + let merge_stream = event.kind == PtyInputKind::Ordered && events.back().is_some_and(|previous| { - previous.kind != PtyInputKind::Motion && previous.surface_id == event.surface_id + previous.kind == PtyInputKind::Ordered && previous.surface_id == event.surface_id }); let consumes_reservation = event.kind == PtyInputKind::Release && *release_reservations > 0; let mut projected = events.len() @@ -236,7 +322,7 @@ fn enqueue_bounded( true } -fn worker(queue: Arc) { +fn worker(queue: Arc, on_failure: Arc) { loop { let event = { let mut state = queue.state.lock().unwrap(); @@ -251,7 +337,19 @@ fn worker(queue: Arc) { state.queued_bytes = state.queued_bytes.saturating_sub(event.bytes.len()); event }; - event.surface.write_bytes(&event.bytes); + let surface_id = (event.kind != PtyInputKind::Mutation).then_some(event.surface_id); + let result = if let Some(operation) = event.mutation { + operation() + } else { + event.surface.write_bytes(&event.bytes) + }; + if let Err(error) = result { + on_failure(PtyOperationFailure { + surface_id, + label: event.label, + error: error.to_string(), + }); + } let mut state = queue.state.lock().unwrap(); state.in_flight = false; queue.changed.notify_all(); @@ -263,12 +361,12 @@ mod tests { use super::*; fn event(surface_id: SurfaceId, bytes: u8, kind: PtyInputKind) -> PtyInputEvent { - PtyInputEvent { + PtyInputEvent::input( surface_id, - surface: SurfaceHandle::RemoteBrowserUnsupported, - bytes: SmallVec::from_slice(&[bytes]), + SurfaceHandle::RemoteBrowserUnsupported, + SmallVec::from_slice(&[bytes]), kind, - } + ) } #[test] @@ -316,6 +414,42 @@ mod tests { assert_eq!(events[2].bytes.as_slice(), &[4]); } + #[test] + fn ordered_input_does_not_cross_a_session_mutation() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = 0; + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 1, PtyInputKind::Ordered), + 8, + 1024, + )); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + PtyInputEvent::mutation("close tab", None, || Ok(())), + 8, + 1024, + )); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 2, PtyInputKind::Ordered), + 8, + 1024, + )); + + assert_eq!(events.len(), 3); + assert_eq!(events[0].bytes.as_slice(), &[1]); + assert_eq!(events[1].kind, PtyInputKind::Mutation); + assert_eq!(events[2].bytes.as_slice(), &[2]); + } + #[test] fn accepted_press_guarantees_its_release_slot() { let mut events = VecDeque::new(); @@ -375,7 +509,7 @@ mod tests { #[test] fn shutdown_drains_and_joins_the_worker() { - let mut dispatcher = PtyInputDispatcher::spawn().unwrap(); + let mut dispatcher = PtyInputDispatcher::spawn(|_| {}).unwrap(); assert_eq!( dispatcher.enqueue(event(1, 1, PtyInputKind::Ordered)), PtyInputEnqueueResult::Accepted @@ -394,7 +528,10 @@ mod tests { state.release_reservations = 1; state.in_flight = true; } - let mut dispatcher = PtyInputDispatcher { queue: queue.clone(), worker: None }; + let mut dispatcher = PtyInputDispatcher { + sender: PtyInputSender { queue: queue.clone(), on_failure: Arc::new(|_| {}) }, + worker: None, + }; assert!(!dispatcher.shutdown(Duration::ZERO)); @@ -406,41 +543,70 @@ mod tests { } #[test] - fn accepted_input_completes_before_following_mutation() { - let queue = Arc::new(SharedQueue::default()); - queue.state.lock().unwrap().in_flight = true; - let dispatcher = Arc::new(PtyInputDispatcher { queue: queue.clone(), worker: None }); + fn accepted_operation_completes_before_following_mutation_without_blocking_enqueue() { + let dispatcher = PtyInputDispatcher::spawn(|_| {}).unwrap(); + let sender = dispatcher.sender(); let mutated = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (started_tx, started_rx) = std::sync::mpsc::channel(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let flush_dispatcher = dispatcher; - let mutation = mutated.clone(); - let flush_thread = std::thread::spawn(move || { + let (release_tx, release_rx) = std::sync::mpsc::channel(); + sender.enqueue_mutation("blocking operation", move || { started_tx.send(()).unwrap(); - let flushed = flush_dispatcher.flush(Duration::from_secs(1)); - if flushed { - mutation.store(true, std::sync::atomic::Ordering::Release); - } - done_tx.send(flushed).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }); + let mutation = mutated.clone(); + sender.enqueue_mutation("following mutation", move || { + mutation.store(true, std::sync::atomic::Ordering::Release); + Ok(()) }); started_rx.recv().unwrap(); - assert_eq!(done_rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)); assert!(!mutated.load(std::sync::atomic::Ordering::Acquire)); - { - let mut state = queue.state.lock().unwrap(); - state.in_flight = false; - queue.changed.notify_all(); + release_tx.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while !mutated.load(std::sync::atomic::Ordering::Acquire) && Instant::now() < deadline { + std::thread::yield_now(); } - - assert!(done_rx.recv_timeout(Duration::from_secs(1)).unwrap()); assert!(mutated.load(std::sync::atomic::Ordering::Acquire)); - flush_thread.join().unwrap(); + } + + #[test] + fn operation_failure_is_reported() { + let (failure_tx, failure_rx) = std::sync::mpsc::channel(); + let dispatcher = PtyInputDispatcher::spawn(move |failure| { + failure_tx.send(failure).unwrap(); + }) + .unwrap(); + + dispatcher.sender().enqueue_mutation("close pane", || anyhow::bail!("write failed")); + + let failure = failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(failure.label, "close pane"); + assert_eq!(failure.error, "write failed"); + } + + #[test] + fn pty_write_failure_is_reported_with_its_surface() { + let (failure_tx, failure_rx) = std::sync::mpsc::channel(); + let dispatcher = PtyInputDispatcher::spawn(move |failure| { + failure_tx.send(failure).unwrap(); + }) + .unwrap(); + + assert_eq!( + dispatcher.enqueue(event(42, 1, PtyInputKind::Ordered)), + PtyInputEnqueueResult::Accepted + ); + + let failure = failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(failure.surface_id, Some(42)); + assert_eq!(failure.label, "PTY input"); + assert!(failure.error.contains("browser surface")); } #[test] fn oversized_input_is_distinguished_from_queue_saturation() { - let dispatcher = PtyInputDispatcher::spawn().unwrap(); + let dispatcher = PtyInputDispatcher::spawn(|_| {}).unwrap(); let mut oversized = event(1, 1, PtyInputKind::Ordered); oversized.bytes = vec![1; MAX_QUEUED_BYTES + 1].into(); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 08a04c803b54..e80aa24e6039 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -24,6 +24,7 @@ use serde_json::json; pub use remote::{RemoteSession, RemoteSurface}; pub use tree::{TabNotificationView, TreeView, WorkspaceView}; +#[derive(Clone)] pub enum Session { Local(Arc), Remote(Arc), @@ -479,15 +480,13 @@ impl SurfaceHandle { } } - pub fn write_bytes(&self, bytes: &[u8]) { + pub fn write_bytes(&self, bytes: &[u8]) -> anyhow::Result<()> { match self { - SurfaceHandle::Local(surface) => { - let _ = surface.write_bytes(bytes); - } - SurfaceHandle::Remote(surface, session) => { - session.send_bytes(surface.id, bytes); + SurfaceHandle::Local(surface) => surface.write_bytes(bytes).map_err(Into::into), + SurfaceHandle::Remote(surface, session) => session.send_bytes(surface.id, bytes), + SurfaceHandle::RemoteBrowserUnsupported => { + anyhow::bail!("browser surface does not accept PTY input") } - SurfaceHandle::RemoteBrowserUnsupported => {} } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 52f9af0dc9f0..0b0b46251839 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -439,9 +439,9 @@ impl RemoteSession { } } - pub fn send_bytes(&self, surface: SurfaceId, bytes: &[u8]) { + pub fn send_bytes(&self, surface: SurfaceId, bytes: &[u8]) -> anyhow::Result<()> { let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - let _ = self.request(json!({"cmd": "send", "surface": surface, "bytes": encoded})); + self.request(json!({"cmd": "send", "surface": surface, "bytes": encoded})).map(|_| ()) } pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { From b6c044f9e103dfff961d0fe1fd90c780878a5770 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:58:40 -0700 Subject: [PATCH 017/103] Cover failed PTY mouse press cleanup --- cmux-tui/crates/cmux-tui/src/pty_input.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index f3df8076734f..0519cdc7f2f4 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -604,6 +604,22 @@ mod tests { assert!(failure.error.contains("browser surface")); } + #[test] + fn failed_press_releases_its_reserved_queue_slot() { + let (failure_tx, failure_rx) = std::sync::mpsc::channel(); + let dispatcher = PtyInputDispatcher::spawn(move |failure| { + failure_tx.send(failure).unwrap(); + }) + .unwrap(); + + assert_eq!( + dispatcher.enqueue(event(7, 1, PtyInputKind::Press)), + PtyInputEnqueueResult::Accepted + ); + failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(dispatcher.sender.queue.state.lock().unwrap().release_reservations, 0); + } + #[test] fn oversized_input_is_distinguished_from_queue_saturation() { let dispatcher = PtyInputDispatcher::spawn(|_| {}).unwrap(); From 696c147f979ddcb9e798b9ead5807ce6b4ce215b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:59:02 -0700 Subject: [PATCH 018/103] Close ordered input review gaps --- cmux-tui/crates/cmux-tui/src/app.rs | 81 ++++++++++++++----- cmux-tui/crates/cmux-tui/src/browser_input.rs | 55 +++++++++++-- cmux-tui/crates/cmux-tui/src/pty_input.rs | 26 ++++-- 3 files changed, 128 insertions(+), 34 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index cc676149ac17..f13dc0fee90e 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::io::Write; use std::ops::Deref; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -51,6 +51,7 @@ pub enum AppEvent { Mux(MuxEvent), Input(Event), PtyOperationFailed(PtyOperationFailure), + BrowserTabCreated { pane: Option }, } /// Read access stays synchronous, while every UI-originated session mutation @@ -60,11 +61,12 @@ pub enum AppEvent { pub struct OrderedSession { inner: Session, operations: PtyInputSender, + events: Sender, } impl OrderedSession { - fn new(inner: Session, operations: PtyInputSender) -> Self { - Self { inner, operations } + fn new(inner: Session, operations: PtyInputSender, events: Sender) -> Self { + Self { inner, operations, events } } fn enqueue( @@ -103,6 +105,21 @@ impl OrderedSession { Ok(()) } + pub fn new_browser_tab( + &self, + url: String, + pane: Option, + size: Option<(u16, u16)>, + ) -> anyhow::Result<()> { + let events = self.events.clone(); + self.enqueue("create browser tab", move |session| { + session.new_browser_tab(url, pane, size)?; + let _ = events.send(AppEvent::BrowserTabCreated { pane }); + Ok(()) + }); + Ok(()) + } + pub fn set_cell_pixel_size(&self, width: u16, height: u16) { self.enqueue("set cell pixel size", move |session| { session.set_cell_pixel_size(width, height); @@ -798,7 +815,7 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { let pty_input = PtyInputDispatcher::spawn(move |failure| { let _ = failure_tx.send(AppEvent::PtyOperationFailed(failure)); })?; - let session = OrderedSession::new(session, pty_input.sender()); + let session = OrderedSession::new(session, pty_input.sender(), tx.clone()); let stdout_lock = Arc::new(Mutex::new(())); // Session events → app channel. @@ -1171,7 +1188,7 @@ impl App { && surface.resize_needed(content.width, content.height, false) && self.prepare_pty_input_before_mutation() { - self.session.resize_surface( + self.enqueue_surface_resize( tab.surface, surface, content.width, @@ -1269,14 +1286,23 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::PtyOperationFailed(failure) => { - if failure.surface_id.is_some_and(|surface| { - matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) - }) { + if failure.kind.is_some() + && failure.surface_id.is_some_and(|surface| { + matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) + }) + { self.drag = None; } self.status_message = Some(format!("{} failed: {}", failure.label, failure.error)); Ok(RenderAction::Draw) } + AppEvent::BrowserTabCreated { pane } => { + self.tree = self.session.tree(); + if let Some(pane) = pane.or_else(|| self.active_pane()) { + self.focus_omnibar_with_buffer(pane, String::new(), false); + } + Ok(RenderAction::Draw) + } AppEvent::Input(Event::Key(key)) => { if key.kind != KeyEventKind::Release { self.reassert_visible_surface_sizes(); @@ -1365,7 +1391,7 @@ impl App { } else if self.prepare_pty_input_before_mutation() && let Some(surface_id) = self.sidebar_plugin_surface { - self.session.resize_surface(surface_id, surface, rect.width, rect.height, true); + self.enqueue_surface_resize(surface_id, surface, rect.width, rect.height, true); } } } @@ -1386,7 +1412,7 @@ impl App { if !needs_barrier { surface.reassert_size(area.content.width, area.content.height); } else if self.prepare_pty_input_before_mutation() { - self.session.resize_surface( + self.enqueue_surface_resize( area.surface, surface, area.content.width, @@ -1405,6 +1431,25 @@ impl App { } } + fn enqueue_surface_resize( + &mut self, + surface_id: SurfaceId, + surface: SurfaceHandle, + cols: u16, + rows: u16, + reassert: bool, + ) { + if surface.kind() == SurfaceKind::Browser { + self.browser_input.enqueue(BrowserInputEvent { + surface_id, + surface, + kind: BrowserInputKind::Resize { cols, rows, reassert }, + }); + } else { + self.session.resize_surface(surface_id, surface, cols, rows, reassert); + } + } + pub fn tab_drag(&self) -> Option { match self.drag { Some(Drag::Tab { surface, target }) => Some(TabDragView { surface, target }), @@ -1513,7 +1558,11 @@ impl App { } if self.sidebar_focused { self.forward_sidebar_key(&key); - return Ok(RenderAction::None); + return Ok(if self.status_message.is_some() { + RenderAction::Draw + } else { + RenderAction::None + }); } if let Some(action) = self.config.keys.modeless_action_for(&key) { return self.run_action(action); @@ -1521,7 +1570,7 @@ impl App { // Typing replaces any selection highlight. self.selection = None; self.forward_key(&key); - Ok(RenderAction::None) + Ok(if self.status_message.is_some() { RenderAction::Draw } else { RenderAction::None }) } /// Commit the open rename dialog (Enter or the OK button). @@ -1866,11 +1915,6 @@ impl App { pane, self.browser_tab_size_hint(pane), )?; - self.tree = self.session.tree(); - let target_pane = pane.or_else(|| self.active_pane()); - if let Some(pane) = target_pane { - self.focus_omnibar_with_buffer(pane, String::new(), false); - } Ok(()) } @@ -4030,7 +4074,8 @@ mod tests { fn test_app(session: Session) -> App { let pty_input = PtyInputDispatcher::spawn(|_| {}).unwrap(); - let session = OrderedSession::new(session, pty_input.sender()); + let (events, _) = std::sync::mpsc::channel(); + let session = OrderedSession::new(session, pty_input.sender(), events); App { session, config: Config::default(), diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index d7485cbcabc6..6aebe2409319 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -59,6 +59,11 @@ pub enum BrowserInputKind { text: Option<&'static str>, }, InsertText(String), + Resize { + cols: u16, + rows: u16, + reassert: bool, + }, } impl BrowserInputKind { @@ -67,6 +72,10 @@ impl BrowserInputKind { fn is_mouse_move(&self) -> bool { matches!(self, BrowserInputKind::Mouse { event_type: "mouseMoved", .. }) } + + fn is_resize(&self) -> bool { + matches!(self, BrowserInputKind::Resize { .. }) + } } pub struct BrowserInputDispatcher { @@ -95,7 +104,7 @@ fn worker(rx: Receiver) { while let Ok(next) = rx.try_recv() { batch.push(next); } - coalesce_mouse_moves(&mut batch); + coalesce_browser_events(&mut batch); for event in batch { dispatch(&event); } @@ -105,12 +114,14 @@ fn worker(rx: Receiver) { /// Drop a mouse move when the next event is also a mouse move on the /// same surface: only the final position of a consecutive run is /// forwarded. Clicks, keys, and wheel events keep their order. -fn coalesce_mouse_moves(batch: &mut Vec) { +fn coalesce_browser_events(batch: &mut Vec) { let mut index = 0; while index + 1 < batch.len() { - let drop_current = batch[index].kind.is_mouse_move() - && batch[index + 1].kind.is_mouse_move() - && batch[index].surface_id == batch[index + 1].surface_id; + let same_coalescing_kind = (batch[index].kind.is_mouse_move() + && batch[index + 1].kind.is_mouse_move()) + || (batch[index].kind.is_resize() && batch[index + 1].kind.is_resize()); + let drop_current = + same_coalescing_kind && batch[index].surface_id == batch[index + 1].surface_id; if drop_current { batch.remove(index); } else { @@ -142,6 +153,14 @@ fn dispatch(event: &BrowserInputEvent) { *text, ), BrowserInputKind::InsertText(text) => surface.browser_insert_text(text), + BrowserInputKind::Resize { cols, rows, reassert } => { + if *reassert { + surface.reassert_size(*cols, *rows); + } else { + surface.resize(*cols, *rows); + } + Ok(()) + } }; } @@ -177,6 +196,14 @@ mod tests { } } + fn resize_event(surface: SurfaceId, cols: u16) -> BrowserInputEvent { + BrowserInputEvent { + surface_id: surface, + surface: SurfaceHandle::RemoteBrowserUnsupported, + kind: BrowserInputKind::Resize { cols, rows: 24, reassert: false }, + } + } + fn positions(batch: &[BrowserInputEvent]) -> Vec<(&'static str, SurfaceId)> { batch .iter() @@ -190,7 +217,7 @@ mod tests { #[test] fn consecutive_moves_on_same_surface_keep_latest_only() { let mut batch = vec![move_event(1, 1.0), move_event(1, 2.0), move_event(1, 3.0)]; - coalesce_mouse_moves(&mut batch); + coalesce_browser_events(&mut batch); assert_eq!(batch.len(), 1); match batch[0].kind { BrowserInputKind::Mouse { x, .. } => assert_eq!(x, 3.0), @@ -201,7 +228,7 @@ mod tests { #[test] fn clicks_break_coalescing_and_keep_order() { let mut batch = vec![move_event(1, 1.0), click_event(1), move_event(1, 2.0)]; - coalesce_mouse_moves(&mut batch); + coalesce_browser_events(&mut batch); assert_eq!( positions(&batch), vec![("mouseMoved", 1), ("mousePressed", 1), ("mouseMoved", 1)] @@ -211,7 +238,19 @@ mod tests { #[test] fn moves_on_different_surfaces_are_kept() { let mut batch = vec![move_event(1, 1.0), move_event(2, 1.0)]; - coalesce_mouse_moves(&mut batch); + coalesce_browser_events(&mut batch); + assert_eq!(batch.len(), 2); + } + + #[test] + fn consecutive_resizes_keep_latest_without_crossing_clicks() { + let mut batch = vec![resize_event(1, 80), resize_event(1, 100), click_event(1)]; + coalesce_browser_events(&mut batch); assert_eq!(batch.len(), 2); + match batch[0].kind { + BrowserInputKind::Resize { cols, .. } => assert_eq!(cols, 100), + _ => panic!("expected resize event"), + } + assert!(matches!(batch[1].kind, BrowserInputKind::Mouse { .. })); } } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 0519cdc7f2f4..b6f5951e593f 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -85,6 +85,7 @@ impl PtyInputEvent { #[derive(Debug, Clone)] pub struct PtyOperationFailure { pub surface_id: Option, + pub kind: Option, pub label: &'static str, pub error: String, } @@ -227,6 +228,7 @@ impl PtyInputSender { { (self.on_failure)(PtyOperationFailure { surface_id: None, + kind: None, label, error: "operation queue is full; the session was left unchanged".into(), }); @@ -337,22 +339,29 @@ fn worker(queue: Arc, on_failure: Arc Date: Thu, 9 Jul 2026 21:11:26 -0700 Subject: [PATCH 019/103] Cover PTY drag preservation after motion failure --- cmux-tui/crates/cmux-tui/src/app.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index f13dc0fee90e..e0d892fe02d2 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3841,7 +3841,9 @@ mod tests { use crate::browser_input::BrowserInputDispatcher; use crate::config::{Config, ScrollbarPosition}; - use crate::pty_input::{PtyInputDispatcher, PtyInputEnqueueResult}; + use crate::pty_input::{ + PtyInputDispatcher, PtyInputEnqueueResult, PtyInputKind, PtyOperationFailure, + }; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{Session, TreeView}; @@ -4018,6 +4020,29 @@ mod tests { assert!(!app.quit); } + #[test] + fn motion_failure_preserves_pty_drag_for_the_physical_release() { + let mux = Mux::new("motion-failure-drag-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.drag = Some(Drag::PtyMouse { + surface: 42, + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Right, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Motion), + label: "PTY input", + error: "write failed".to_string(), + })) + .unwrap(); + + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); + } + #[test] fn notify_unread_indicators_render_and_clear() { let mux = Mux::new( From 83e000d0869b75c717250c5e50980f9d396003dd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:11:41 -0700 Subject: [PATCH 020/103] Bound remote PTY operation failures --- cmux-tui/crates/cmux-tui/src/app.rs | 22 ++++- cmux-tui/crates/cmux-tui/src/pty_input.rs | 91 ++++++++++++++++++--- cmux-tui/crates/cmux-tui/src/session/mod.rs | 4 + 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index e0d892fe02d2..6226b747fb90 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -62,11 +62,13 @@ pub struct OrderedSession { inner: Session, operations: PtyInputSender, events: Sender, + remote: bool, } impl OrderedSession { fn new(inner: Session, operations: PtyInputSender, events: Sender) -> Self { - Self { inner, operations, events } + let remote = matches!(inner, Session::Remote(_)); + Self { inner, operations, events, remote } } fn enqueue( @@ -75,7 +77,7 @@ impl OrderedSession { operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, ) { let session = self.inner.clone(); - self.operations.enqueue_mutation(label, move || operation(session)); + self.operations.enqueue_session_mutation(label, self.remote, move || operation(session)); } fn resize_surface( @@ -89,6 +91,7 @@ impl OrderedSession { self.operations.enqueue_coalescing_mutation( "resize PTY surface", ("surface resize", surface_id), + self.remote, move || { if reassert { surface.reassert_size(cols, rows); @@ -184,6 +187,7 @@ impl OrderedSession { self.operations.enqueue_coalescing_mutation( "resize pane split", (direction, pane), + self.remote, move || { session.set_ratio(pane, dir, ratio); Ok(()) @@ -1286,7 +1290,7 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::PtyOperationFailed(failure) => { - if failure.kind.is_some() + if failure.kind == Some(PtyInputKind::Press) && failure.surface_id.is_some_and(|surface| { matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) }) @@ -2796,6 +2800,11 @@ impl App { Some("PTY input queue is full; input was not sent".to_string()); false } + PtyInputEnqueueResult::Failed => { + self.status_message = + Some("PTY input is unavailable after a transport failure".to_string()); + false + } } } @@ -4018,6 +4027,13 @@ mod tests { Some("PTY input queue is full; input was not sent") ); assert!(!app.quit); + + assert!(!app.handle_pty_enqueue_result(PtyInputEnqueueResult::Failed)); + assert_eq!( + app.status_message.as_deref(), + Some("PTY input is unavailable after a transport failure") + ); + assert!(!app.quit); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index b6f5951e593f..7d30bd3df382 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -35,6 +35,7 @@ pub enum PtyInputEnqueueResult { Accepted, Oversized, Saturated, + Failed, } pub struct PtyInputEvent { @@ -45,6 +46,7 @@ pub struct PtyInputEvent { mutation: Option anyhow::Result<()> + Send>>, label: &'static str, coalesce_key: Option<(&'static str, u64)>, + remote: bool, } impl PtyInputEvent { @@ -54,6 +56,7 @@ impl PtyInputEvent { bytes: PtyInputBytes, kind: PtyInputKind, ) -> Self { + let remote = surface.is_remote(); Self { surface_id, surface, @@ -62,12 +65,14 @@ impl PtyInputEvent { mutation: None, label: "PTY input", coalesce_key: None, + remote, } } fn mutation( label: &'static str, coalesce_key: Option<(&'static str, u64)>, + remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) -> Self { Self { @@ -78,6 +83,7 @@ impl PtyInputEvent { mutation: Some(Box::new(operation)), label, coalesce_key, + remote, } } } @@ -97,6 +103,7 @@ struct QueueState { release_reservations: usize, in_flight: bool, closed: bool, + remote_failed: bool, } #[derive(Default)] @@ -174,6 +181,9 @@ impl PtyInputSender { if state.closed { return PtyInputEnqueueResult::Saturated; } + if state.remote_failed && event.remote { + return PtyInputEnqueueResult::Failed; + } if event.bytes.len() > MAX_QUEUED_BYTES { return PtyInputEnqueueResult::Oversized; } @@ -200,37 +210,45 @@ impl PtyInputSender { self.queue.changed.notify_all(); } - pub fn enqueue_mutation( + pub fn enqueue_session_mutation( &self, label: &'static str, + remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) { - self.enqueue_mutation_with_key(label, None, operation); + self.enqueue_mutation_with_key(label, None, remote, operation); } pub fn enqueue_coalescing_mutation( &self, label: &'static str, key: (&'static str, u64), + remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) { - self.enqueue_mutation_with_key(label, Some(key), operation); + self.enqueue_mutation_with_key(label, Some(key), remote, operation); } fn enqueue_mutation_with_key( &self, label: &'static str, key: Option<(&'static str, u64)>, + remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) { - if self.enqueue(PtyInputEvent::mutation(label, key, operation)) - != PtyInputEnqueueResult::Accepted - { + let result = self.enqueue(PtyInputEvent::mutation(label, key, remote, operation)); + if result != PtyInputEnqueueResult::Accepted { (self.on_failure)(PtyOperationFailure { surface_id: None, kind: None, label, - error: "operation queue is full; the session was left unchanged".into(), + error: match result { + PtyInputEnqueueResult::Failed => { + "remote operation lane is unavailable after a transport failure" + } + _ => "operation queue is full; the session was left unchanged", + } + .into(), }); } } @@ -341,6 +359,7 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc bool { + matches!(self, SurfaceHandle::Remote(_, _)) + } + pub fn kind(&self) -> SurfaceKind { match self { SurfaceHandle::Local(surface) => surface.kind(), From 3b53a4e709af4306b25942f5598d0033561d1bce Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:25:55 -0700 Subject: [PATCH 021/103] Complete ordered session failure handling --- cmux-tui/crates/cmux-tui/src/app.rs | 251 ++++++++++++------ cmux-tui/crates/cmux-tui/src/browser_input.rs | 5 +- cmux-tui/crates/cmux-tui/src/pty_input.rs | 46 +++- cmux-tui/crates/cmux-tui/src/session/mod.rs | 219 +++++++++------ .../crates/cmux-tui/src/session/remote.rs | 45 +++- 5 files changed, 395 insertions(+), 171 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 6226b747fb90..ffe7b4ac437c 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -6,9 +6,10 @@ //! snapshots, prefix arming, the current layout, hit map, selection, and //! menu/prompt overlays). -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::io::Write; use std::ops::Deref; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -47,11 +48,24 @@ use crate::ui::graphics_writer::GraphicsWriter; use crate::ui::input::{InputEvent, TextInput}; use crate::ui::thumb_geometry; +const DEFERRED_INPUT_CAPACITY: usize = 512; + pub enum AppEvent { Mux(MuxEvent), Input(Event), PtyOperationFailed(PtyOperationFailure), BrowserTabCreated { pane: Option }, + SessionMutationSettled, +} + +struct PendingSessionMutation { + events: Sender, +} + +impl Drop for PendingSessionMutation { + fn drop(&mut self) { + let _ = self.events.send(AppEvent::SessionMutationSettled); + } } /// Read access stays synchronous, while every UI-originated session mutation @@ -63,12 +77,30 @@ pub struct OrderedSession { operations: PtyInputSender, events: Sender, remote: bool, + pending_mutations: Arc, } impl OrderedSession { fn new(inner: Session, operations: PtyInputSender, events: Sender) -> Self { let remote = matches!(inner, Session::Remote(_)); - Self { inner, operations, events, remote } + Self { inner, operations, events, remote, pending_mutations: Arc::new(AtomicUsize::new(0)) } + } + + fn pending_mutation(&self) -> PendingSessionMutation { + self.pending_mutations.fetch_add(1, Ordering::AcqRel); + PendingSessionMutation { events: self.events.clone() } + } + + fn has_pending_mutations(&self) -> bool { + self.pending_mutations.load(Ordering::Acquire) > 0 + } + + fn settle_pending_mutation(&self) { + let result = + self.pending_mutations.fetch_update(Ordering::AcqRel, Ordering::Acquire, |pending| { + pending.checked_sub(1) + }); + debug_assert!(result.is_ok(), "session mutation completion without a pending operation"); } fn enqueue( @@ -77,7 +109,11 @@ impl OrderedSession { operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, ) { let session = self.inner.clone(); - self.operations.enqueue_session_mutation(label, self.remote, move || operation(session)); + let pending = self.pending_mutation(); + self.operations.enqueue_session_mutation(label, self.remote, move || { + let _pending = pending; + operation(session) + }); } fn resize_surface( @@ -88,15 +124,17 @@ impl OrderedSession { rows: u16, reassert: bool, ) { + let pending = self.pending_mutation(); self.operations.enqueue_coalescing_mutation( "resize PTY surface", ("surface resize", surface_id), self.remote, move || { + let _pending = pending; if reassert { - surface.reassert_size(cols, rows); + surface.reassert_size(cols, rows)?; } else { - surface.resize(cols, rows); + surface.resize(cols, rows)?; } Ok(()) }, @@ -125,8 +163,7 @@ impl OrderedSession { pub fn set_cell_pixel_size(&self, width: u16, height: u16) { self.enqueue("set cell pixel size", move |session| { - session.set_cell_pixel_size(width, height); - Ok(()) + session.set_cell_pixel_size(width, height) }); } @@ -141,31 +178,19 @@ impl OrderedSession { } pub fn close_screen(&self, screen: cmux_tui_core::ScreenId) { - self.enqueue("close screen", move |session| { - session.close_screen(screen); - Ok(()) - }); + self.enqueue("close screen", move |session| session.close_screen(screen)); } pub fn rename_screen(&self, screen: cmux_tui_core::ScreenId, name: String) { - self.enqueue("rename screen", move |session| { - session.rename_screen(screen, name); - Ok(()) - }); + self.enqueue("rename screen", move |session| session.rename_screen(screen, name)); } pub fn select_screen(&self, index: Option, delta: Option) { - self.enqueue("select screen", move |session| { - session.select_screen(index, delta); - Ok(()) - }); + self.enqueue("select screen", move |session| session.select_screen(index, delta)); } pub fn zoom_pane(&self, pane: Option) { - self.enqueue("zoom pane", move |session| { - session.zoom_pane(pane); - Ok(()) - }); + self.enqueue("zoom pane", move |session| session.zoom_pane(pane)); } pub fn split( @@ -184,92 +209,60 @@ impl OrderedSession { SplitDir::Right => "horizontal split ratio", SplitDir::Down => "vertical split ratio", }; + let pending = self.pending_mutation(); self.operations.enqueue_coalescing_mutation( "resize pane split", (direction, pane), self.remote, move || { - session.set_ratio(pane, dir, ratio); - Ok(()) + let _pending = pending; + session.set_ratio(pane, dir, ratio) }, ); } pub fn close_surface(&self, surface: SurfaceId) { - self.enqueue("close tab", move |session| { - session.close_surface(surface); - Ok(()) - }); + self.enqueue("close tab", move |session| session.close_surface(surface)); } pub fn close_pane(&self, pane: PaneId) { - self.enqueue("close pane", move |session| { - session.close_pane(pane); - Ok(()) - }); + self.enqueue("close pane", move |session| session.close_pane(pane)); } pub fn swap_pane(&self, pane: PaneId, target: PaneId) { - self.enqueue("swap panes", move |session| { - session.swap_pane(pane, target); - Ok(()) - }); + self.enqueue("swap panes", move |session| session.swap_pane(pane, target)); } pub fn close_workspace(&self, workspace: WorkspaceId) { - self.enqueue("close workspace", move |session| { - session.close_workspace(workspace); - Ok(()) - }); + self.enqueue("close workspace", move |session| session.close_workspace(workspace)); } pub fn rename_surface(&self, surface: SurfaceId, name: String) { - self.enqueue("rename tab", move |session| { - session.rename_surface(surface, name); - Ok(()) - }); + self.enqueue("rename tab", move |session| session.rename_surface(surface, name)); } pub fn rename_workspace(&self, workspace: WorkspaceId, name: String) { - self.enqueue("rename workspace", move |session| { - session.rename_workspace(workspace, name); - Ok(()) - }); + self.enqueue("rename workspace", move |session| session.rename_workspace(workspace, name)); } pub fn focus_pane(&self, pane: PaneId) { - self.enqueue("focus pane", move |session| { - session.focus_pane(pane); - Ok(()) - }); + self.enqueue("focus pane", move |session| session.focus_pane(pane)); } pub fn select_tab(&self, pane: Option, index: Option, delta: Option) { - self.enqueue("select tab", move |session| { - session.select_tab(pane, index, delta); - Ok(()) - }); + self.enqueue("select tab", move |session| session.select_tab(pane, index, delta)); } pub fn select_workspace(&self, index: Option, delta: Option) { - self.enqueue("select workspace", move |session| { - session.select_workspace(index, delta); - Ok(()) - }); + self.enqueue("select workspace", move |session| session.select_workspace(index, delta)); } pub fn move_tab(&self, surface: SurfaceId, pane: PaneId, index: usize) { - self.enqueue("move tab", move |session| { - session.move_tab(surface, pane, index); - Ok(()) - }); + self.enqueue("move tab", move |session| session.move_tab(surface, pane, index)); } pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) { - self.enqueue("move workspace", move |session| { - session.move_workspace(workspace, index); - Ok(()) - }); + self.enqueue("move workspace", move |session| session.move_workspace(workspace, index)); } } @@ -666,6 +659,7 @@ pub struct App { /// never run on the event-loop thread (see `browser_input`). browser_input: BrowserInputDispatcher, pty_input: PtyInputDispatcher, + deferred_input: VecDeque, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -922,6 +916,7 @@ pub fn run(session: Session, session_label: String) -> anyhow::Result<()> { last_browser_hover: None, browser_input, pty_input, + deferred_input: VecDeque::new(), drag: None, encoder, mouse_encoder, @@ -1238,6 +1233,14 @@ impl App { } fn handle(&mut self, event: AppEvent) -> anyhow::Result { + let event = match event { + AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) + if self.session.has_pending_mutations() => + { + return Ok(self.defer_input(input)); + } + event => event, + }; match event { AppEvent::Mux(MuxEvent::Empty) => { self.quit = true; @@ -1290,11 +1293,11 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::PtyOperationFailed(failure) => { - if failure.kind == Some(PtyInputKind::Press) + let failed_active_press = failure.kind == Some(PtyInputKind::Press) && failure.surface_id.is_some_and(|surface| { matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) - }) - { + }); + if failed_active_press || failure.lane_failed { self.drag = None; } self.status_message = Some(format!("{} failed: {}", failure.label, failure.error)); @@ -1307,6 +1310,19 @@ impl App { } Ok(RenderAction::Draw) } + AppEvent::SessionMutationSettled => { + self.session.settle_pending_mutation(); + if self.session.has_pending_mutations() { + return Ok(RenderAction::None); + } + self.tree = self.session.tree(); + let mut action = RenderAction::Draw; + while !self.session.has_pending_mutations() { + let Some(input) = self.deferred_input.pop_front() else { break }; + action = action.merge(self.handle(AppEvent::Input(input))?); + } + Ok(action) + } AppEvent::Input(Event::Key(key)) => { if key.kind != KeyEventKind::Release { self.reassert_visible_surface_sizes(); @@ -1360,6 +1376,20 @@ impl App { } } + fn defer_input(&mut self, input: Event) -> RenderAction { + if self.deferred_input.len() >= DEFERRED_INPUT_CAPACITY { + if matches!(&input, Event::Mouse(MouseEvent { kind: MouseEventKind::Up(_), .. })) { + self.deferred_input.pop_front(); + } else { + self.status_message = + Some("Input queue is full while a session change is pending".to_string()); + return RenderAction::Draw; + } + } + self.deferred_input.push_back(input); + RenderAction::None + } + fn active_pane(&self) -> Option { self.tree.active_screen().map(|screen| screen.active_pane) } @@ -1391,7 +1421,7 @@ impl App { if rect.width > 0 && rect.height > 0 { let needs_barrier = surface.resize_needed(rect.width, rect.height, true); if !needs_barrier { - surface.reassert_size(rect.width, rect.height); + let _ = surface.reassert_size(rect.width, rect.height); } else if self.prepare_pty_input_before_mutation() && let Some(surface_id) = self.sidebar_plugin_surface { @@ -1414,7 +1444,7 @@ impl App { let needs_barrier = surface.resize_needed(area.content.width, area.content.height, true); if !needs_barrier { - surface.reassert_size(area.content.width, area.content.height); + let _ = surface.reassert_size(area.content.width, area.content.height); } else if self.prepare_pty_input_before_mutation() { self.enqueue_surface_resize( area.surface, @@ -3839,11 +3869,15 @@ mod tests { App, AppEvent, Drag, OrderedSession, PaneArea, browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; - use std::collections::HashMap; + use std::collections::{HashMap, VecDeque}; + use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; + use std::time::Duration; use cmux_tui_core::{BrowserStatus, Mux, Node, Rect, SurfaceKind, SurfaceOptions}; - use crossterm::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }; use ghostty_vt::{KeyEncoder, MouseEncoder, RenderState}; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -4053,12 +4087,65 @@ mod tests { kind: Some(PtyInputKind::Motion), label: "PTY input", error: "write failed".to_string(), + lane_failed: false, })) .unwrap(); assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); } + #[test] + fn lane_failure_clears_pty_drag_after_nonpress_failure() { + let mux = Mux::new("lane-failure-drag-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.drag = Some(Drag::PtyMouse { + surface: 42, + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Right, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Motion), + label: "PTY input", + error: "remote session did not respond".to_string(), + lane_failed: true, + })) + .unwrap(); + + assert!(app.drag.is_none()); + } + + #[test] + fn input_waits_for_prior_session_mutation_to_settle() { + let mux = Mux::new("pending-mutation-input-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session.enqueue("blocking selection", move |_| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + + assert_eq!(app.deferred_input.len(), 1); + release_tx.send(()).unwrap(); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!(settled, AppEvent::SessionMutationSettled)); + app.handle(settled).unwrap(); + assert!(app.deferred_input.is_empty()); + assert!(!app.session.has_pending_mutations()); + } + #[test] fn notify_unread_indicators_render_and_clear() { let mux = Mux::new( @@ -4114,10 +4201,14 @@ mod tests { } fn test_app(session: Session) -> App { + test_app_with_events(session).0 + } + + fn test_app_with_events(session: Session) -> (App, Receiver) { let pty_input = PtyInputDispatcher::spawn(|_| {}).unwrap(); - let (events, _) = std::sync::mpsc::channel(); + let (events, receiver) = std::sync::mpsc::channel(); let session = OrderedSession::new(session, pty_input.sender(), events); - App { + let app = App { session, config: Config::default(), tree: TreeView::default(), @@ -4151,12 +4242,14 @@ mod tests { last_browser_hover: None, browser_input: BrowserInputDispatcher::spawn().unwrap(), pty_input, + deferred_input: VecDeque::new(), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), encode_buf: Vec::new(), quit: false, - } + }; + (app, receiver) } fn notify_tree(surface: u64, unread: bool) -> TreeView { diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 6aebe2409319..7e11f70de636 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -155,11 +155,10 @@ fn dispatch(event: &BrowserInputEvent) { BrowserInputKind::InsertText(text) => surface.browser_insert_text(text), BrowserInputKind::Resize { cols, rows, reassert } => { if *reassert { - surface.reassert_size(*cols, *rows); + surface.reassert_size(*cols, *rows) } else { - surface.resize(*cols, *rows); + surface.resize(*cols, *rows) } - Ok(()) } }; } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 7d30bd3df382..0824ade23b8f 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use cmux_tui_core::SurfaceId; use smallvec::SmallVec; -use crate::session::SurfaceHandle; +use crate::session::{SurfaceHandle, is_remote_transport_failure}; const QUEUE_CAPACITY: usize = 512; const MAX_QUEUED_BYTES: usize = 4 * 1024 * 1024; @@ -94,6 +94,7 @@ pub struct PtyOperationFailure { pub kind: Option, pub label: &'static str, pub error: String, + pub lane_failed: bool, } #[derive(Default)] @@ -249,6 +250,7 @@ impl PtyInputSender { _ => "operation queue is full; the session was left unchanged", } .into(), + lane_failed: result == PtyInputEnqueueResult::Failed, }); } } @@ -365,18 +367,21 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc), } +pub(crate) fn is_remote_transport_failure(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(remote::RemoteRequestError::is_transport_failure) +} + +#[cfg(test)] +pub(crate) fn test_remote_timeout_error() -> anyhow::Error { + remote::RemoteRequestError::Timeout.into() +} + +#[cfg(test)] +pub(crate) fn test_remote_rejected_error() -> anyhow::Error { + remote::RemoteRequestError::Rejected("unknown surface".to_string()).into() +} + pub struct SidebarPluginSurface { pub surface_id: Option, pub error: Option, @@ -233,9 +249,12 @@ impl Session { } } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux.set_cell_pixel_size(width_px, height_px), + Session::Local(mux) => { + mux.set_cell_pixel_size(width_px, height_px); + Ok(()) + } Session::Remote(remote) => remote.set_cell_pixel_size(width_px, height_px), } } @@ -259,47 +278,51 @@ impl Session { } } - pub fn close_screen(&self, screen: ScreenId) { + pub fn close_screen(&self, screen: ScreenId) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.close_screen(screen); + Ok(()) } Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "close-screen", "screen": screen})); + remote.request(json!({"cmd": "close-screen", "screen": screen})).map(|_| ()) } } } - pub fn rename_screen(&self, screen: ScreenId, name: String) { + pub fn rename_screen(&self, screen: ScreenId, name: String) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.rename_screen(screen, name); + Ok(()) } - Session::Remote(remote) => { - let _ = - remote.request(json!({"cmd": "rename-screen", "screen": screen, "name": name})); - } + Session::Remote(remote) => remote + .request(json!({"cmd": "rename-screen", "screen": screen, "name": name})) + .map(|_| ()), } } - pub fn select_screen(&self, index: Option, delta: Option) { + pub fn select_screen(&self, index: Option, delta: Option) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux.select_screen(index, delta), - Session::Remote(remote) => { - let _ = - remote.request(json!({"cmd": "select-screen", "index": index, "delta": delta})); + Session::Local(mux) => { + mux.select_screen(index, delta); + Ok(()) } + Session::Remote(remote) => remote + .request(json!({"cmd": "select-screen", "index": index, "delta": delta})) + .map(|_| ()), } } - pub fn zoom_pane(&self, pane: Option) { + pub fn zoom_pane(&self, pane: Option) -> anyhow::Result<()> { match self { Session::Local(mux) => { let _ = mux.zoom_pane(pane, ZoomMode::Toggle); + Ok(()) } - Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "zoom-pane", "pane": pane, "mode": "toggle"})); - } + Session::Remote(remote) => remote + .request(json!({"cmd": "zoom-pane", "pane": pane, "mode": "toggle"})) + .map(|_| ()), } } @@ -323,84 +346,97 @@ impl Session { } } - pub fn set_ratio(&self, pane: PaneId, dir: SplitDir, ratio: f32) { + pub fn set_ratio(&self, pane: PaneId, dir: SplitDir, ratio: f32) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.set_ratio(pane, dir, ratio); + Ok(()) } Session::Remote(remote) => { let dir = match dir { SplitDir::Right => "right", SplitDir::Down => "down", }; - let _ = remote - .request(json!({"cmd": "set-ratio", "pane": pane, "dir": dir, "ratio": ratio})); + remote + .request(json!({"cmd": "set-ratio", "pane": pane, "dir": dir, "ratio": ratio})) + .map(|_| ()) } } } - pub fn close_surface(&self, surface: SurfaceId) { + pub fn close_surface(&self, surface: SurfaceId) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux.close_surface(surface), + Session::Local(mux) => { + mux.close_surface(surface); + Ok(()) + } Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "close-surface", "surface": surface})); + remote.request(json!({"cmd": "close-surface", "surface": surface})).map(|_| ()) } } } - pub fn close_pane(&self, pane: PaneId) { + pub fn close_pane(&self, pane: PaneId) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux.close_pane(pane), + Session::Local(mux) => { + mux.close_pane(pane); + Ok(()) + } Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "close-pane", "pane": pane})); + remote.request(json!({"cmd": "close-pane", "pane": pane})).map(|_| ()) } } } - pub fn swap_pane(&self, pane: PaneId, target: PaneId) { + pub fn swap_pane(&self, pane: PaneId, target: PaneId) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.swap_panes(pane, target); + Ok(()) } - Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "swap-pane", "pane": pane, "target": target})); - } + Session::Remote(remote) => remote + .request(json!({"cmd": "swap-pane", "pane": pane, "target": target})) + .map(|_| ()), } } - pub fn close_workspace(&self, workspace: WorkspaceId) { + pub fn close_workspace(&self, workspace: WorkspaceId) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.close_workspace(workspace); + Ok(()) } - Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "close-workspace", "workspace": workspace})); - } + Session::Remote(remote) => remote + .request(json!({"cmd": "close-workspace", "workspace": workspace})) + .map(|_| ()), } } - pub fn rename_surface(&self, surface: SurfaceId, name: String) { + pub fn rename_surface(&self, surface: SurfaceId, name: String) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.rename_surface(surface, name); + Ok(()) } - Session::Remote(remote) => { - let _ = remote - .request(json!({"cmd": "rename-surface", "surface": surface, "name": name})); - } + Session::Remote(remote) => remote + .request(json!({"cmd": "rename-surface", "surface": surface, "name": name})) + .map(|_| ()), } } - pub fn rename_workspace(&self, workspace: WorkspaceId, name: String) { + pub fn rename_workspace(&self, workspace: WorkspaceId, name: String) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.rename_workspace(workspace, name); + Ok(()) } - Session::Remote(remote) => { - let _ = remote.request( - json!({"cmd": "rename-workspace", "workspace": workspace, "name": name}), - ); - } + Session::Remote(remote) => remote + .request(json!({ + "cmd": "rename-workspace", + "workspace": workspace, + "name": name + })) + .map(|_| ()), } } @@ -412,61 +448,82 @@ impl Session { } } - pub fn focus_pane(&self, pane: PaneId) { + pub fn focus_pane(&self, pane: PaneId) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.focus_pane(pane); + Ok(()) } Session::Remote(remote) => { - let _ = remote.request(json!({"cmd": "focus-pane", "pane": pane})); + remote.request(json!({"cmd": "focus-pane", "pane": pane})).map(|_| ()) } } } - pub fn select_tab(&self, pane: Option, index: Option, delta: Option) { + pub fn select_tab( + &self, + pane: Option, + index: Option, + delta: Option, + ) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux.select_tab(pane, index, delta), - Session::Remote(remote) => { - let _ = remote.request( - json!({"cmd": "select-tab", "pane": pane, "index": index, "delta": delta}), - ); + Session::Local(mux) => { + mux.select_tab(pane, index, delta); + Ok(()) } + Session::Remote(remote) => remote + .request(json!({ + "cmd": "select-tab", + "pane": pane, + "index": index, + "delta": delta + })) + .map(|_| ()), } } - pub fn select_workspace(&self, index: Option, delta: Option) { + pub fn select_workspace( + &self, + index: Option, + delta: Option, + ) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux.select_workspace(index, delta), - Session::Remote(remote) => { - let _ = remote - .request(json!({"cmd": "select-workspace", "index": index, "delta": delta})); + Session::Local(mux) => { + mux.select_workspace(index, delta); + Ok(()) } + Session::Remote(remote) => remote + .request(json!({"cmd": "select-workspace", "index": index, "delta": delta})) + .map(|_| ()), } } - pub fn move_tab(&self, surface: SurfaceId, pane: PaneId, index: usize) { + pub fn move_tab(&self, surface: SurfaceId, pane: PaneId, index: usize) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.move_tab(surface, pane, index); + Ok(()) } - Session::Remote(remote) => { - let _ = remote.request( - json!({"cmd": "move-tab", "surface": surface, "pane": pane, "index": index}), - ); - } + Session::Remote(remote) => remote + .request(json!({ + "cmd": "move-tab", + "surface": surface, + "pane": pane, + "index": index + })) + .map(|_| ()), } } - pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) { + pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) -> anyhow::Result<()> { match self { Session::Local(mux) => { mux.move_workspace(workspace, index); + Ok(()) } - Session::Remote(remote) => { - let _ = remote.request( - json!({"cmd": "move-workspace", "workspace": workspace, "index": index}), - ); - } + Session::Remote(remote) => remote + .request(json!({"cmd": "move-workspace", "workspace": workspace, "index": index})) + .map(|_| ()), } } } @@ -494,24 +551,26 @@ impl SurfaceHandle { } } - pub fn resize(&self, cols: u16, rows: u16) { + pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> { let desired = (cols.max(1), rows.max(1)); match self { SurfaceHandle::Local(surface) => { let _ = surface.resize(desired.0, desired.1); + Ok(()) } SurfaceHandle::Remote(surface, session) => { if resize_action(desired, surface.asserted_size(), surface.server_size(), false) { - let _ = session.request(json!({ + session.request(json!({ "cmd": "resize-surface", "surface": surface.id, "cols": desired.0, "rows": desired.1, - })); + }))?; surface.set_asserted_size(desired); } + Ok(()) } - SurfaceHandle::RemoteBrowserUnsupported => {} + SurfaceHandle::RemoteBrowserUnsupported => Ok(()), } } @@ -529,24 +588,26 @@ impl SurfaceHandle { } } - pub fn reassert_size(&self, cols: u16, rows: u16) { + pub fn reassert_size(&self, cols: u16, rows: u16) -> anyhow::Result<()> { let desired = (cols.max(1), rows.max(1)); match self { SurfaceHandle::Local(surface) => { let _ = surface.resize(desired.0, desired.1); + Ok(()) } SurfaceHandle::Remote(surface, session) => { if resize_action(desired, surface.asserted_size(), surface.server_size(), true) { - let _ = session.request(json!({ + session.request(json!({ "cmd": "resize-surface", "surface": surface.id, "cols": desired.0, "rows": desired.1, - })); + }))?; } surface.set_asserted_size(desired); + Ok(()) } - SurfaceHandle::RemoteBrowserUnsupported => {} + SurfaceHandle::RemoteBrowserUnsupported => Ok(()), } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 0b0b46251839..9c1ce0bbe2e1 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -21,6 +21,34 @@ use serde_json::{Value, json}; use super::tree::{TreeView, parse_tree}; const SUPPORTED_PROTOCOL_VERSION: u64 = 6; + +#[derive(Debug)] +pub(crate) enum RemoteRequestError { + Encode(serde_json::Error), + Transport(std::io::Error), + Timeout, + Rejected(String), +} + +impl RemoteRequestError { + pub(crate) fn is_transport_failure(&self) -> bool { + matches!(self, Self::Transport(_) | Self::Timeout) + } +} + +impl std::fmt::Display for RemoteRequestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Encode(error) => write!(formatter, "could not encode remote request: {error}"), + Self::Transport(error) => write!(formatter, "remote transport write failed: {error}"), + Self::Timeout => write!(formatter, "remote session did not respond"), + Self::Rejected(error) => write!(formatter, "remote command rejected: {error}"), + } + } +} + +impl std::error::Error for RemoteRequestError {} + #[derive(Clone)] struct RemoteBrowserFrame { frame: BrowserFrame, @@ -411,14 +439,16 @@ impl RemoteSession { pub fn request(&self, mut cmd: Value) -> anyhow::Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); cmd["id"] = json!(id); - let mut line = serde_json::to_vec(&cmd)?; + let mut line = serde_json::to_vec(&cmd) + .map_err(RemoteRequestError::Encode) + .map_err(anyhow::Error::new)?; line.push(b'\n'); let (tx, rx) = channel(); self.pending.lock().unwrap().insert(id, tx); if let Err(err) = self.writer.lock().unwrap().write_all(&line) { self.pending.lock().unwrap().remove(&id); - return Err(err.into()); + return Err(RemoteRequestError::Transport(err).into()); } let response = match rx.recv_timeout(Duration::from_secs(10)) { @@ -428,14 +458,14 @@ impl RemoteSession { // accumulate abandoned senders (and a late response is // not delivered to a receiver nobody holds). self.pending.lock().unwrap().remove(&id); - anyhow::bail!("session did not respond") + return Err(RemoteRequestError::Timeout.into()); } }; if response.get("ok").and_then(|v| v.as_bool()) == Some(true) { Ok(response.get("data").cloned().unwrap_or(Value::Null)) } else { let error = response.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error"); - anyhow::bail!("{error}") + Err(RemoteRequestError::Rejected(error.to_string()).into()) } } @@ -444,12 +474,13 @@ impl RemoteSession { self.request(json!({"cmd": "send", "surface": surface, "bytes": encoded})).map(|_| ()) } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { - let _ = self.request(json!({ + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result<()> { + self.request(json!({ "cmd": "set-cell-pixels", "width_px": width_px, "height_px": height_px, - })); + })) + .map(|_| ()) } pub fn set_default_colors(&self, colors: DefaultColors) -> anyhow::Result<()> { From 5b9b53a4ac9abad94442cab41bf856c1920a4422 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:34:14 -0700 Subject: [PATCH 022/103] Coalesce high-rate mouse updates --- cmux-tui/crates/cmux-tui/src/app.rs | 45 +++++++++++++- cmux-tui/crates/ghostty-vt/src/mouse.rs | 78 ++++++++++++++++++++----- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index ffe7b4ac437c..79a90e6e7ab1 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1235,7 +1235,8 @@ impl App { fn handle(&mut self, event: AppEvent) -> anyhow::Result { let event = match event { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) - if self.session.has_pending_mutations() => + if self.session.has_pending_mutations() + && !self.input_can_update_pending_mutation(&input) => { return Ok(self.defer_input(input)); } @@ -1376,6 +1377,16 @@ impl App { } } + fn input_can_update_pending_mutation(&self, input: &Event) -> bool { + matches!( + (input, &self.drag), + ( + Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left), .. }), + Some(Drag::ResizeSplit { .. }) + ) + ) + } + fn defer_input(&mut self, input: Event) -> RenderAction { if self.deferred_input.len() >= DEFERRED_INPUT_CAPACITY { if matches!(&input, Event::Mouse(MouseEvent { kind: MouseEventKind::Up(_), .. })) { @@ -4000,7 +4011,7 @@ mod tests { assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); app.handle_mouse(event(MouseEventKind::Drag(MouseButton::Left), KeyModifiers::NONE)) .unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<34;5;3M"); + assert!(app.encode_buf.is_empty()); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); @@ -4016,7 +4027,7 @@ mod tests { modifiers: KeyModifiers::NONE, }; app.handle_mouse(moved_event).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<32;5;3M"); + assert!(app.encode_buf.is_empty()); app.handle_mouse(MouseEvent { kind: MouseEventKind::Up(MouseButton::Left), ..moved_event }) .unwrap(); assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); @@ -4146,6 +4157,34 @@ mod tests { assert!(!app.session.has_pending_mutations()); } + #[test] + fn split_drag_updates_the_coalescing_lane_while_a_ratio_is_pending() { + let mux = Mux::new("pending-split-drag-test", SurfaceOptions::default()); + let (mut app, _events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (_release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + app.session.enqueue("blocking ratio", move |_| { + started_tx.send(()).unwrap(); + let _ = release_rx.recv(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + app.drag = Some(Drag::ResizeSplit { + horizontal: Some((1, super::PaneEdge::Right)), + vertical: None, + }); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 20, + row: 5, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + + assert!(app.deferred_input.is_empty()); + } + #[test] fn notify_unread_indicators_render_and_clear() { let mux = Mux::new( diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index d7927c4847ca..531d7855dc0d 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -44,6 +44,8 @@ pub struct MouseInput { pub struct MouseEncoder { encoder: sys::GhosttyMouseEncoder, event: sys::GhosttyMouseEvent, + terminal_state: Option<(usize, [bool; 8])>, + size: Option<((u32, u32), (u32, u32))>, } impl MouseEncoder { @@ -56,13 +58,29 @@ impl MouseEncoder { unsafe { sys::ghostty_mouse_encoder_free(encoder) }; return Err(error); } - Ok(Self { encoder, event }) + let track_last_cell = true; + unsafe { + sys::ghostty_mouse_encoder_setopt( + encoder, + sys::GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL, + &track_last_cell as *const _ as *const c_void, + ); + } + Ok(Self { encoder, event, terminal_state: None, size: None }) } pub fn sync_from_terminal(&mut self, terminal: &Terminal) { + let state = ( + terminal.raw() as usize, + [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016].map(|mode| terminal.mode(mode, false)), + ); + if self.terminal_state == Some(state) { + return; + } unsafe { sys::ghostty_mouse_encoder_setopt_from_terminal(self.encoder, terminal.raw()); } + self.terminal_state = Some(state); } pub fn encode(&mut self, input: MouseInput, out: &mut Vec) -> Result<()> { @@ -84,19 +102,24 @@ impl MouseEncoder { sys::GhosttyMousePosition { x: input.position.0, y: input.position.1 }, ); - let size = sys::GhosttyMouseEncoderSize { - size: size_of::(), - screen_width: input.screen_size.0, - screen_height: input.screen_size.1, - cell_width: input.cell_size.0.max(1), - cell_height: input.cell_size.1.max(1), - ..Default::default() - }; - sys::ghostty_mouse_encoder_setopt( - self.encoder, - sys::GHOSTTY_MOUSE_ENCODER_OPT_SIZE, - &size as *const _ as *const c_void, - ); + let cell_size = (input.cell_size.0.max(1), input.cell_size.1.max(1)); + let size_key = (input.screen_size, cell_size); + if self.size != Some(size_key) { + let size = sys::GhosttyMouseEncoderSize { + size: size_of::(), + screen_width: input.screen_size.0, + screen_height: input.screen_size.1, + cell_width: cell_size.0, + cell_height: cell_size.1, + ..Default::default() + }; + sys::ghostty_mouse_encoder_setopt( + self.encoder, + sys::GHOSTTY_MOUSE_ENCODER_OPT_SIZE, + &size as *const _ as *const c_void, + ); + self.size = Some(size_key); + } sys::ghostty_mouse_encoder_setopt( self.encoder, sys::GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED, @@ -232,4 +255,31 @@ mod tests { assert_eq!(out, b"\x1b[<0;36;40M"); } + + #[test] + fn same_cell_motion_is_suppressed_until_mode_or_geometry_changes() { + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1003h\x1b[?1006h"); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + let mut event = input(MouseAction::Motion, None); + event.position = (36.0, 40.0); + event.screen_size = (640, 384); + event.cell_size = (8, 16); + let mut out = Vec::new(); + + encoder.encode(event, &mut out).unwrap(); + assert_eq!(out, b"\x1b[<35;5;3M"); + + out.clear(); + event.position = (39.0, 47.0); + encoder.sync_from_terminal(&terminal); + encoder.encode(event, &mut out).unwrap(); + assert!(out.is_empty()); + + out.clear(); + event.cell_size = (4, 8); + encoder.encode(event, &mut out).unwrap(); + assert_eq!(out, b"\x1b[<35;10;6M"); + } } From 687c0a9a5312f849f88231bbca31999087ca127e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:45:14 -0700 Subject: [PATCH 023/103] Close mouse queue lifecycle gaps --- cmux-tui/crates/cmux-tui/src/app.rs | 129 ++++++++++++++++++++-- cmux-tui/crates/cmux-tui/src/pty_input.rs | 75 ++++++++++--- 2 files changed, 180 insertions(+), 24 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 79a90e6e7ab1..f4cd5dcaab81 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -614,6 +614,13 @@ enum Drag { ResizeSplit { horizontal: Option<(PaneId, PaneEdge)>, vertical: Option<(PaneId, PaneEdge)> }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PtyMousePressResult { + NotOwned, + Consumed, + Started, +} + pub struct App { pub session: OrderedSession, pub config: Config, @@ -1388,6 +1395,21 @@ impl App { } fn defer_input(&mut self, input: Event) -> RenderAction { + let replace_motion = match (&input, self.deferred_input.back()) { + ( + Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), + Some(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. })), + ) => true, + ( + Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(button), .. }), + Some(Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(previous), .. })), + ) => button == previous, + _ => false, + }; + if replace_motion { + *self.deferred_input.back_mut().unwrap() = input; + return RenderAction::None; + } if self.deferred_input.len() >= DEFERRED_INPUT_CAPACITY { if matches!(&input, Event::Mouse(MouseEvent { kind: MouseEventKind::Up(_), .. })) { self.deferred_input.pop_front(); @@ -2484,7 +2506,8 @@ impl App { mouse.row, MouseButton::Right, mouse.modifiers, - ) { + ) != PtyMousePressResult::NotOwned + { return Ok(RenderAction::Draw); } self.open_context_menu(mouse.column, mouse.row); @@ -2520,7 +2543,8 @@ impl App { mouse.row, MouseButton::Middle, mouse.modifiers, - ) { + ) != PtyMousePressResult::NotOwned + { RenderAction::Draw } else { RenderAction::None @@ -2571,20 +2595,22 @@ impl App { y: u16, button: MouseButton, modifiers: KeyModifiers, - ) -> bool { + ) -> PtyMousePressResult { if modifiers.contains(KeyModifiers::SHIFT) || self.menu.is_some() || self.prompt.is_some() || self.drag.is_some() { - return false; + return PtyMousePressResult::NotOwned; } - let Some(area) = self.pane_area_at(x, y).copied() else { return false }; + let Some(area) = self.pane_area_at(x, y).copied() else { + return PtyMousePressResult::NotOwned; + }; if !area.content.contains(x, y) || self.surface_kind(area.surface) != SurfaceKind::Pty || !self.pty_mouse_tracking(area.surface) { - return false; + return PtyMousePressResult::NotOwned; } if self.active_pane() != Some(area.pane) { @@ -2602,7 +2628,7 @@ impl App { modifiers, true, ) { - return false; + return PtyMousePressResult::Consumed; } self.drag = Some(Drag::PtyMouse { surface: area.surface, @@ -2611,7 +2637,7 @@ impl App { position: (x, y), modifiers, }); - true + PtyMousePressResult::Started } fn forward_pty_mouse_drag( @@ -3184,7 +3210,9 @@ impl App { ); self.drag = Some(Drag::Browser { surface: area.surface, content: area.content }); - } else if self.begin_pty_mouse_drag(x, y, MouseButton::Left, modifiers) { + } else if self.begin_pty_mouse_drag(x, y, MouseButton::Left, modifiers) + != PtyMousePressResult::NotOwned + { return Ok(RenderAction::Draw); } else { if self.active_pane() != Some(area.pane) { @@ -4185,6 +4213,89 @@ mod tests { assert!(app.deferred_input.is_empty()); } + #[test] + fn deferred_pointer_motion_keeps_only_the_latest_sample() { + let mux = Mux::new("deferred-motion-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session.enqueue("blocking mutation", move |_| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + for column in [4, 9] { + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column, + row: 3, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + } + + assert_eq!(app.deferred_input.len(), 1); + assert!(matches!( + app.deferred_input.front(), + Some(Event::Mouse(MouseEvent { column: 9, .. })) + )); + release_tx.send(()).unwrap(); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + app.handle(settled).unwrap(); + } + + #[test] + fn failed_pty_owned_press_does_not_fall_through_to_cmux_mouse_actions() { + let mux = Mux::new( + "failed-owned-press-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]), + ..Default::default() + }, + ); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1000h\x1b[?1006h")); + let mut app = test_app(Session::Local(mux.clone())); + app.tree = app.session.tree(); + let pane = app.tree.active_screen().unwrap().active_pane; + let content = Rect { x: 2, y: 3, width: 20, height: 8 }; + app.pane_areas.push(PaneArea { + pane, + surface: surface.id, + rect: Rect { x: 1, y: 2, width: 23, height: 10 }, + bar: Some(Rect { x: 1, y: 2, width: 23, height: 1 }), + omnibar: None, + content, + track: None, + }); + assert!(app.pty_input.shutdown(Duration::from_secs(1))); + let event = |button| MouseEvent { + kind: MouseEventKind::Down(button), + column: content.x + 4, + row: content.y + 2, + modifiers: KeyModifiers::NONE, + }; + + app.handle_mouse(event(MouseButton::Right)).unwrap(); + assert!(app.menu.is_none()); + assert!(app.drag.is_none()); + app.handle_mouse(event(MouseButton::Left)).unwrap(); + assert!(app.selection.is_none()); + assert!(app.drag.is_none()); + assert_eq!( + app.status_message.as_deref(), + Some("PTY input queue is full; input was not sent") + ); + + mux.close_surface(surface.id); + } + #[test] fn notify_unread_indicators_render_and_clear() { let mux = Mux::new( diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 0824ade23b8f..2de0ccad9fd4 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -102,9 +102,16 @@ struct QueueState { events: VecDeque, queued_bytes: usize, release_reservations: usize, - in_flight: bool, + in_flight: Option, closed: bool, remote_failed: bool, + shutdown_release_drain: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InFlightInput { + surface_id: SurfaceId, + kind: PtyInputKind, } #[derive(Default)] @@ -155,17 +162,30 @@ impl PtyInputDispatcher { pub fn shutdown(&mut self, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; let mut state = self.sender.queue.state.lock().unwrap(); - while (!state.events.is_empty() || state.in_flight) && Instant::now() < deadline { + while (!state.events.is_empty() || state.in_flight.is_some()) && Instant::now() < deadline { let remaining = deadline.saturating_duration_since(Instant::now()); let (next, _) = self.sender.queue.changed.wait_timeout(state, remaining).unwrap(); state = next; } - let drained = state.events.is_empty() && !state.in_flight; + let drained = state.events.is_empty() && state.in_flight.is_none(); state.closed = true; if !drained { + let release = state + .in_flight + .filter(|input| input.kind == PtyInputKind::Press) + .and_then(|input| { + state.events.iter().position(|event| { + event.kind == PtyInputKind::Release && event.surface_id == input.surface_id + }) + }) + .and_then(|index| state.events.remove(index)); state.events.clear(); - state.queued_bytes = 0; + state.queued_bytes = release.as_ref().map_or(0, |event| event.bytes.len()); state.release_reservations = 0; + if let Some(release) = release { + state.events.push_back(release); + state.shutdown_release_drain = true; + } } self.sender.queue.changed.notify_all(); drop(state); @@ -260,9 +280,11 @@ impl Drop for PtyInputDispatcher { fn drop(&mut self) { let mut state = self.sender.queue.state.lock().unwrap(); state.closed = true; - state.events.clear(); - state.queued_bytes = 0; - state.release_reservations = 0; + if !state.shutdown_release_drain { + state.events.clear(); + state.queued_bytes = 0; + state.release_reservations = 0; + } self.sender.queue.changed.notify_all(); } } @@ -354,8 +376,9 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc Date: Thu, 9 Jul 2026 21:54:48 -0700 Subject: [PATCH 024/103] Make remote mutation recovery authoritative --- cmux-tui/crates/cmux-tui/src/app.rs | 8 +- cmux-tui/crates/cmux-tui/src/pty_input.rs | 98 +++++++++++++++---- cmux-tui/crates/cmux-tui/src/session/mod.rs | 21 ++++ .../crates/cmux-tui/src/session/remote.rs | 10 +- 4 files changed, 113 insertions(+), 24 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 25d914b12366..82852eea1c8a 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -112,7 +112,9 @@ impl OrderedSession { let pending = self.pending_mutation(); self.operations.enqueue_session_mutation(label, self.remote, move || { let _pending = pending; - operation(session) + operation(session.clone())?; + session.invalidate_remote_tree(); + Ok(()) }); } @@ -216,7 +218,9 @@ impl OrderedSession { self.remote, move || { let _pending = pending; - session.set_ratio(pane, dir, ratio) + session.set_ratio(pane, dir, ratio)?; + session.invalidate_remote_tree(); + Ok(()) }, ); } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 2de0ccad9fd4..c6d845a88ad2 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use cmux_tui_core::SurfaceId; use smallvec::SmallVec; -use crate::session::{SurfaceHandle, is_remote_transport_failure}; +use crate::session::{SurfaceHandle, is_remote_timeout, is_remote_transport_failure}; const QUEUE_CAPACITY: usize = 512; const MAX_QUEUED_BYTES: usize = 4 * 1024 * 1024; @@ -170,20 +170,15 @@ impl PtyInputDispatcher { let drained = state.events.is_empty() && state.in_flight.is_none(); state.closed = true; if !drained { - let release = state - .in_flight - .filter(|input| input.kind == PtyInputKind::Press) - .and_then(|input| { - state.events.iter().position(|event| { - event.kind == PtyInputKind::Release && event.surface_id == input.surface_id - }) - }) - .and_then(|index| state.events.remove(index)); - state.events.clear(); - state.queued_bytes = release.as_ref().map_or(0, |event| event.bytes.len()); + let releases = state + .events + .drain(..) + .filter(|event| event.kind == PtyInputKind::Release) + .collect::>(); + state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); state.release_reservations = 0; - if let Some(release) = release { - state.events.push_back(release); + if !releases.is_empty() { + state.events = releases; state.shutdown_release_drain = true; } } @@ -392,6 +387,7 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc bool { .is_some_and(remote::RemoteRequestError::is_transport_failure) } +pub(crate) fn is_remote_timeout(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(remote::RemoteRequestError::is_timeout) +} + #[cfg(test)] pub(crate) fn test_remote_timeout_error() -> anyhow::Error { remote::RemoteRequestError::Timeout.into() } +#[cfg(test)] +pub(crate) fn test_remote_transport_error() -> anyhow::Error { + remote::RemoteRequestError::Transport(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "socket closed", + )) + .into() +} + #[cfg(test)] pub(crate) fn test_remote_rejected_error() -> anyhow::Error { remote::RemoteRequestError::Rejected("unknown surface".to_string()).into() @@ -87,6 +102,12 @@ pub enum SurfaceHandle { } impl Session { + pub fn invalidate_remote_tree(&self) { + if let Session::Remote(remote) = self { + remote.invalidate_tree(); + } + } + /// Make sure the session has at least one workspace to show. `size` /// is the expected content size of the first pane, when known. pub fn ensure_initial(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 9c1ce0bbe2e1..ecf67a583e91 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -32,7 +32,11 @@ pub(crate) enum RemoteRequestError { impl RemoteRequestError { pub(crate) fn is_transport_failure(&self) -> bool { - matches!(self, Self::Transport(_) | Self::Timeout) + matches!(self, Self::Transport(_)) + } + + pub(crate) fn is_timeout(&self) -> bool { + matches!(self, Self::Timeout) } } @@ -579,6 +583,10 @@ impl RemoteSession { } Ok(tree) } + + pub fn invalidate_tree(&self) { + self.tree_stale.store(true, Ordering::Release); + } } impl Drop for RemoteSession { From 25bb3d044135421c77d9f304ce6c3c7a2a7c4e48 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:02:48 -0700 Subject: [PATCH 025/103] Order browser completion after tree invalidation --- cmux-tui/crates/cmux-tui/src/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 82852eea1c8a..0f67fdbc6e4a 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -157,6 +157,7 @@ impl OrderedSession { let events = self.events.clone(); self.enqueue("create browser tab", move |session| { session.new_browser_tab(url, pane, size)?; + session.invalidate_remote_tree(); let _ = events.send(AppEvent::BrowserTabCreated { pane }); Ok(()) }); @@ -1475,7 +1476,8 @@ impl App { } } } - for area in self.pane_areas.clone() { + for index in 0..self.pane_areas.len() { + let area = self.pane_areas[index]; if area.content.width == 0 || area.content.height == 0 { continue; } From db1b5e512ce338754f8b81707f15d55bf0e1d6c6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:16:02 -0700 Subject: [PATCH 026/103] Refresh remote tree snapshots off loop --- cmux-tui/crates/cmux-tui/src/app.rs | 161 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/session/mod.rs | 25 ++- .../crates/cmux-tui/src/session/remote.rs | 18 +- 3 files changed, 179 insertions(+), 25 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 0f67fdbc6e4a..7ebaf097a2ff 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, VecDeque}; use std::io::Write; use std::ops::Deref; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -55,16 +55,34 @@ pub enum AppEvent { Input(Event), PtyOperationFailed(PtyOperationFailure), BrowserTabCreated { pane: Option }, - SessionMutationSettled, + SessionMutationSettled { tree: Option> }, } struct PendingSessionMutation { events: Sender, + settled: bool, +} + +impl PendingSessionMutation { + fn settle(mut self, tree: Option>) { + let _ = self.events.send(AppEvent::SessionMutationSettled { tree }); + self.settled = true; + } } impl Drop for PendingSessionMutation { fn drop(&mut self) { - let _ = self.events.send(AppEvent::SessionMutationSettled); + if !self.settled { + let _ = self.events.send(AppEvent::SessionMutationSettled { tree: None }); + } + } +} + +struct RemoteRefreshClaim(Arc); + +impl Drop for RemoteRefreshClaim { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); } } @@ -78,17 +96,25 @@ pub struct OrderedSession { events: Sender, remote: bool, pending_mutations: Arc, + remote_refresh_queued: Arc, } impl OrderedSession { fn new(inner: Session, operations: PtyInputSender, events: Sender) -> Self { let remote = matches!(inner, Session::Remote(_)); - Self { inner, operations, events, remote, pending_mutations: Arc::new(AtomicUsize::new(0)) } + Self { + inner, + operations, + events, + remote, + pending_mutations: Arc::new(AtomicUsize::new(0)), + remote_refresh_queued: Arc::new(AtomicBool::new(false)), + } } fn pending_mutation(&self) -> PendingSessionMutation { self.pending_mutations.fetch_add(1, Ordering::AcqRel); - PendingSessionMutation { events: self.events.clone() } + PendingSessionMutation { events: self.events.clone(), settled: false } } fn has_pending_mutations(&self) -> bool { @@ -103,17 +129,55 @@ impl OrderedSession { debug_assert!(result.is_ok(), "session mutation completion without a pending operation"); } + fn refresh_remote_tree_if_stale(&self) { + if !self.inner.take_remote_tree_stale() { + return; + } + if self.remote_refresh_queued.swap(true, Ordering::AcqRel) { + self.inner.invalidate_remote_tree(); + return; + } + self.inner.invalidate_remote_tree(); + let session = self.inner.clone(); + let pending = self.pending_mutation(); + let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); + self.operations.enqueue_session_mutation("refresh remote tree", true, move || { + let tree = session.refresh_tree().map_err(|error| error.to_string()); + drop(claim); + pending.settle(Some(tree)); + Ok(()) + }); + } + + fn remote_tree_is_stale(&self) -> bool { + self.inner.remote_tree_is_stale() + } + fn enqueue( &self, label: &'static str, operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, + ) { + self.enqueue_with_completion(label, operation, None); + } + + fn enqueue_with_completion( + &self, + label: &'static str, + operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, + completion: Option, ) { let session = self.inner.clone(); + let events = self.events.clone(); let pending = self.pending_mutation(); self.operations.enqueue_session_mutation(label, self.remote, move || { - let _pending = pending; operation(session.clone())?; session.invalidate_remote_tree(); + let tree = session.refresh_tree().map_err(|error| error.to_string()); + pending.settle(Some(tree)); + if let Some(completion) = completion { + let _ = events.send(completion); + } Ok(()) }); } @@ -154,13 +218,11 @@ impl OrderedSession { pane: Option, size: Option<(u16, u16)>, ) -> anyhow::Result<()> { - let events = self.events.clone(); - self.enqueue("create browser tab", move |session| { - session.new_browser_tab(url, pane, size)?; - session.invalidate_remote_tree(); - let _ = events.send(AppEvent::BrowserTabCreated { pane }); - Ok(()) - }); + self.enqueue_with_completion( + "create browser tab", + move |session| session.new_browser_tab(url, pane, size), + Some(AppEvent::BrowserTabCreated { pane }), + ); Ok(()) } @@ -218,9 +280,10 @@ impl OrderedSession { (direction, pane), self.remote, move || { - let _pending = pending; session.set_ratio(pane, dir, ratio)?; session.invalidate_remote_tree(); + let tree = session.refresh_tree().map_err(|error| error.to_string()); + pending.settle(Some(tree)); Ok(()) }, ); @@ -1254,6 +1317,19 @@ impl App { } fn handle(&mut self, event: AppEvent) -> anyhow::Result { + match &event { + AppEvent::Mux( + MuxEvent::SurfaceExited(_) + | MuxEvent::TitleChanged(_) + | MuxEvent::Notification(_) + | MuxEvent::TreeChanged + | MuxEvent::LayoutChanged(_), + ) + | AppEvent::Input(Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) => { + self.session.refresh_remote_tree_if_stale(); + } + _ => {} + } let event = match event { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if self.session.has_pending_mutations() @@ -1332,12 +1408,25 @@ impl App { } Ok(RenderAction::Draw) } - AppEvent::SessionMutationSettled => { + AppEvent::SessionMutationSettled { tree } => { self.session.settle_pending_mutation(); - if self.session.has_pending_mutations() { - return Ok(RenderAction::None); + let mut refresh_failed = false; + if let Some(tree) = tree { + match tree { + Ok(tree) => self.tree = tree, + Err(error) => { + refresh_failed = true; + self.status_message = + Some(format!("refresh remote tree failed: {error}")); + } + } + } + if !refresh_failed { + self.session.refresh_remote_tree_if_stale(); + } + if self.session.has_pending_mutations() || self.session.remote_tree_is_stale() { + return Ok(RenderAction::Draw); } - self.tree = self.session.tree(); let mut action = RenderAction::Draw; while !self.session.has_pending_mutations() { let Some(input) = self.deferred_input.pop_front() else { break }; @@ -1404,6 +1493,13 @@ impl App { ( Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left), .. }), Some(Drag::ResizeSplit { .. }) + ) | ( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left), + .. + }), + Some(Drag::Browser { .. }) ) ) } @@ -4194,7 +4290,7 @@ mod tests { assert_eq!(app.deferred_input.len(), 1); release_tx.send(()).unwrap(); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!(settled, AppEvent::SessionMutationSettled)); + assert!(matches!(settled, AppEvent::SessionMutationSettled { .. })); app.handle(settled).unwrap(); assert!(app.deferred_input.is_empty()); assert!(!app.session.has_pending_mutations()); @@ -4261,6 +4357,33 @@ mod tests { app.handle(settled).unwrap(); } + #[test] + fn browser_drag_release_bypasses_a_pending_focus_mutation() { + let mux = Mux::new("browser-release-barrier-test", SurfaceOptions::default()); + let (mut app, _events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (_release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + app.session.enqueue("blocking focus", move |_| { + started_tx.send(()).unwrap(); + let _ = release_rx.recv(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + app.drag = + Some(Drag::Browser { surface: 42, content: Rect { x: 2, y: 3, width: 20, height: 8 } }); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + + assert!(app.deferred_input.is_empty()); + assert!(app.drag.is_none()); + } + #[test] fn failed_pty_owned_press_does_not_fall_through_to_cmux_mouse_actions() { let mux = Mux::new( diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 4dee9ec3ed42..5fe9aeba36c9 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -108,6 +108,27 @@ impl Session { } } + pub fn take_remote_tree_stale(&self) -> bool { + match self { + Session::Local(_) => false, + Session::Remote(remote) => remote.take_tree_stale(), + } + } + + pub fn remote_tree_is_stale(&self) -> bool { + match self { + Session::Local(_) => false, + Session::Remote(remote) => remote.tree_is_stale(), + } + } + + pub fn refresh_tree(&self) -> anyhow::Result { + match self { + Session::Local(_) => Ok(self.tree()), + Session::Remote(remote) => remote.refresh_tree(), + } + } + /// Make sure the session has at least one workspace to show. `size` /// is the expected content size of the first pane, when known. pub fn ensure_initial(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { @@ -117,7 +138,7 @@ impl Session { Ok(()) } Session::Remote(remote) => { - if remote.tree()?.workspaces.is_empty() { + if remote.refresh_tree()?.workspaces.is_empty() { remote.request(with_size(json!({"cmd": "new-workspace"}), size))?; } Ok(()) @@ -200,7 +221,7 @@ impl Session { tree::tree_from_state_with_notifications(state, ¬ifications) }) } - Session::Remote(remote) => remote.tree().unwrap_or_default(), + Session::Remote(remote) => remote.cached_tree(), } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index ecf67a583e91..94ec6bf81913 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -563,10 +563,12 @@ impl RemoteSession { self.tree.lock().unwrap().surface_kind(id) } - pub fn tree(&self) -> anyhow::Result { - if !self.tree_stale.swap(false, Ordering::AcqRel) { - return Ok(self.tree.lock().unwrap().clone()); - } + pub fn cached_tree(&self) -> TreeView { + self.tree.lock().unwrap().clone() + } + + pub fn refresh_tree(&self) -> anyhow::Result { + self.tree_stale.store(false, Ordering::Release); let data = match self.request(json!({"cmd": "list-workspaces"})) { Ok(data) => data, Err(e) => { @@ -587,6 +589,14 @@ impl RemoteSession { pub fn invalidate_tree(&self) { self.tree_stale.store(true, Ordering::Release); } + + pub fn take_tree_stale(&self) -> bool { + self.tree_stale.swap(false, Ordering::AcqRel) + } + + pub fn tree_is_stale(&self) -> bool { + self.tree_stale.load(Ordering::Acquire) + } } impl Drop for RemoteSession { From 30e49b398cf715d14e4ae657e1d89eb27a1f2a24 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:38:37 -0700 Subject: [PATCH 027/103] Separate identity and background tree refresh --- cmux-tui/crates/cmux-tui/src/app.rs | 240 ++++++++++++++---- cmux-tui/crates/cmux-tui/src/pty_input.rs | 51 +++- cmux-tui/crates/cmux-tui/src/session/mod.rs | 6 + .../crates/cmux-tui/src/session/remote.rs | 17 +- 4 files changed, 257 insertions(+), 57 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 7ebaf097a2ff..3148df4f7389 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -54,8 +54,18 @@ pub enum AppEvent { Mux(MuxEvent), Input(Event), PtyOperationFailed(PtyOperationFailure), + SessionMutationSettled(SessionMutationOutcome), + RemoteTreeUpdated(Result), +} + +pub enum SessionMutationOutcome { + Success { tree: Option, completion: Option }, + Failed(String), + Canceled, +} + +pub enum SessionCompletion { BrowserTabCreated { pane: Option }, - SessionMutationSettled { tree: Option> }, } struct PendingSessionMutation { @@ -64,8 +74,8 @@ struct PendingSessionMutation { } impl PendingSessionMutation { - fn settle(mut self, tree: Option>) { - let _ = self.events.send(AppEvent::SessionMutationSettled { tree }); + fn settle(mut self, outcome: SessionMutationOutcome) { + let _ = self.events.send(AppEvent::SessionMutationSettled(outcome)); self.settled = true; } } @@ -73,7 +83,9 @@ impl PendingSessionMutation { impl Drop for PendingSessionMutation { fn drop(&mut self) { if !self.settled { - let _ = self.events.send(AppEvent::SessionMutationSettled { tree: None }); + let _ = self + .events + .send(AppEvent::SessionMutationSettled(SessionMutationOutcome::Canceled)); } } } @@ -97,6 +109,7 @@ pub struct OrderedSession { remote: bool, pending_mutations: Arc, remote_refresh_queued: Arc, + remote_background_dirty: Arc, } impl OrderedSession { @@ -109,6 +122,7 @@ impl OrderedSession { remote, pending_mutations: Arc::new(AtomicUsize::new(0)), remote_refresh_queued: Arc::new(AtomicBool::new(false)), + remote_background_dirty: Arc::new(AtomicBool::new(false)), } } @@ -142,13 +156,53 @@ impl OrderedSession { let pending = self.pending_mutation(); let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); self.operations.enqueue_session_mutation("refresh remote tree", true, move || { - let tree = session.refresh_tree().map_err(|error| error.to_string()); + let result = session.refresh_tree(); drop(claim); - pending.settle(Some(tree)); - Ok(()) + match result { + Ok(tree) => { + pending.settle(SessionMutationOutcome::Success { + tree: Some(tree), + completion: None, + }); + Ok(()) + } + Err(error) => { + let message = error.to_string(); + session.clear_remote_tree_stale(); + pending.settle(SessionMutationOutcome::Failed(message)); + Err(error) + } + } }); } + fn refresh_remote_tree_background(&self) { + if !self.remote { + return; + } + if self.remote_refresh_queued.swap(true, Ordering::AcqRel) { + self.remote_background_dirty.store(true, Ordering::Release); + return; + } + let session = self.inner.clone(); + let events = self.events.clone(); + let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); + let spawn = + std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { + let result = session.refresh_tree().map_err(|error| error.to_string()); + drop(claim); + let _ = events.send(AppEvent::RemoteTreeUpdated(result)); + }); + if let Err(error) = spawn { + self.remote_refresh_queued.store(false, Ordering::Release); + let _ = self.events.send(AppEvent::RemoteTreeUpdated(Err(error.to_string()))); + } + } + + fn take_background_refresh_dirty(&self) -> bool { + self.remote_background_dirty.swap(false, Ordering::AcqRel) + } + fn remote_tree_is_stale(&self) -> bool { self.inner.remote_tree_is_stale() } @@ -165,20 +219,29 @@ impl OrderedSession { &self, label: &'static str, operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, - completion: Option, + completion: Option, ) { let session = self.inner.clone(); - let events = self.events.clone(); let pending = self.pending_mutation(); self.operations.enqueue_session_mutation(label, self.remote, move || { - operation(session.clone())?; + if let Err(error) = operation(session.clone()) { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + return Err(error); + } session.invalidate_remote_tree(); - let tree = session.refresh_tree().map_err(|error| error.to_string()); - pending.settle(Some(tree)); - if let Some(completion) = completion { - let _ = events.send(completion); + match session.refresh_tree() { + Ok(tree) => { + pending + .settle(SessionMutationOutcome::Success { tree: Some(tree), completion }); + Ok(()) + } + Err(error) => { + let message = error.to_string(); + session.clear_remote_tree_stale(); + pending.settle(SessionMutationOutcome::Failed(message)); + Err(error) + } } - Ok(()) }); } @@ -196,12 +259,12 @@ impl OrderedSession { ("surface resize", surface_id), self.remote, move || { - let _pending = pending; if reassert { surface.reassert_size(cols, rows)?; } else { surface.resize(cols, rows)?; } + pending.settle(SessionMutationOutcome::Success { tree: None, completion: None }); Ok(()) }, ); @@ -221,7 +284,7 @@ impl OrderedSession { self.enqueue_with_completion( "create browser tab", move |session| session.new_browser_tab(url, pane, size), - Some(AppEvent::BrowserTabCreated { pane }), + Some(SessionCompletion::BrowserTabCreated { pane }), ); Ok(()) } @@ -280,11 +343,26 @@ impl OrderedSession { (direction, pane), self.remote, move || { - session.set_ratio(pane, dir, ratio)?; + if let Err(error) = session.set_ratio(pane, dir, ratio) { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + return Err(error); + } session.invalidate_remote_tree(); - let tree = session.refresh_tree().map_err(|error| error.to_string()); - pending.settle(Some(tree)); - Ok(()) + match session.refresh_tree() { + Ok(tree) => { + pending.settle(SessionMutationOutcome::Success { + tree: Some(tree), + completion: None, + }); + Ok(()) + } + Err(error) => { + let message = error.to_string(); + session.clear_remote_tree_stale(); + pending.settle(SessionMutationOutcome::Failed(message)); + Err(error) + } + } }, ); } @@ -1318,21 +1396,26 @@ impl App { fn handle(&mut self, event: AppEvent) -> anyhow::Result { match &event { - AppEvent::Mux( - MuxEvent::SurfaceExited(_) - | MuxEvent::TitleChanged(_) - | MuxEvent::Notification(_) - | MuxEvent::TreeChanged - | MuxEvent::LayoutChanged(_), - ) + AppEvent::Mux(MuxEvent::SurfaceExited(_) | MuxEvent::LayoutChanged(_)) | AppEvent::Input(Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) => { self.session.refresh_remote_tree_if_stale(); } + AppEvent::Mux(MuxEvent::TitleChanged(_) | MuxEvent::Notification(_)) => { + self.session.refresh_remote_tree_background(); + } + AppEvent::Mux(MuxEvent::TreeChanged) => { + if self.session.remote_tree_is_stale() { + self.session.refresh_remote_tree_if_stale(); + } else { + self.session.refresh_remote_tree_background(); + } + } _ => {} } let event = match event { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) - if self.session.has_pending_mutations() + if (self.session.has_pending_mutations() + || self.session.remote_tree_is_stale()) && !self.input_can_update_pending_mutation(&input) => { return Ok(self.defer_input(input)); @@ -1401,29 +1484,36 @@ impl App { self.status_message = Some(format!("{} failed: {}", failure.label, failure.error)); Ok(RenderAction::Draw) } - AppEvent::BrowserTabCreated { pane } => { - self.tree = self.session.tree(); - if let Some(pane) = pane.or_else(|| self.active_pane()) { - self.focus_omnibar_with_buffer(pane, String::new(), false); - } - Ok(RenderAction::Draw) - } - AppEvent::SessionMutationSettled { tree } => { + AppEvent::SessionMutationSettled(outcome) => { self.session.settle_pending_mutation(); - let mut refresh_failed = false; - if let Some(tree) = tree { - match tree { - Ok(tree) => self.tree = tree, - Err(error) => { - refresh_failed = true; - self.status_message = - Some(format!("refresh remote tree failed: {error}")); + match outcome { + SessionMutationOutcome::Success { tree, completion } => { + if let Some(tree) = tree { + self.tree = tree; + } + if let Some(SessionCompletion::BrowserTabCreated { pane }) = completion + && let Some(pane) = pane.or_else(|| self.active_pane()) + { + self.focus_omnibar_with_buffer(pane, String::new(), false); } } + SessionMutationOutcome::Failed(error) => { + self.session.inner.clear_remote_tree_stale(); + self.deferred_input.clear(); + self.status_message = Some(format!("session operation failed: {error}")); + return Ok(RenderAction::Draw); + } + SessionMutationOutcome::Canceled => { + if self.session.has_pending_mutations() { + return Ok(RenderAction::None); + } + self.session.inner.clear_remote_tree_stale(); + self.deferred_input.clear(); + self.status_message = Some("session operation was canceled".to_string()); + return Ok(RenderAction::Draw); + } } - if !refresh_failed { - self.session.refresh_remote_tree_if_stale(); - } + self.session.refresh_remote_tree_if_stale(); if self.session.has_pending_mutations() || self.session.remote_tree_is_stale() { return Ok(RenderAction::Draw); } @@ -1434,6 +1524,21 @@ impl App { } Ok(action) } + AppEvent::RemoteTreeUpdated(result) => { + match result { + Ok(tree) => self.tree = tree, + Err(error) => { + self.status_message = Some(format!("refresh remote tree failed: {error}")); + } + } + if self.session.remote_tree_is_stale() { + let _ = self.session.take_background_refresh_dirty(); + self.session.refresh_remote_tree_if_stale(); + } else if self.session.take_background_refresh_dirty() { + self.session.refresh_remote_tree_background(); + } + Ok(RenderAction::Draw) + } AppEvent::Input(Event::Key(key)) => { if key.kind != KeyEventKind::Release { self.reassert_visible_surface_sizes(); @@ -1488,6 +1593,13 @@ impl App { } fn input_can_update_pending_mutation(&self, input: &Event) -> bool { + if let Event::Key(key) = input + && (self.config.keys.prefix.matches(key) + || self.config.keys.modeless_action_for(key) == Some(Action::Detach) + || (self.prefix_armed && self.config.keys.action_for(key) == Some(Action::Detach))) + { + return true; + } matches!( (input, &self.drag), ( @@ -4290,12 +4402,42 @@ mod tests { assert_eq!(app.deferred_input.len(), 1); release_tx.send(()).unwrap(); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!(settled, AppEvent::SessionMutationSettled { .. })); + assert!(matches!(settled, AppEvent::SessionMutationSettled(_))); app.handle(settled).unwrap(); assert!(app.deferred_input.is_empty()); assert!(!app.session.has_pending_mutations()); } + #[test] + fn failed_mutation_discards_input_deferred_for_its_destination() { + let mux = Mux::new("failed-mutation-input-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session.enqueue("failing selection", move |_| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + anyhow::bail!("selection rejected") + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + assert_eq!(app.deferred_input.len(), 1); + + release_tx.send(()).unwrap(); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + app.handle(settled).unwrap(); + + assert!(app.deferred_input.is_empty()); + assert_eq!( + app.status_message.as_deref(), + Some("session operation failed: selection rejected") + ); + } + #[test] fn split_drag_updates_the_coalescing_lane_while_a_ratio_is_pending() { let mux = Mux::new("pending-split-drag-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index c6d845a88ad2..51cf6413099c 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -292,11 +292,16 @@ fn enqueue_bounded( capacity: usize, max_bytes: usize, ) -> bool { - if event.coalesce_key.is_some() - && events.back().is_some_and(|previous| previous.coalesce_key == event.coalesce_key) - { - *events.back_mut().unwrap() = event; - return true; + if let Some(key) = event.coalesce_key { + for index in (0..events.len()).rev() { + if events[index].coalesce_key == Some(key) { + events[index] = event; + return true; + } + if events[index].coalesce_key.is_none() { + break; + } + } } if event.kind == PtyInputKind::Motion && events.back().is_some_and(|previous| { @@ -544,6 +549,42 @@ mod tests { assert_eq!(events[2].bytes.as_slice(), &[2]); } + #[test] + fn coalescing_mutations_replace_by_key_across_adjacent_resize_work() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = 0; + for item in [ + PtyInputEvent::mutation("resize one", Some(("resize", 1)), false, || Ok(())), + PtyInputEvent::mutation("resize two", Some(("resize", 2)), false, || Ok(())), + PtyInputEvent::mutation("resize one latest", Some(("resize", 1)), false, || Ok(())), + ] { + assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, item, 8, 1024)); + } + + assert_eq!(events.len(), 2); + assert_eq!(events[0].label, "resize one latest"); + assert_eq!(events[1].label, "resize two"); + + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(1, 1, PtyInputKind::Ordered), + 8, + 1024, + )); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + PtyInputEvent::mutation("resize after input", Some(("resize", 1)), false, || Ok(())), + 8, + 1024, + )); + assert_eq!(events.len(), 4); + } + #[test] fn accepted_press_guarantees_its_release_slot() { let mut events = VecDeque::new(); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 5fe9aeba36c9..e5011092e896 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -122,6 +122,12 @@ impl Session { } } + pub fn clear_remote_tree_stale(&self) { + if let Session::Remote(remote) = self { + remote.clear_tree_stale(); + } + } + pub fn refresh_tree(&self) -> anyhow::Result { match self { Session::Local(_) => Ok(self.tree()), diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 94ec6bf81913..3a1dc7f8d4b6 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -1,7 +1,7 @@ //! Remote session client: JSON-lines control socket plus locally //! mirrored surface terminals (VT replay + live stream). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::Path; @@ -207,7 +207,9 @@ pub struct RemoteSession { pending: Mutex>>, next_id: AtomicU64, surfaces: Mutex>>, + exited_surfaces: Mutex>, tree: Mutex, + tree_refresh: Mutex<()>, tree_stale: AtomicBool, subscribers: Mutex>>, frame_logs: Mutex>>, @@ -228,7 +230,9 @@ impl RemoteSession { pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), surfaces: Mutex::new(HashMap::new()), + exited_surfaces: Mutex::new(HashSet::new()), tree: Mutex::new(TreeView::default()), + tree_refresh: Mutex::new(()), tree_stale: AtomicBool::new(true), subscribers: Mutex::new(Vec::new()), frame_logs: Mutex::new(HashMap::new()), @@ -395,7 +399,6 @@ impl RemoteSession { } Some("title-changed") => { if let Some(id) = surface_id() { - self.tree_stale.store(true, Ordering::Release); self.emit(MuxEvent::TitleChanged(id)); } } @@ -405,7 +408,6 @@ impl RemoteSession { } } Some("notification") => { - self.tree_stale.store(true, Ordering::Release); self.emit(MuxEvent::TreeChanged); } Some("status") => { @@ -527,6 +529,9 @@ impl RemoteSession { kind: SurfaceKind, size: Option<(u16, u16)>, ) -> Option> { + if self.exited_surfaces.lock().unwrap().contains(&id) { + return None; + } if let Some(surface) = self.surfaces.lock().unwrap().get(&id) { return Some(surface.clone()); } @@ -557,6 +562,7 @@ impl RemoteSession { pub fn drop_surface(&self, id: SurfaceId) { self.surfaces.lock().unwrap().remove(&id); + self.exited_surfaces.lock().unwrap().insert(id); } pub fn surface_kind(&self, id: SurfaceId) -> SurfaceKind { @@ -568,6 +574,7 @@ impl RemoteSession { } pub fn refresh_tree(&self) -> anyhow::Result { + let _refresh = self.tree_refresh.lock().unwrap(); self.tree_stale.store(false, Ordering::Release); let data = match self.request(json!({"cmd": "list-workspaces"})) { Ok(data) => data, @@ -597,6 +604,10 @@ impl RemoteSession { pub fn tree_is_stale(&self) -> bool { self.tree_stale.load(Ordering::Acquire) } + + pub fn clear_tree_stale(&self) { + self.tree_stale.store(false, Ordering::Release); + } } impl Drop for RemoteSession { From 72907801c352c839a95bee42afc4b5f35cce67a4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:05:28 -0700 Subject: [PATCH 028/103] Close remote input routing gaps --- cmux-tui/crates/cmux-tui/src/app.rs | 157 ++++++++++-------- cmux-tui/crates/cmux-tui/src/browser_input.rs | 58 ++++++- cmux-tui/crates/cmux-tui/src/pty_input.rs | 113 ++++++++++--- cmux-tui/crates/cmux-tui/src/session/mod.rs | 6 - .../crates/cmux-tui/src/session/remote.rs | 34 +++- 5 files changed, 263 insertions(+), 105 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 3148df4f7389..0b6b4591de90 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -155,25 +155,33 @@ impl OrderedSession { let session = self.inner.clone(); let pending = self.pending_mutation(); let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); - self.operations.enqueue_session_mutation("refresh remote tree", true, move || { - let result = session.refresh_tree(); - drop(claim); - match result { - Ok(tree) => { - pending.settle(SessionMutationOutcome::Success { - tree: Some(tree), - completion: None, - }); - Ok(()) - } - Err(error) => { - let message = error.to_string(); - session.clear_remote_tree_stale(); - pending.settle(SessionMutationOutcome::Failed(message)); - Err(error) + let spawn = + std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { + let result = session.refresh_tree(); + drop(claim); + match result { + Ok(tree) => { + pending.settle(SessionMutationOutcome::Success { + tree: Some(tree), + completion: None, + }); + } + Err(error) => { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + } } - } - }); + }); + if let Err(error) = spawn { + self.remote_refresh_queued.store(false, Ordering::Release); + self.inner.invalidate_remote_tree(); + let _ = self.events.send(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: None, + kind: None, + label: "remote tree refresh", + error: error.to_string(), + lane_failed: false, + })); + } } fn refresh_remote_tree_background(&self) { @@ -229,19 +237,19 @@ impl OrderedSession { return Err(error); } session.invalidate_remote_tree(); - match session.refresh_tree() { - Ok(tree) => { - pending - .settle(SessionMutationOutcome::Success { tree: Some(tree), completion }); - Ok(()) - } - Err(error) => { - let message = error.to_string(); - session.clear_remote_tree_stale(); - pending.settle(SessionMutationOutcome::Failed(message)); - Err(error) - } - } + let _ = + std::thread::Builder::new().name("session-tree-settle".into()).spawn(move || { + match session.refresh_tree() { + Ok(tree) => pending.settle(SessionMutationOutcome::Success { + tree: Some(tree), + completion, + }), + Err(error) => { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + } + } + }); + Ok(()) }); } @@ -348,21 +356,18 @@ impl OrderedSession { return Err(error); } session.invalidate_remote_tree(); - match session.refresh_tree() { - Ok(tree) => { - pending.settle(SessionMutationOutcome::Success { + let _ = std::thread::Builder::new().name("session-tree-settle".into()).spawn( + move || match session.refresh_tree() { + Ok(tree) => pending.settle(SessionMutationOutcome::Success { tree: Some(tree), completion: None, - }); - Ok(()) - } - Err(error) => { - let message = error.to_string(); - session.clear_remote_tree_stale(); - pending.settle(SessionMutationOutcome::Failed(message)); - Err(error) - } - } + }), + Err(error) => { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + } + }, + ); + Ok(()) }, ); } @@ -814,6 +819,7 @@ pub struct App { browser_input: BrowserInputDispatcher, pty_input: PtyInputDispatcher, deferred_input: VecDeque, + routing_refresh_pending: bool, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -1078,6 +1084,7 @@ pub fn run( browser_input, pty_input, deferred_input: VecDeque::new(), + routing_refresh_pending: false, drag: None, encoder, mouse_encoder, @@ -1166,22 +1173,43 @@ impl App { if self.expire_toast() { action = action.merge(RenderAction::Draw); } - match action { - RenderAction::Draw => { - let size = terminal.size()?; - self.sync_layout((size.width, size.height)); - self.draw_terminal(terminal)?; - self.emit_graphics()?; - } - RenderAction::Graphics => { - self.emit_graphics()?; - } - RenderAction::None => {} + self.render_action(terminal, action)?; + if self.routing_refresh_pending { + self.routing_refresh_pending = false; + let replay = self.replay_deferred_input()?; + self.render_action(terminal, replay)?; } } Ok(()) } + fn render_action( + &mut self, + terminal: &mut RatatuiTerminal>, + action: RenderAction, + ) -> anyhow::Result<()> { + match action { + RenderAction::Draw => { + let size = terminal.size()?; + self.sync_layout((size.width, size.height)); + self.draw_terminal(terminal)?; + self.emit_graphics()?; + } + RenderAction::Graphics => self.emit_graphics()?, + RenderAction::None => {} + } + Ok(()) + } + + fn replay_deferred_input(&mut self) -> anyhow::Result { + let mut action = RenderAction::None; + while !self.session.has_pending_mutations() && !self.session.remote_tree_is_stale() { + let Some(input) = self.deferred_input.pop_front() else { break }; + action = action.merge(self.handle(AppEvent::Input(input))?); + } + Ok(action) + } + fn draw_terminal( &mut self, terminal: &mut RatatuiTerminal>, @@ -1400,7 +1428,7 @@ impl App { | AppEvent::Input(Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) => { self.session.refresh_remote_tree_if_stale(); } - AppEvent::Mux(MuxEvent::TitleChanged(_) | MuxEvent::Notification(_)) => { + AppEvent::Mux(MuxEvent::Notification(_)) => { self.session.refresh_remote_tree_background(); } AppEvent::Mux(MuxEvent::TreeChanged) => { @@ -1415,7 +1443,8 @@ impl App { let event = match event { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if (self.session.has_pending_mutations() - || self.session.remote_tree_is_stale()) + || self.session.remote_tree_is_stale() + || self.routing_refresh_pending) && !self.input_can_update_pending_mutation(&input) => { return Ok(self.defer_input(input)); @@ -1498,7 +1527,6 @@ impl App { } } SessionMutationOutcome::Failed(error) => { - self.session.inner.clear_remote_tree_stale(); self.deferred_input.clear(); self.status_message = Some(format!("session operation failed: {error}")); return Ok(RenderAction::Draw); @@ -1507,7 +1535,6 @@ impl App { if self.session.has_pending_mutations() { return Ok(RenderAction::None); } - self.session.inner.clear_remote_tree_stale(); self.deferred_input.clear(); self.status_message = Some("session operation was canceled".to_string()); return Ok(RenderAction::Draw); @@ -1517,12 +1544,8 @@ impl App { if self.session.has_pending_mutations() || self.session.remote_tree_is_stale() { return Ok(RenderAction::Draw); } - let mut action = RenderAction::Draw; - while !self.session.has_pending_mutations() { - let Some(input) = self.deferred_input.pop_front() else { break }; - action = action.merge(self.handle(AppEvent::Input(input))?); - } - Ok(action) + self.routing_refresh_pending = true; + Ok(RenderAction::Draw) } AppEvent::RemoteTreeUpdated(result) => { match result { @@ -4404,6 +4427,9 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!(settled, AppEvent::SessionMutationSettled(_))); app.handle(settled).unwrap(); + assert!(app.routing_refresh_pending); + app.routing_refresh_pending = false; + app.replay_deferred_input().unwrap(); assert!(app.deferred_input.is_empty()); assert!(!app.session.has_pending_mutations()); } @@ -4716,6 +4742,7 @@ mod tests { browser_input: BrowserInputDispatcher::spawn().unwrap(), pty_input, deferred_input: VecDeque::new(), + routing_refresh_pending: false, drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 7e11f70de636..2b576fe011da 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -20,7 +20,9 @@ //! that can act on a per-event error, and the surface's own status //! (`BrowserStatus`) is what the UI reports. +use std::collections::HashMap; use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::sync::{Arc, Mutex}; use cmux_tui_core::SurfaceId; @@ -31,12 +33,14 @@ use crate::session::SurfaceHandle; /// blocked worker caps queued work at a few hundred events. const QUEUE_CAPACITY: usize = 512; +#[derive(Clone)] pub struct BrowserInputEvent { pub surface_id: SurfaceId, pub surface: SurfaceHandle, pub kind: BrowserInputKind, } +#[derive(Clone)] pub enum BrowserInputKind { Mouse { event_type: &'static str, @@ -80,23 +84,34 @@ impl BrowserInputKind { pub struct BrowserInputDispatcher { tx: SyncSender, + latest_resizes: Arc>>, } impl BrowserInputDispatcher { pub fn spawn() -> anyhow::Result { let (tx, rx) = sync_channel(QUEUE_CAPACITY); - std::thread::Builder::new().name("mux-browser-input".into()).spawn(move || worker(rx))?; - Ok(BrowserInputDispatcher { tx }) + let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let worker_resizes = latest_resizes.clone(); + std::thread::Builder::new() + .name("mux-browser-input".into()) + .spawn(move || worker(rx, worker_resizes))?; + Ok(BrowserInputDispatcher { tx, latest_resizes }) } /// Queue an event; never blocks. A full queue (worker wedged inside /// a blocking browser call) drops the event. pub fn enqueue(&self, event: BrowserInputEvent) { + if event.kind.is_resize() { + self.latest_resizes.lock().unwrap().insert(event.surface_id, event.clone()); + } let _ = self.tx.try_send(event); } } -fn worker(rx: Receiver) { +fn worker( + rx: Receiver, + latest_resizes: Arc>>, +) { while let Ok(event) = rx.recv() { // Drain whatever queued behind the first event so mouse moves // can be coalesced across the batch. @@ -104,6 +119,8 @@ fn worker(rx: Receiver) { while let Ok(next) = rx.try_recv() { batch.push(next); } + let latest = std::mem::take(&mut *latest_resizes.lock().unwrap()); + merge_latest_resizes(&mut batch, latest); coalesce_browser_events(&mut batch); for event in batch { dispatch(&event); @@ -111,6 +128,29 @@ fn worker(rx: Receiver) { } } +fn merge_latest_resizes( + batch: &mut Vec, + mut latest: HashMap, +) { + let mut merged = Vec::with_capacity(batch.len() + latest.len()); + for index in 0..batch.len() { + let event = &batch[index]; + if event.kind.is_resize() { + let has_later = batch[index + 1..] + .iter() + .any(|next| next.kind.is_resize() && next.surface_id == event.surface_id); + if has_later { + continue; + } + merged.push(latest.remove(&event.surface_id).unwrap_or_else(|| event.clone())); + } else { + merged.push(event.clone()); + } + } + merged.extend(latest.into_values()); + *batch = merged; +} + /// Drop a mouse move when the next event is also a mouse move on the /// same surface: only the final position of a consecutive run is /// forwarded. Clicks, keys, and wheel events keep their order. @@ -252,4 +292,16 @@ mod tests { } assert!(matches!(batch[1].kind, BrowserInputKind::Mouse { .. })); } + + #[test] + fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { + let mut batch = vec![click_event(1)]; + let latest = HashMap::from([(1, resize_event(1, 132))]); + + merge_latest_resizes(&mut batch, latest); + + assert_eq!(batch.len(), 2); + assert!(matches!(batch[0].kind, BrowserInputKind::Mouse { .. })); + assert!(matches!(batch[1].kind, BrowserInputKind::Resize { cols: 132, .. })); + } } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 51cf6413099c..1dfefccf04ad 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -5,7 +5,7 @@ //! motion is coalesced, and every accepted mouse press reserves its release //! capacity. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -47,6 +47,7 @@ pub struct PtyInputEvent { label: &'static str, coalesce_key: Option<(&'static str, u64)>, remote: bool, + reservation_id: Option, } impl PtyInputEvent { @@ -66,6 +67,7 @@ impl PtyInputEvent { label: "PTY input", coalesce_key: None, remote, + reservation_id: None, } } @@ -84,6 +86,7 @@ impl PtyInputEvent { label, coalesce_key, remote, + reservation_id: None, } } } @@ -101,13 +104,51 @@ pub struct PtyOperationFailure { struct QueueState { events: VecDeque, queued_bytes: usize, - release_reservations: usize, + release_reservations: ReleaseReservations, in_flight: Option, closed: bool, remote_failed: bool, shutdown_release_drain: bool, } +#[derive(Default)] +struct ReleaseReservations { + next_id: u64, + outstanding: HashMap, +} + +impl ReleaseReservations { + fn len(&self) -> usize { + self.outstanding.len() + } + + fn reserve(&mut self, surface_id: SurfaceId) -> u64 { + self.next_id = self.next_id.wrapping_add(1); + let id = self.next_id; + self.outstanding.insert(id, surface_id); + id + } + + fn consume(&mut self, surface_id: SurfaceId) -> bool { + let id = self + .outstanding + .iter() + .filter_map(|(id, surface)| (*surface == surface_id).then_some(*id)) + .min(); + id.is_some_and(|id| self.outstanding.remove(&id).is_some()) + } + + fn cancel_latest(&mut self) { + if let Some(id) = self.outstanding.keys().copied().max() { + self.outstanding.remove(&id); + } + } + + fn clear(&mut self) { + self.outstanding.clear(); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct InFlightInput { surface_id: SurfaceId, @@ -176,7 +217,7 @@ impl PtyInputDispatcher { .filter(|event| event.kind == PtyInputKind::Release) .collect::>(); state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); - state.release_reservations = 0; + state.release_reservations.clear(); if !releases.is_empty() { state.events = releases; state.shutdown_release_drain = true; @@ -222,7 +263,7 @@ impl PtyInputSender { pub fn cancel_release_reservation(&self) { let mut state = self.queue.state.lock().unwrap(); - state.release_reservations = state.release_reservations.saturating_sub(1); + state.release_reservations.cancel_latest(); self.queue.changed.notify_all(); } @@ -278,7 +319,7 @@ impl Drop for PtyInputDispatcher { if !state.shutdown_release_drain { state.events.clear(); state.queued_bytes = 0; - state.release_reservations = 0; + state.release_reservations.clear(); } self.sender.queue.changed.notify_all(); } @@ -287,8 +328,8 @@ impl Drop for PtyInputDispatcher { fn enqueue_bounded( events: &mut VecDeque, queued_bytes: &mut usize, - release_reservations: &mut usize, - event: PtyInputEvent, + release_reservations: &mut ReleaseReservations, + mut event: PtyInputEvent, capacity: usize, max_bytes: usize, ) -> bool { @@ -311,7 +352,7 @@ fn enqueue_bounded( let previous_len = events.back().unwrap().bytes.len(); let projected_bytes = queued_bytes.saturating_sub(previous_len) + event.bytes.len() - + *release_reservations * RESERVED_RELEASE_BYTES; + + release_reservations.len() * RESERVED_RELEASE_BYTES; if projected_bytes > max_bytes { return false; } @@ -324,9 +365,10 @@ fn enqueue_bounded( && events.back().is_some_and(|previous| { previous.kind == PtyInputKind::Ordered && previous.surface_id == event.surface_id }); - let consumes_reservation = event.kind == PtyInputKind::Release && *release_reservations > 0; + let consumes_reservation = event.kind == PtyInputKind::Release + && release_reservations.outstanding.values().any(|surface| *surface == event.surface_id); let mut projected = events.len() - + *release_reservations + + release_reservations.len() + usize::from(!merge_stream) + usize::from(event.kind == PtyInputKind::Press); if consumes_reservation { @@ -334,7 +376,7 @@ fn enqueue_bounded( } let mut projected_bytes = *queued_bytes + event.bytes.len() - + (*release_reservations + usize::from(event.kind == PtyInputKind::Press)) + + (release_reservations.len() + usize::from(event.kind == PtyInputKind::Press)) * RESERVED_RELEASE_BYTES; if consumes_reservation { projected_bytes = projected_bytes.saturating_sub(RESERVED_RELEASE_BYTES); @@ -351,9 +393,9 @@ fn enqueue_bounded( } if event.kind == PtyInputKind::Press { - *release_reservations += 1; + event.reservation_id = Some(release_reservations.reserve(event.surface_id)); } else if consumes_reservation { - *release_reservations -= 1; + release_reservations.consume(event.surface_id); } if merge_stream { @@ -385,6 +427,7 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc anyhow::Result { match self { Session::Local(_) => Ok(self.tree()), diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 3a1dc7f8d4b6..f8b4a3323887 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -399,6 +399,7 @@ impl RemoteSession { } Some("title-changed") => { if let Some(id) = surface_id() { + self.update_cached_title(id); self.emit(MuxEvent::TitleChanged(id)); } } @@ -442,6 +443,27 @@ impl RemoteSession { self.frame_logs.lock().unwrap().entry(surface).or_default().push(line); } + fn update_cached_title(&self, surface_id: SurfaceId) { + let title = self + .surfaces + .lock() + .unwrap() + .get(&surface_id) + .and_then(|surface| surface.term.lock().unwrap().title()) + .unwrap_or_default(); + let mut tree = self.tree.lock().unwrap(); + if let Some(tab) = tree + .workspaces + .iter_mut() + .flat_map(|workspace| workspace.screens.iter_mut()) + .flat_map(|screen| screen.panes.iter_mut()) + .flat_map(|pane| pane.tabs.iter_mut()) + .find(|tab| tab.surface == surface_id) + { + tab.title = title; + } + } + pub fn request(&self, mut cmd: Value) -> anyhow::Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); cmd["id"] = json!(id); @@ -585,6 +607,14 @@ impl RemoteSession { } }; let tree = parse_tree(&data); + self.exited_surfaces.lock().unwrap().retain(|surface_id| { + tree.workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .any(|tab| tab.surface == *surface_id) + }); *self.tree.lock().unwrap() = tree.clone(); let surfaces = self.surfaces.lock().unwrap().clone(); for (id, surface) in surfaces { @@ -604,10 +634,6 @@ impl RemoteSession { pub fn tree_is_stale(&self) -> bool { self.tree_stale.load(Ordering::Acquire) } - - pub fn clear_tree_stale(&self) { - self.tree_stale.store(false, Ordering::Release); - } } impl Drop for RemoteSession { From 9ce5b48b93af99634337225a140a3193ecc4bb2c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:07:45 -0700 Subject: [PATCH 029/103] Preserve remote input routing invariants --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 4 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 7 +- cmux-tui/crates/cmux-tui-core/src/server.rs | 128 +++++--- cmux-tui/crates/cmux-tui-core/src/surface.rs | 4 +- cmux-tui/crates/cmux-tui/src/app.rs | 298 ++++++++++++++++-- cmux-tui/crates/cmux-tui/src/browser_input.rs | 78 +++-- .../crates/cmux-tui/src/session/remote.rs | 137 ++++++-- 7 files changed, 512 insertions(+), 144 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 68b104d45255..88b58593aa5a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -535,13 +535,13 @@ fn start_surface_thread( if (url_changed || title_changed) && let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged(id)); + mux.emit(MuxEvent::TitleChanged { surface: id, title: browser.title() }); } } CdpEvent::Other { method, params, .. } if method == "Page.frameNavigated" => { handle_frame_navigated(browser, params); if let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged(id)); + mux.emit(MuxEvent::TitleChanged { surface: id, title: browser.title() }); mux.emit(MuxEvent::SurfaceOutput(id)); } } diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 58d5cb490d99..b678d43a1a13 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -27,7 +27,10 @@ pub enum MuxEvent { /// A surface's child exited. The mux has already reaped it from the /// tree (a tree-changed follows) by the time this arrives. SurfaceExited(SurfaceId), - TitleChanged(SurfaceId), + TitleChanged { + surface: SurfaceId, + title: String, + }, Bell(SurfaceId), Notification(NotificationEvent), Status(String), @@ -429,7 +432,7 @@ impl Mux { browser.mark_failed(err.to_string()); } mux.emit(MuxEvent::Status(format!("browser failed: {err}"))); - mux.emit(MuxEvent::TitleChanged(id)); + mux.emit(MuxEvent::TitleChanged { surface: id, title: surface.title() }); mux.emit(MuxEvent::SurfaceOutput(id)); } }, diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index bfb03ab2a67d..4d785913e3fe 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -1368,54 +1368,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: let writer = writer.clone(); std::thread::Builder::new().name("mux-events-out".into()).spawn(move || { while let Ok(event) = events.recv() { - let value = match &event { - MuxEvent::SurfaceOutput(id) => { - json!({"event": "surface-output", "surface": id}) - } - MuxEvent::SurfaceResized { surface, cols, rows } => { - json!({ - "event": "surface-resized", - "surface": surface, - "cols": cols, - "rows": rows, - }) - } - MuxEvent::SurfaceExited(id) => { - json!({"event": "surface-exited", "surface": id}) - } - MuxEvent::TitleChanged(id) => { - json!({"event": "title-changed", "surface": id}) - } - MuxEvent::Bell(id) => json!({"event": "bell", "surface": id}), - MuxEvent::Notification(notification) => json!({ - "event": "notification", - "notification": notification.notification, - "title": notification.title, - "body": notification.body, - "level": notification.level.as_str(), - "surface": notification.surface, - }), - MuxEvent::Status(message) => { - json!({"event": "status", "message": message}) - } - MuxEvent::ConfigReloadRequested => { - json!({"event": "config-reload-requested"}) - } - MuxEvent::WindowTitleRequested(title) => { - json!({"event": "window-title-requested", "title": title}) - } - MuxEvent::ScrollChanged { surface, offset, at_bottom } => json!({ - "event": "scroll-changed", - "surface": surface, - "offset": offset, - "at_bottom": at_bottom, - }), - MuxEvent::TreeChanged => json!({"event": "tree-changed"}), - MuxEvent::LayoutChanged(screen) => { - json!({"event": "layout-changed", "screen": screen}) - } - MuxEvent::Empty => json!({"event": "empty"}), - }; + let value = subscribed_event_json(&event); if writer.send(&value).is_err() { break; } @@ -1493,6 +1446,45 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: } } +fn subscribed_event_json(event: &MuxEvent) -> Value { + match event { + MuxEvent::SurfaceOutput(id) => json!({"event": "surface-output", "surface": id}), + MuxEvent::SurfaceResized { surface, cols, rows } => json!({ + "event": "surface-resized", + "surface": surface, + "cols": cols, + "rows": rows, + }), + MuxEvent::SurfaceExited(id) => json!({"event": "surface-exited", "surface": id}), + MuxEvent::TitleChanged { surface, title } => { + json!({"event": "title-changed", "surface": surface, "title": title}) + } + MuxEvent::Bell(id) => json!({"event": "bell", "surface": id}), + MuxEvent::Notification(notification) => json!({ + "event": "notification", + "notification": notification.notification, + "title": notification.title, + "body": notification.body, + "level": notification.level.as_str(), + "surface": notification.surface, + }), + MuxEvent::Status(message) => json!({"event": "status", "message": message}), + MuxEvent::ConfigReloadRequested => json!({"event": "config-reload-requested"}), + MuxEvent::WindowTitleRequested(title) => { + json!({"event": "window-title-requested", "title": title}) + } + MuxEvent::ScrollChanged { surface, offset, at_bottom } => json!({ + "event": "scroll-changed", + "surface": surface, + "offset": offset, + "at_bottom": at_bottom, + }), + MuxEvent::TreeChanged => json!({"event": "tree-changed"}), + MuxEvent::LayoutChanged(screen) => json!({"event": "layout-changed", "screen": screen}), + MuxEvent::Empty => json!({"event": "empty"}), + } +} + /// Remove the socket file (call on clean shutdown). pub fn cleanup(path: &Path) { let _ = std::fs::remove_file(path); @@ -1594,6 +1586,46 @@ mod tests { assert_eq!(window_title_osc("a\x1bb\x07c").as_slice(), b"\x1b]0;a b c\x07\x1b]2;a b c\x07"); } + #[test] + fn title_changed_event_includes_authoritative_surface_title() { + let mux = Mux::new( + "title-event-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '\\033]2;server title\\007'".to_string(), + ]), + ..SurfaceOptions::default() + }, + ); + let events = mux.subscribe(); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + loop { + match events.recv_timeout(Duration::from_secs(1)).unwrap() { + MuxEvent::TitleChanged { surface: id, title } + if id == surface.id && title == "server title" => + { + break; + } + _ => {} + } + } + + assert_eq!(surface.title(), "server title"); + assert_eq!( + subscribed_event_json(&MuxEvent::TitleChanged { + surface: surface.id, + title: "server title".to_string(), + }), + json!({ + "event": "title-changed", + "surface": surface.id, + "title": "server title", + }) + ); + } + #[test] fn scroll_surface_emits_one_scroll_changed_event() { let mux = test_mux(); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 6f745a2cf235..3b04266b1f35 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -291,9 +291,9 @@ impl Surface { } if title_changed.swap(false, Ordering::Relaxed) { let title = term.title().unwrap_or_default(); - *pty.title.lock().unwrap() = title; + *pty.title.lock().unwrap() = title.clone(); if let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged(surface.id)); + mux.emit(MuxEvent::TitleChanged { surface: surface.id, title }); } } if let Some(pwd) = term.pwd() { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 0b6b4591de90..504a29289f26 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -49,6 +49,10 @@ use crate::ui::input::{InputEvent, TextInput}; use crate::ui::thumb_geometry; const DEFERRED_INPUT_CAPACITY: usize = 512; +const DEFERRED_INPUT_FIXED_BYTES: usize = 64; +const BRACKETED_PASTE_MARKER_BYTES: usize = 12; +const MAX_DEFERRED_INPUT_BYTES: usize = 4 * 1024 * 1024; +const ROUTING_REFRESH_RETRIES: u8 = 1; pub enum AppEvent { Mux(MuxEvent), @@ -60,6 +64,8 @@ pub enum AppEvent { pub enum SessionMutationOutcome { Success { tree: Option, completion: Option }, + CommittedTreeStale { error: Option, completion: Option }, + RefreshFailed(String), Failed(String), Canceled, } @@ -167,7 +173,7 @@ impl OrderedSession { }); } Err(error) => { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); + pending.settle(SessionMutationOutcome::RefreshFailed(error.to_string())); } } }); @@ -231,24 +237,26 @@ impl OrderedSession { ) { let session = self.inner.clone(); let pending = self.pending_mutation(); + let remote = self.remote; self.operations.enqueue_session_mutation(label, self.remote, move || { if let Err(error) = operation(session.clone()) { pending.settle(SessionMutationOutcome::Failed(error.to_string())); return Err(error); } session.invalidate_remote_tree(); - let _ = - std::thread::Builder::new().name("session-tree-settle".into()).spawn(move || { - match session.refresh_tree() { - Ok(tree) => pending.settle(SessionMutationOutcome::Success { - tree: Some(tree), - completion, - }), - Err(error) => { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); - } - } - }); + if remote { + pending + .settle(SessionMutationOutcome::CommittedTreeStale { error: None, completion }); + } else { + match session.refresh_tree() { + Ok(tree) => pending + .settle(SessionMutationOutcome::Success { tree: Some(tree), completion }), + Err(error) => pending.settle(SessionMutationOutcome::CommittedTreeStale { + error: Some(error.to_string()), + completion, + }), + } + } Ok(()) }); } @@ -340,12 +348,18 @@ impl OrderedSession { } pub fn set_ratio(&self, pane: PaneId, dir: SplitDir, ratio: f32) { + self.set_ratio_deferred(pane, dir, ratio); + self.settle_split_ratio(); + } + + fn set_ratio_deferred(&self, pane: PaneId, dir: SplitDir, ratio: f32) { let session = self.inner.clone(); let direction = match dir { SplitDir::Right => "horizontal split ratio", SplitDir::Down => "vertical split ratio", }; let pending = self.pending_mutation(); + let remote = self.remote; self.operations.enqueue_coalescing_mutation( "resize pane split", (direction, pane), @@ -355,23 +369,32 @@ impl OrderedSession { pending.settle(SessionMutationOutcome::Failed(error.to_string())); return Err(error); } - session.invalidate_remote_tree(); - let _ = std::thread::Builder::new().name("session-tree-settle".into()).spawn( - move || match session.refresh_tree() { + if remote { + pending + .settle(SessionMutationOutcome::Success { tree: None, completion: None }); + } else { + match session.refresh_tree() { Ok(tree) => pending.settle(SessionMutationOutcome::Success { tree: Some(tree), completion: None, }), Err(error) => { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); + pending.settle(SessionMutationOutcome::CommittedTreeStale { + error: Some(error.to_string()), + completion: None, + }); } - }, - ); + } + } Ok(()) }, ); } + fn settle_split_ratio(&self) { + self.enqueue("settle split resize", |_| Ok(())); + } + pub fn close_surface(&self, surface: SurfaceId) { self.enqueue("close tab", move |session| session.close_surface(surface)); } @@ -820,6 +843,8 @@ pub struct App { pty_input: PtyInputDispatcher, deferred_input: VecDeque, routing_refresh_pending: bool, + routing_refresh_retries_remaining: u8, + pending_session_completions: VecDeque, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -1085,6 +1110,8 @@ pub fn run( pty_input, deferred_input: VecDeque::new(), routing_refresh_pending: false, + routing_refresh_retries_remaining: 0, + pending_session_completions: VecDeque::new(), drag: None, encoder, mouse_encoder, @@ -1210,6 +1237,16 @@ impl App { Ok(action) } + fn apply_session_completion(&mut self, completion: SessionCompletion) { + match completion { + SessionCompletion::BrowserTabCreated { pane } => { + if let Some(pane) = pane.or_else(|| self.active_pane()) { + self.focus_omnibar_with_buffer(pane, String::new(), false); + } + } + } + } + fn draw_terminal( &mut self, terminal: &mut RatatuiTerminal>, @@ -1423,6 +1460,12 @@ impl App { } fn handle(&mut self, event: AppEvent) -> anyhow::Result { + if let AppEvent::Input(Event::Paste(text)) = &event + && deferred_paste_bytes(text) > MAX_DEFERRED_INPUT_BYTES + { + self.status_message = Some("Paste exceeds the 4 MiB PTY buffer limit".to_string()); + return Ok(RenderAction::Draw); + } match &event { AppEvent::Mux(MuxEvent::SurfaceExited(_) | MuxEvent::LayoutChanged(_)) | AppEvent::Input(Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) => { @@ -1520,14 +1563,41 @@ impl App { if let Some(tree) = tree { self.tree = tree; } - if let Some(SessionCompletion::BrowserTabCreated { pane }) = completion - && let Some(pane) = pane.or_else(|| self.active_pane()) - { - self.focus_omnibar_with_buffer(pane, String::new(), false); + self.routing_refresh_retries_remaining = 0; + if let Some(completion) = completion { + self.apply_session_completion(completion); + } + while let Some(completion) = self.pending_session_completions.pop_front() { + self.apply_session_completion(completion); + } + } + SessionMutationOutcome::CommittedTreeStale { error, completion } => { + if let Some(completion) = completion { + self.pending_session_completions.push_back(completion); + } + self.routing_refresh_retries_remaining = ROUTING_REFRESH_RETRIES; + if let Some(error) = error { + self.status_message = Some(format!( + "session changed, but its layout refresh failed: {error}" + )); + } + self.session.invalidate_remote_tree(); + self.session.refresh_remote_tree_if_stale(); + } + SessionMutationOutcome::RefreshFailed(error) => { + self.status_message = Some(format!( + "session changed, but its layout is still stale: {error}" + )); + if self.routing_refresh_retries_remaining > 0 { + self.routing_refresh_retries_remaining -= 1; + self.session.invalidate_remote_tree(); + self.session.refresh_remote_tree_if_stale(); } + return Ok(RenderAction::Draw); } SessionMutationOutcome::Failed(error) => { self.deferred_input.clear(); + self.pending_session_completions.clear(); self.status_message = Some(format!("session operation failed: {error}")); return Ok(RenderAction::Draw); } @@ -1536,6 +1606,7 @@ impl App { return Ok(RenderAction::None); } self.deferred_input.clear(); + self.pending_session_completions.clear(); self.status_message = Some("session operation was canceled".to_string()); return Ok(RenderAction::Draw); } @@ -1549,7 +1620,13 @@ impl App { } AppEvent::RemoteTreeUpdated(result) => { match result { - Ok(tree) => self.tree = tree, + Ok(tree) => { + self.tree = tree; + self.routing_refresh_retries_remaining = 0; + while let Some(completion) = self.pending_session_completions.pop_front() { + self.apply_session_completion(completion); + } + } Err(error) => { self.status_message = Some(format!("refresh remote tree failed: {error}")); } @@ -1560,6 +1637,12 @@ impl App { } else if self.session.take_background_refresh_dirty() { self.session.refresh_remote_tree_background(); } + if !self.session.has_pending_mutations() + && !self.session.remote_tree_is_stale() + && !self.deferred_input.is_empty() + { + self.routing_refresh_pending = true; + } Ok(RenderAction::Draw) } AppEvent::Input(Event::Key(key)) => { @@ -1617,7 +1700,7 @@ impl App { fn input_can_update_pending_mutation(&self, input: &Event) -> bool { if let Event::Key(key) = input - && (self.config.keys.prefix.matches(key) + && ((self.config.keys.prefix.matches(key) && !self.prefix_armed) || self.config.keys.modeless_action_for(key) == Some(Action::Detach) || (self.prefix_armed && self.config.keys.action_for(key) == Some(Action::Detach))) { @@ -1626,7 +1709,11 @@ impl App { matches!( (input, &self.drag), ( - Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left), .. }), + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left), + .. + }), Some(Drag::ResizeSplit { .. }) ) | ( Event::Mouse(MouseEvent { @@ -1635,6 +1722,12 @@ impl App { .. }), Some(Drag::Browser { .. }) + ) | ( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(_) | MouseEventKind::Up(_), + .. + }), + Some(Drag::PtyMouse { .. }) ) ) } @@ -1655,14 +1748,21 @@ impl App { *self.deferred_input.back_mut().unwrap() = input; return RenderAction::None; } - if self.deferred_input.len() >= DEFERRED_INPUT_CAPACITY { - if matches!(&input, Event::Mouse(MouseEvent { kind: MouseEventKind::Up(_), .. })) { - self.deferred_input.pop_front(); - } else { - self.status_message = - Some("Input queue is full while a session change is pending".to_string()); + let input_bytes = deferred_input_bytes(&input); + let mut queued_bytes = self.deferred_input.iter().map(deferred_input_bytes).sum::(); + let prioritize_release = + matches!(&input, Event::Mouse(MouseEvent { kind: MouseEventKind::Up(_), .. })); + while self.deferred_input.len() >= DEFERRED_INPUT_CAPACITY + || queued_bytes.saturating_add(input_bytes) > MAX_DEFERRED_INPUT_BYTES + { + if !prioritize_release { + self.status_message = Some( + "Input queue byte limit reached while a session change is pending".to_string(), + ); return RenderAction::Draw; } + let Some(removed) = self.deferred_input.pop_front() else { break }; + queued_bytes = queued_bytes.saturating_sub(deferred_input_bytes(&removed)); } self.deferred_input.push_back(input); RenderAction::None @@ -3626,6 +3726,11 @@ impl App { ); return Ok(RenderAction::Draw); } + if matches!(self.drag, Some(Drag::ResizeSplit { .. })) { + self.drag = None; + self.session.settle_split_ratio(); + return Ok(RenderAction::Draw); + } let was_select = matches!(self.drag, Some(Drag::Select { .. })); let was_drag = self.drag.is_some(); self.drag = None; @@ -3841,7 +3946,7 @@ impl App { } let ratio = (coord.saturating_sub(start) as f32 / extent as f32).clamp(0.05, 0.95); if self.prepare_pty_input_before_mutation() { - self.session.set_ratio(target.set_pane, dir, ratio); + self.session.set_ratio_deferred(target.set_pane, dir, ratio); } } @@ -4108,6 +4213,17 @@ fn menu_action_prepares_pty_release(action: MenuAction) -> bool { ) } +fn deferred_paste_bytes(text: &str) -> usize { + text.len().saturating_add(BRACKETED_PASTE_MARKER_BYTES) +} + +fn deferred_input_bytes(input: &Event) -> usize { + match input { + Event::Paste(text) => deferred_paste_bytes(text), + _ => DEFERRED_INPUT_FIXED_BYTES, + } +} + fn browser_hover_forward_allowed(status: Option, editing_same_pane: bool) -> bool { !editing_same_pane && matches!(status, Some(BrowserStatus::Live)) } @@ -4155,6 +4271,7 @@ mod tests { browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::{HashMap, VecDeque}; + use std::sync::atomic::Ordering; use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -4490,6 +4607,15 @@ mod tests { .unwrap(); assert!(app.deferred_input.is_empty()); + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 20, + row: 5, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + assert!(app.deferred_input.is_empty()); + assert!(app.drag.is_none()); } #[test] @@ -4552,6 +4678,110 @@ mod tests { assert!(app.drag.is_none()); } + #[test] + fn pty_drag_motion_and_release_bypass_pending_mutation_with_pinned_surface() { + let mux = Mux::new("pty-release-barrier-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.session.pending_mutations.store(1, Ordering::Release); + app.drag = Some(Drag::PtyMouse { + surface: 42, + content: Rect { x: 2, y: 3, width: 20, height: 8 }, + button: MouseButton::Right, + position: (5, 5), + modifiers: KeyModifiers::NONE, + }); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 8, + row: 6, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + assert!(app.deferred_input.is_empty()); + assert!(matches!(app.drag, Some(Drag::PtyMouse { surface: 42, position: (8, 6), .. }))); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 8, + row: 6, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + assert!(app.deferred_input.is_empty()); + assert!(app.drag.is_none()); + } + + #[test] + fn doubled_prefix_waits_for_pending_mutation_after_first_prefix_arms() { + let mux = Mux::new("doubled-prefix-barrier-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.session.pending_mutations.store(1, Ordering::Release); + let prefix = Event::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + + app.handle(AppEvent::Input(prefix.clone())).unwrap(); + assert!(app.prefix_armed); + assert!(app.deferred_input.is_empty()); + + app.handle(AppEvent::Input(prefix)).unwrap(); + assert!(app.prefix_armed); + assert_eq!(app.deferred_input.len(), 1); + } + + #[test] + fn committed_mutation_with_stale_refresh_retains_deferred_input() { + let mux = Mux::new("committed-stale-refresh-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.session.pending_mutations.store(1, Ordering::Release); + app.deferred_input + .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::CommittedTreeStale { + error: Some("refresh unavailable".to_string()), + completion: None, + }, + )) + .unwrap(); + + assert_eq!(app.deferred_input.len(), 1); + assert_eq!( + app.status_message.as_deref(), + Some("session changed, but its layout refresh failed: refresh unavailable") + ); + assert!(!app.session.has_pending_mutations()); + } + + #[test] + fn oversized_paste_is_rejected_before_routing_or_text_insertion() { + let mux = Mux::new("oversized-paste-ingress-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let text = + "x".repeat(super::MAX_DEFERRED_INPUT_BYTES - super::BRACKETED_PASTE_MARKER_BYTES + 1); + + app.handle(AppEvent::Input(Event::Paste(text))).unwrap(); + + assert!(app.deferred_input.is_empty()); + assert_eq!(app.status_message.as_deref(), Some("Paste exceeds the 4 MiB PTY buffer limit")); + } + + #[test] + fn deferred_paste_budget_counts_bracket_markers() { + let mux = Mux::new("deferred-paste-budget-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.session.pending_mutations.store(1, Ordering::Release); + let half = "x".repeat(super::MAX_DEFERRED_INPUT_BYTES / 2); + + app.handle(AppEvent::Input(Event::Paste(half.clone()))).unwrap(); + app.handle(AppEvent::Input(Event::Paste(half))).unwrap(); + + assert_eq!(app.deferred_input.len(), 1); + assert_eq!( + app.status_message.as_deref(), + Some("Input queue byte limit reached while a session change is pending") + ); + } + #[test] fn failed_pty_owned_press_does_not_fall_through_to_cmux_mouse_actions() { let mux = Mux::new( @@ -4743,6 +4973,8 @@ mod tests { pty_input, deferred_input: VecDeque::new(), routing_refresh_pending: false, + routing_refresh_retries_remaining: 0, + pending_session_completions: VecDeque::new(), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 2b576fe011da..246fbd2b34b1 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -13,15 +13,16 @@ //! wins) before dispatch, so a stalled endpoint never builds a replay //! backlog of stale hover/drag positions. //! - When the queue is full (the worker is stuck inside a blocking -//! call), events are dropped instead of blocking the UI. Dropped -//! input against a wedged browser was going nowhere anyway. +//! call), pointer and key events are dropped instead of blocking the +//! UI. The latest rejected resize per surface is retained so geometry +//! catches up after already-queued input drains. //! //! Results are intentionally discarded: browser input has no caller //! that can act on a per-event error, and the surface's own status //! (`BrowserStatus`) is what the UI reports. use std::collections::HashMap; -use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; use cmux_tui_core::SurfaceId; @@ -98,13 +99,14 @@ impl BrowserInputDispatcher { Ok(BrowserInputDispatcher { tx, latest_resizes }) } - /// Queue an event; never blocks. A full queue (worker wedged inside - /// a blocking browser call) drops the event. + /// Queue an event without blocking. A full queue retains the latest + /// resize per surface and drops other input. pub fn enqueue(&self, event: BrowserInputEvent) { - if event.kind.is_resize() { - self.latest_resizes.lock().unwrap().insert(event.surface_id, event.clone()); + if let Err(TrySendError::Full(event)) = self.tx.try_send(event) + && event.kind.is_resize() + { + self.latest_resizes.lock().unwrap().insert(event.surface_id, event); } - let _ = self.tx.try_send(event); } } @@ -130,25 +132,13 @@ fn worker( fn merge_latest_resizes( batch: &mut Vec, - mut latest: HashMap, + latest: HashMap, ) { - let mut merged = Vec::with_capacity(batch.len() + latest.len()); - for index in 0..batch.len() { - let event = &batch[index]; - if event.kind.is_resize() { - let has_later = batch[index + 1..] - .iter() - .any(|next| next.kind.is_resize() && next.surface_id == event.surface_id); - if has_later { - continue; - } - merged.push(latest.remove(&event.surface_id).unwrap_or_else(|| event.clone())); - } else { - merged.push(event.clone()); - } - } - merged.extend(latest.into_values()); - *batch = merged; + // These resizes were rejected only after every event already in + // `batch`, so append them at that ordering point. The subsequent + // coalescing pass may merge them with a trailing resize run, but + // must not move them ahead of intervening browser input. + batch.extend(latest.into_values()); } /// Drop a mouse move when the next event is also a mouse move on the @@ -293,6 +283,42 @@ mod tests { assert!(matches!(batch[1].kind, BrowserInputKind::Mouse { .. })); } + #[test] + fn resize_coalescing_stops_at_non_resize_input() { + let mut batch = vec![resize_event(1, 80), click_event(1), resize_event(1, 100)]; + + coalesce_browser_events(&mut batch); + + assert_eq!(batch.len(), 3); + assert!(matches!(batch[0].kind, BrowserInputKind::Resize { cols: 80, .. })); + assert!(matches!(batch[1].kind, BrowserInputKind::Mouse { .. })); + assert!(matches!(batch[2].kind, BrowserInputKind::Resize { cols: 100, .. })); + } + + #[test] + fn only_full_resizes_are_saved_for_fallback_delivery() { + let (tx, rx) = sync_channel(1); + let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let dispatcher = BrowserInputDispatcher { tx, latest_resizes: latest_resizes.clone() }; + + dispatcher.enqueue(click_event(1)); + dispatcher.enqueue(resize_event(1, 132)); + assert!(matches!(rx.recv().unwrap().kind, BrowserInputKind::Mouse { .. })); + assert!(matches!( + latest_resizes.lock().unwrap().get(&1).map(|event| &event.kind), + Some(BrowserInputKind::Resize { cols: 132, .. }) + )); + + latest_resizes.lock().unwrap().clear(); + dispatcher.enqueue(resize_event(2, 144)); + assert!(latest_resizes.lock().unwrap().is_empty()); + assert!(matches!(rx.recv().unwrap().kind, BrowserInputKind::Resize { cols: 144, .. })); + + drop(rx); + dispatcher.enqueue(resize_event(3, 156)); + assert!(latest_resizes.lock().unwrap().is_empty()); + } + #[test] fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { let mut batch = vec![click_event(1)]; diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index f8b4a3323887..90f458282b65 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -85,6 +85,53 @@ impl Default for RemoteBrowserState { } } +#[derive(Default)] +struct RemoteTreeCache { + view: TreeView, + surface_tabs: HashMap, +} + +impl RemoteTreeCache { + fn replace(&mut self, view: TreeView) { + self.surface_tabs.clear(); + for (workspace_index, workspace) in view.workspaces.iter().enumerate() { + for (screen_index, screen) in workspace.screens.iter().enumerate() { + for (pane_index, pane) in screen.panes.iter().enumerate() { + for (tab_index, tab) in pane.tabs.iter().enumerate() { + self.surface_tabs.insert( + tab.surface, + [workspace_index, screen_index, pane_index, tab_index], + ); + } + } + } + } + self.view = view; + } + + fn update_title(&mut self, surface_id: SurfaceId, title: String) -> bool { + let Some([workspace, screen, pane, tab]) = self.surface_tabs.get(&surface_id).copied() + else { + return false; + }; + let Some(tab) = self + .view + .workspaces + .get_mut(workspace) + .and_then(|workspace| workspace.screens.get_mut(screen)) + .and_then(|screen| screen.panes.get_mut(pane)) + .and_then(|pane| pane.tabs.get_mut(tab)) + else { + return false; + }; + if tab.surface != surface_id { + return false; + } + tab.title = title; + true + } +} + /// A surface mirrored from a remote session. pub struct RemoteSurface { pub id: SurfaceId, @@ -208,7 +255,7 @@ pub struct RemoteSession { next_id: AtomicU64, surfaces: Mutex>>, exited_surfaces: Mutex>, - tree: Mutex, + tree: Mutex, tree_refresh: Mutex<()>, tree_stale: AtomicBool, subscribers: Mutex>>, @@ -231,7 +278,7 @@ impl RemoteSession { next_id: AtomicU64::new(1), surfaces: Mutex::new(HashMap::new()), exited_surfaces: Mutex::new(HashSet::new()), - tree: Mutex::new(TreeView::default()), + tree: Mutex::new(RemoteTreeCache::default()), tree_refresh: Mutex::new(()), tree_stale: AtomicBool::new(true), subscribers: Mutex::new(Vec::new()), @@ -361,7 +408,9 @@ impl RemoteSession { surface.update_browser_state(&value); surface.dirty.store(true, Ordering::Release); } - self.emit(MuxEvent::TitleChanged(id)); + if let Some(title) = value.get("title").and_then(Value::as_str) { + self.emit(MuxEvent::TitleChanged { surface: id, title: title.to_string() }); + } self.emit(MuxEvent::SurfaceOutput(id)); } Some("frame") => { @@ -399,8 +448,17 @@ impl RemoteSession { } Some("title-changed") => { if let Some(id) = surface_id() { - self.update_cached_title(id); - self.emit(MuxEvent::TitleChanged(id)); + if let Some(title) = value.get("title").and_then(Value::as_str) { + let updated = self.tree.lock().unwrap().update_title(id, title.to_string()); + if !updated { + self.tree_stale.store(true, Ordering::Release); + self.emit(MuxEvent::TreeChanged); + } + self.emit(MuxEvent::TitleChanged { surface: id, title: title.to_string() }); + } else { + self.tree_stale.store(true, Ordering::Release); + self.emit(MuxEvent::TreeChanged); + } } } Some("bell") => { @@ -443,27 +501,6 @@ impl RemoteSession { self.frame_logs.lock().unwrap().entry(surface).or_default().push(line); } - fn update_cached_title(&self, surface_id: SurfaceId) { - let title = self - .surfaces - .lock() - .unwrap() - .get(&surface_id) - .and_then(|surface| surface.term.lock().unwrap().title()) - .unwrap_or_default(); - let mut tree = self.tree.lock().unwrap(); - if let Some(tab) = tree - .workspaces - .iter_mut() - .flat_map(|workspace| workspace.screens.iter_mut()) - .flat_map(|screen| screen.panes.iter_mut()) - .flat_map(|pane| pane.tabs.iter_mut()) - .find(|tab| tab.surface == surface_id) - { - tab.title = title; - } - } - pub fn request(&self, mut cmd: Value) -> anyhow::Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); cmd["id"] = json!(id); @@ -540,7 +577,7 @@ impl RemoteSession { ) -> Option> { let kind = { let tree = self.tree.lock().unwrap(); - tree.surface_kind(id) + tree.view.surface_kind(id) }; self.ensure_surface_with_kind(id, kind, size) } @@ -559,7 +596,7 @@ impl RemoteSession { } let source = { let tree = self.tree.lock().unwrap(); - browser_source_from_tree(&tree, id) + browser_source_from_tree(&tree.view, id) }; let (cols, rows) = size.unwrap_or((80, 24)); let term = Terminal::new(cols, rows, 10_000, Callbacks::default()).ok()?; @@ -588,11 +625,11 @@ impl RemoteSession { } pub fn surface_kind(&self, id: SurfaceId) -> SurfaceKind { - self.tree.lock().unwrap().surface_kind(id) + self.tree.lock().unwrap().view.surface_kind(id) } pub fn cached_tree(&self) -> TreeView { - self.tree.lock().unwrap().clone() + self.tree.lock().unwrap().view.clone() } pub fn refresh_tree(&self) -> anyhow::Result { @@ -615,7 +652,7 @@ impl RemoteSession { .flat_map(|pane| pane.tabs.iter()) .any(|tab| tab.surface == *surface_id) }); - *self.tree.lock().unwrap() = tree.clone(); + self.tree.lock().unwrap().replace(tree.clone()); let surfaces = self.surfaces.lock().unwrap().clone(); for (id, surface) in surfaces { surface.update_browser_source(browser_source_from_tree(&tree, id)); @@ -737,6 +774,44 @@ mod tests { use super::*; + #[test] + fn indexed_title_update_changes_only_the_addressed_surface() { + let mut cache = RemoteTreeCache::default(); + cache.replace(parse_tree(&json!({ + "workspaces": [ + { + "id": 1, + "active": true, + "screens": [{ + "id": 2, + "active": true, + "layout": {"type": "leaf", "pane": 3}, + "panes": [{ + "id": 3, + "tabs": [{"surface": 4, "title": "old target"}], + }], + }], + }, + { + "id": 5, + "screens": [{ + "id": 6, + "layout": {"type": "leaf", "pane": 7}, + "panes": [{ + "id": 7, + "tabs": [{"surface": 8, "title": "other title"}], + }], + }], + }, + ], + }))); + + assert!(cache.update_title(4, "server title".to_string())); + assert_eq!(cache.view.workspaces[0].screens[0].panes[0].tabs[0].title, "server title"); + assert_eq!(cache.view.workspaces[1].screens[0].panes[0].tabs[0].title, "other title"); + assert!(!cache.update_title(99, "missing".to_string())); + } + #[test] fn browser_state_without_frame_keeps_cached_frame() { let surface = RemoteSurface { From 096f34bbb5533d0a1886e77749b6a4b5ff705804 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:31:22 -0700 Subject: [PATCH 030/103] Close asynchronous input ordering races --- cmux-tui/crates/cmux-tui/src/app.rs | 100 +++++++++--- cmux-tui/crates/cmux-tui/src/browser_input.rs | 143 +++++++++++++----- cmux-tui/crates/cmux-tui/src/pty_input.rs | 63 +++++++- .../crates/cmux-tui/src/session/remote.rs | 140 ++++++++++++++--- 4 files changed, 368 insertions(+), 78 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 504a29289f26..12736c298a08 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -40,7 +40,7 @@ use crate::config::{Action, ChromeTheme, Config, ScrollbarPosition}; use crate::keys; use crate::pty_input::{ PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind, - PtyInputSender, PtyOperationFailure, + PtyInputSender, PtyOperationDelivery, PtyOperationFailure, }; use crate::session::{Session, SurfaceHandle, TreeView}; use crate::ui::graphics::GraphicPlacement; @@ -64,8 +64,9 @@ pub enum AppEvent { pub enum SessionMutationOutcome { Success { tree: Option, completion: Option }, + IdentityRefreshSucceeded(TreeView), CommittedTreeStale { error: Option, completion: Option }, - RefreshFailed(String), + IdentityRefreshFailed(String), Failed(String), Canceled, } @@ -167,13 +168,12 @@ impl OrderedSession { drop(claim); match result { Ok(tree) => { - pending.settle(SessionMutationOutcome::Success { - tree: Some(tree), - completion: None, - }); + pending.settle(SessionMutationOutcome::IdentityRefreshSucceeded(tree)); } Err(error) => { - pending.settle(SessionMutationOutcome::RefreshFailed(error.to_string())); + pending.settle(SessionMutationOutcome::IdentityRefreshFailed( + error.to_string(), + )); } } }); @@ -186,6 +186,7 @@ impl OrderedSession { label: "remote tree refresh", error: error.to_string(), lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, })); } } @@ -1247,6 +1248,17 @@ impl App { } } + fn complete_remote_tree_refresh(&self, refresh_stale: bool) { + let background_dirty = self.session.take_background_refresh_dirty(); + if self.session.remote_tree_is_stale() { + if refresh_stale || background_dirty { + self.session.refresh_remote_tree_if_stale(); + } + } else if background_dirty { + self.session.refresh_remote_tree_background(); + } + } + fn draw_terminal( &mut self, terminal: &mut RatatuiTerminal>, @@ -1547,6 +1559,7 @@ impl App { AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::PtyOperationFailed(failure) => { let failed_active_press = failure.kind == Some(PtyInputKind::Press) + && failure.delivery == PtyOperationDelivery::KnownNotDelivered && failure.surface_id.is_some_and(|surface| { matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) }); @@ -1571,6 +1584,14 @@ impl App { self.apply_session_completion(completion); } } + SessionMutationOutcome::IdentityRefreshSucceeded(tree) => { + self.tree = tree; + self.routing_refresh_retries_remaining = 0; + while let Some(completion) = self.pending_session_completions.pop_front() { + self.apply_session_completion(completion); + } + self.complete_remote_tree_refresh(true); + } SessionMutationOutcome::CommittedTreeStale { error, completion } => { if let Some(completion) = completion { self.pending_session_completions.push_back(completion); @@ -1584,15 +1605,16 @@ impl App { self.session.invalidate_remote_tree(); self.session.refresh_remote_tree_if_stale(); } - SessionMutationOutcome::RefreshFailed(error) => { + SessionMutationOutcome::IdentityRefreshFailed(error) => { self.status_message = Some(format!( "session changed, but its layout is still stale: {error}" )); - if self.routing_refresh_retries_remaining > 0 { + let refresh_stale = self.routing_refresh_retries_remaining > 0; + if refresh_stale { self.routing_refresh_retries_remaining -= 1; self.session.invalidate_remote_tree(); - self.session.refresh_remote_tree_if_stale(); } + self.complete_remote_tree_refresh(refresh_stale); return Ok(RenderAction::Draw); } SessionMutationOutcome::Failed(error) => { @@ -1631,12 +1653,7 @@ impl App { self.status_message = Some(format!("refresh remote tree failed: {error}")); } } - if self.session.remote_tree_is_stale() { - let _ = self.session.take_background_refresh_dirty(); - self.session.refresh_remote_tree_if_stale(); - } else if self.session.take_background_refresh_dirty() { - self.session.refresh_remote_tree_background(); - } + self.complete_remote_tree_refresh(true); if !self.session.has_pending_mutations() && !self.session.remote_tree_is_stale() && !self.deferred_input.is_empty() @@ -4287,7 +4304,8 @@ mod tests { use crate::browser_input::BrowserInputDispatcher; use crate::config::{ChromeTheme, Config, ScrollbarPosition}; use crate::pty_input::{ - PtyInputDispatcher, PtyInputEnqueueResult, PtyInputKind, PtyOperationFailure, + PtyInputDispatcher, PtyInputEnqueueResult, PtyInputKind, PtyOperationDelivery, + PtyOperationFailure, }; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{Session, TreeView}; @@ -4490,6 +4508,7 @@ mod tests { label: "PTY input", error: "write failed".to_string(), lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, })) .unwrap(); @@ -4514,12 +4533,38 @@ mod tests { label: "PTY input", error: "remote session did not respond".to_string(), lane_failed: true, + delivery: PtyOperationDelivery::KnownNotDelivered, })) .unwrap(); assert!(app.drag.is_none()); } + #[test] + fn ambiguous_press_failure_preserves_pty_drag_for_the_physical_release() { + let mux = Mux::new("ambiguous-press-drag-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.drag = Some(Drag::PtyMouse { + surface: 42, + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Left, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Press), + label: "PTY input", + error: "remote session did not respond".to_string(), + lane_failed: false, + delivery: PtyOperationDelivery::Ambiguous, + })) + .unwrap(); + + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Left, .. }))); + } + #[test] fn input_waits_for_prior_session_mutation_to_settle() { let mux = Mux::new("pending-mutation-input-test", SurfaceOptions::default()); @@ -4551,6 +4596,27 @@ mod tests { assert!(!app.session.has_pending_mutations()); } + #[test] + fn identity_refresh_completion_consumes_coalesced_background_refresh() { + let mux = Mux::new("identity-refresh-dirty-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.session.remote = true; + app.session.pending_mutations.store(1, Ordering::Release); + app.session.remote_background_dirty.store(true, Ordering::Release); + + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded(TreeView::default()), + )) + .unwrap(); + + assert!(!app.session.remote_background_dirty.load(Ordering::Acquire)); + assert!(!app.session.has_pending_mutations()); + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::RemoteTreeUpdated(Ok(_)) + )); + } + #[test] fn failed_mutation_discards_input_deferred_for_its_destination() { let mux = Mux::new("failed-mutation-input-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 246fbd2b34b1..c4ed57d8f87f 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -14,8 +14,8 @@ //! backlog of stale hover/drag positions. //! - When the queue is full (the worker is stuck inside a blocking //! call), pointer and key events are dropped instead of blocking the -//! UI. The latest rejected resize per surface is retained so geometry -//! catches up after already-queued input drains. +//! UI. The latest rejected resize per surface and uninterrupted resize +//! run is retained so geometry catches up without crossing later input. //! //! Results are intentionally discarded: browser input has no caller //! that can act on a per-event error, and the surface's own status @@ -71,6 +71,18 @@ pub enum BrowserInputKind { }, } +struct SequencedBrowserInputEvent { + sequence: u64, + event: BrowserInputEvent, +} + +#[derive(Default)] +struct BrowserEnqueueOrder { + next_sequence: u64, + /// Successfully queued non-resize input separates resize runs. + barrier_epoch: u64, +} + impl BrowserInputKind { /// Mouse moves carry only a position; when several are queued for /// the same surface, only the newest matters. @@ -84,45 +96,56 @@ impl BrowserInputKind { } pub struct BrowserInputDispatcher { - tx: SyncSender, - latest_resizes: Arc>>, + tx: SyncSender, + order: Arc>, + latest_resizes: Arc>>, } impl BrowserInputDispatcher { pub fn spawn() -> anyhow::Result { let (tx, rx) = sync_channel(QUEUE_CAPACITY); + let order = Arc::new(Mutex::new(BrowserEnqueueOrder::default())); let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let worker_order = order.clone(); let worker_resizes = latest_resizes.clone(); std::thread::Builder::new() .name("mux-browser-input".into()) - .spawn(move || worker(rx, worker_resizes))?; - Ok(BrowserInputDispatcher { tx, latest_resizes }) + .spawn(move || worker(rx, worker_order, worker_resizes))?; + Ok(BrowserInputDispatcher { tx, order, latest_resizes }) } /// Queue an event without blocking. A full queue retains the latest - /// resize per surface and drops other input. + /// resize per surface and input-delimited run, and drops other input. pub fn enqueue(&self, event: BrowserInputEvent) { - if let Err(TrySendError::Full(event)) = self.tx.try_send(event) - && event.kind.is_resize() - { - self.latest_resizes.lock().unwrap().insert(event.surface_id, event); + let is_resize = event.kind.is_resize(); + let mut order = self.order.lock().unwrap(); + let sequence = order.next_sequence; + order.next_sequence = order.next_sequence.saturating_add(1); + let event = SequencedBrowserInputEvent { sequence, event }; + match self.tx.try_send(event) { + Ok(()) if !is_resize => { + order.barrier_epoch = order.barrier_epoch.saturating_add(1); + } + Err(TrySendError::Full(event)) if is_resize => { + self.latest_resizes + .lock() + .unwrap() + .insert((event.event.surface_id, order.barrier_epoch), event); + } + Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {} } } } fn worker( - rx: Receiver, - latest_resizes: Arc>>, + rx: Receiver, + order: Arc>, + latest_resizes: Arc>>, ) { while let Ok(event) = rx.recv() { - // Drain whatever queued behind the first event so mouse moves - // can be coalesced across the batch. let mut batch = vec![event]; - while let Ok(next) = rx.try_recv() { - batch.push(next); - } - let latest = std::mem::take(&mut *latest_resizes.lock().unwrap()); - merge_latest_resizes(&mut batch, latest); + finish_ordered_batch(&rx, &order, &latest_resizes, &mut batch); + let mut batch = batch.into_iter().map(|event| event.event).collect::>(); coalesce_browser_events(&mut batch); for event in batch { dispatch(&event); @@ -130,15 +153,31 @@ fn worker( } } +fn finish_ordered_batch( + rx: &Receiver, + order: &Mutex, + latest_resizes: &Mutex>, + batch: &mut Vec, +) { + // Block new sequence assignments while establishing the batch cut. + // Every earlier accepted event is drained before fallbacks are collected. + let order_guard = order.lock().unwrap(); + while let Ok(next) = rx.try_recv() { + batch.push(next); + } + let latest = std::mem::take(&mut *latest_resizes.lock().unwrap()); + drop(order_guard); + merge_latest_resizes(batch, latest); +} + fn merge_latest_resizes( - batch: &mut Vec, - latest: HashMap, + batch: &mut Vec, + latest: HashMap<(SurfaceId, u64), SequencedBrowserInputEvent>, ) { - // These resizes were rejected only after every event already in - // `batch`, so append them at that ordering point. The subsequent - // coalescing pass may merge them with a trailing resize run, but - // must not move them ahead of intervening browser input. batch.extend(latest.into_values()); + // A fallback may race with a later successful channel send. Restore + // their common enqueue order before applying adjacency coalescing. + batch.sort_unstable_by_key(|event| event.sequence); } /// Drop a mouse move when the next event is also a mouse move on the @@ -233,6 +272,10 @@ mod tests { } } + fn sequenced(sequence: u64, event: BrowserInputEvent) -> SequencedBrowserInputEvent { + SequencedBrowserInputEvent { sequence, event } + } + fn positions(batch: &[BrowserInputEvent]) -> Vec<(&'static str, SurfaceId)> { batch .iter() @@ -299,20 +342,27 @@ mod tests { fn only_full_resizes_are_saved_for_fallback_delivery() { let (tx, rx) = sync_channel(1); let latest_resizes = Arc::new(Mutex::new(HashMap::new())); - let dispatcher = BrowserInputDispatcher { tx, latest_resizes: latest_resizes.clone() }; + let dispatcher = BrowserInputDispatcher { + tx, + order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), + latest_resizes: latest_resizes.clone(), + }; dispatcher.enqueue(click_event(1)); dispatcher.enqueue(resize_event(1, 132)); - assert!(matches!(rx.recv().unwrap().kind, BrowserInputKind::Mouse { .. })); + assert!(matches!(rx.recv().unwrap().event.kind, BrowserInputKind::Mouse { .. })); assert!(matches!( - latest_resizes.lock().unwrap().get(&1).map(|event| &event.kind), + latest_resizes.lock().unwrap().get(&(1, 1)).map(|event| &event.event.kind), Some(BrowserInputKind::Resize { cols: 132, .. }) )); latest_resizes.lock().unwrap().clear(); dispatcher.enqueue(resize_event(2, 144)); assert!(latest_resizes.lock().unwrap().is_empty()); - assert!(matches!(rx.recv().unwrap().kind, BrowserInputKind::Resize { cols: 144, .. })); + assert!(matches!( + rx.recv().unwrap().event.kind, + BrowserInputKind::Resize { cols: 144, .. } + )); drop(rx); dispatcher.enqueue(resize_event(3, 156)); @@ -321,13 +371,38 @@ mod tests { #[test] fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { - let mut batch = vec![click_event(1)]; - let latest = HashMap::from([(1, resize_event(1, 132))]); + let mut batch = vec![sequenced(0, click_event(1))]; + let latest = HashMap::from([((1, 0), sequenced(1, resize_event(1, 132)))]); merge_latest_resizes(&mut batch, latest); assert_eq!(batch.len(), 2); - assert!(matches!(batch[0].kind, BrowserInputKind::Mouse { .. })); - assert!(matches!(batch[1].kind, BrowserInputKind::Resize { cols: 132, .. })); + assert!(matches!(batch[0].event.kind, BrowserInputKind::Mouse { .. })); + assert!(matches!(batch[1].event.kind, BrowserInputKind::Resize { cols: 132, .. })); + } + + #[test] + fn rejected_resize_stays_before_later_accepted_input() { + let (tx, rx) = sync_channel(1); + let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let dispatcher = BrowserInputDispatcher { + tx, + order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), + latest_resizes: latest_resizes.clone(), + }; + + dispatcher.enqueue(click_event(1)); + dispatcher.enqueue(resize_event(1, 132)); + let first = rx.recv().unwrap(); + dispatcher.enqueue(click_event(1)); + dispatcher.enqueue(resize_event(1, 144)); + let mut batch = vec![first]; + finish_ordered_batch(&rx, &dispatcher.order, &latest_resizes, &mut batch); + + assert_eq!(batch.iter().map(|event| event.sequence).collect::>(), vec![0, 1, 2, 3]); + assert!(matches!(batch[0].event.kind, BrowserInputKind::Mouse { .. })); + assert!(matches!(batch[1].event.kind, BrowserInputKind::Resize { cols: 132, .. })); + assert!(matches!(batch[2].event.kind, BrowserInputKind::Mouse { .. })); + assert!(matches!(batch[3].event.kind, BrowserInputKind::Resize { cols: 144, .. })); } } diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 1dfefccf04ad..670fab6f729c 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use cmux_tui_core::SurfaceId; +use cmux_tui_core::{SurfaceId, SurfaceKind}; use smallvec::SmallVec; use crate::session::{SurfaceHandle, is_remote_timeout, is_remote_transport_failure}; @@ -38,6 +38,12 @@ pub enum PtyInputEnqueueResult { Failed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PtyOperationDelivery { + KnownNotDelivered, + Ambiguous, +} + pub struct PtyInputEvent { pub surface_id: SurfaceId, pub surface: SurfaceHandle, @@ -98,6 +104,7 @@ pub struct PtyOperationFailure { pub label: &'static str, pub error: String, pub lane_failed: bool, + pub delivery: PtyOperationDelivery, } #[derive(Default)] @@ -307,6 +314,7 @@ impl PtyInputSender { } .into(), lane_failed: result == PtyInputEnqueueResult::Failed, + delivery: PtyOperationDelivery::KnownNotDelivered, }); } } @@ -436,16 +444,29 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc>(); + for event in queued { if event.kind == PtyInputKind::Release { releases.push_back(event); } else { + if let Some(reservation_id) = event.reservation_id { + state.release_reservations.outstanding.remove(&reservation_id); + } canceled.push(PtyOperationFailure { surface_id: (event.kind != PtyInputKind::Mutation) .then_some(event.surface_id), @@ -482,12 +508,12 @@ fn worker(queue: Arc, on_failure: Arc, + title_generation: u64, + title_updates: HashMap, +} + +struct TitleUpdate { + generation: u64, + title: String, } impl RemoteTreeCache { - fn replace(&mut self, view: TreeView) { + fn replace(&mut self, view: TreeView, refresh_generation: u64) { self.surface_tabs.clear(); for (workspace_index, workspace) in view.workspaces.iter().enumerate() { for (screen_index, screen) in workspace.screens.iter().enumerate() { @@ -107,9 +114,32 @@ impl RemoteTreeCache { } } self.view = view; + + // A response snapshot can predate title events received while its + // request was in flight. Reapply only those later authoritative + // events; older events are already represented by the response. + let updates = std::mem::take(&mut self.title_updates); + for (surface_id, update) in updates { + if self.surface_tabs.contains_key(&surface_id) { + if update.generation > refresh_generation { + self.update_view_title(surface_id, update.title); + } + } else if update.generation > refresh_generation { + self.title_updates.insert(surface_id, update); + } + } } fn update_title(&mut self, surface_id: SurfaceId, title: String) -> bool { + self.title_generation = self.title_generation.saturating_add(1); + self.title_updates.insert( + surface_id, + TitleUpdate { generation: self.title_generation, title: title.clone() }, + ); + self.update_view_title(surface_id, title) + } + + fn update_view_title(&mut self, surface_id: SurfaceId, title: String) -> bool { let Some([workspace, screen, pane, tab]) = self.surface_tabs.get(&surface_id).copied() else { return false; @@ -130,6 +160,10 @@ impl RemoteTreeCache { tab.title = title; true } + + fn title_generation(&self) -> u64 { + self.title_generation + } } /// A surface mirrored from a remote session. @@ -635,6 +669,7 @@ impl RemoteSession { pub fn refresh_tree(&self) -> anyhow::Result { let _refresh = self.tree_refresh.lock().unwrap(); self.tree_stale.store(false, Ordering::Release); + let refresh_generation = self.tree.lock().unwrap().title_generation(); let data = match self.request(json!({"cmd": "list-workspaces"})) { Ok(data) => data, Err(e) => { @@ -652,7 +687,11 @@ impl RemoteSession { .flat_map(|pane| pane.tabs.iter()) .any(|tab| tab.surface == *surface_id) }); - self.tree.lock().unwrap().replace(tree.clone()); + let tree = { + let mut cache = self.tree.lock().unwrap(); + cache.replace(tree, refresh_generation); + cache.view.clone() + }; let surfaces = self.surfaces.lock().unwrap().clone(); for (id, surface) in surfaces { surface.update_browser_source(browser_source_from_tree(&tree, id)); @@ -777,39 +816,96 @@ mod tests { #[test] fn indexed_title_update_changes_only_the_addressed_surface() { let mut cache = RemoteTreeCache::default(); - cache.replace(parse_tree(&json!({ - "workspaces": [ - { + cache.replace( + parse_tree(&json!({ + "workspaces": [ + { + "id": 1, + "active": true, + "screens": [{ + "id": 2, + "active": true, + "layout": {"type": "leaf", "pane": 3}, + "panes": [{ + "id": 3, + "tabs": [{"surface": 4, "title": "old target"}], + }], + }], + }, + { + "id": 5, + "screens": [{ + "id": 6, + "layout": {"type": "leaf", "pane": 7}, + "panes": [{ + "id": 7, + "tabs": [{"surface": 8, "title": "other title"}], + }], + }], + }, + ], + })), + 0, + ); + + assert!(cache.update_title(4, "server title".to_string())); + assert_eq!(cache.view.workspaces[0].screens[0].panes[0].tabs[0].title, "server title"); + assert_eq!(cache.view.workspaces[1].screens[0].panes[0].tabs[0].title, "other title"); + assert!(!cache.update_title(99, "missing".to_string())); + } + + #[test] + fn refresh_preserves_title_events_that_arrived_after_it_started() { + let tree = |title: &str| { + parse_tree(&json!({ + "workspaces": [{ "id": 1, - "active": true, "screens": [{ "id": 2, - "active": true, "layout": {"type": "leaf", "pane": 3}, "panes": [{ "id": 3, - "tabs": [{"surface": 4, "title": "old target"}], + "tabs": [{"surface": 4, "title": title}], }], }], - }, - { - "id": 5, + }], + })) + }; + let mut cache = RemoteTreeCache::default(); + cache.replace(tree("initial"), 0); + + let refresh_generation = cache.title_generation(); + assert!(cache.update_title(4, "event title".to_string())); + cache.replace(tree("stale snapshot"), refresh_generation); + + assert_eq!(cache.view.workspaces[0].screens[0].panes[0].tabs[0].title, "event title"); + } + + #[test] + fn refresh_uses_snapshot_for_title_events_that_predate_it() { + let tree = |title: &str| { + parse_tree(&json!({ + "workspaces": [{ + "id": 1, "screens": [{ - "id": 6, - "layout": {"type": "leaf", "pane": 7}, + "id": 2, + "layout": {"type": "leaf", "pane": 3}, "panes": [{ - "id": 7, - "tabs": [{"surface": 8, "title": "other title"}], + "id": 3, + "tabs": [{"surface": 4, "title": title}], }], }], - }, - ], - }))); + }], + })) + }; + let mut cache = RemoteTreeCache::default(); + cache.replace(tree("initial"), 0); + assert!(cache.update_title(4, "older event".to_string())); - assert!(cache.update_title(4, "server title".to_string())); - assert_eq!(cache.view.workspaces[0].screens[0].panes[0].tabs[0].title, "server title"); - assert_eq!(cache.view.workspaces[1].screens[0].panes[0].tabs[0].title, "other title"); - assert!(!cache.update_title(99, "missing".to_string())); + let refresh_generation = cache.title_generation(); + cache.replace(tree("fresh snapshot"), refresh_generation); + + assert_eq!(cache.view.workspaces[0].screens[0].panes[0].tabs[0].title, "fresh snapshot"); } #[test] From 4c78a45d07fc62cf51bb2d76a060ec8fc5ba4236 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:05:40 -0700 Subject: [PATCH 031/103] Version title events and complete mutations authoritatively --- cmux-tui/bindings/conformance/fixtures.json | 36 +- cmux-tui/bindings/generate.sh | 2 +- cmux-tui/bindings/go/client.go | 2 +- cmux-tui/bindings/go/cmd/e2e/main.go | 2 +- cmux-tui/bindings/go/events.go | 7 +- cmux-tui/bindings/go/events_test.go | 26 ++ cmux-tui/bindings/go/types.go | 7 + .../java/src/com/cmux/CmuxClient.java | 2 +- .../bindings/java/src/com/cmux/CmuxEvent.java | 8 +- cmux-tui/bindings/java/src/com/cmux/E2e.java | 2 +- .../java/src/com/cmux/TitleChangedEvent.java | 8 + .../java/tests/com/cmux/JsonTest.java | 12 + .../java/tests/com/cmux/WireCaptureTest.java | 4 +- cmux-tui/bindings/python/e2e.py | 2 +- cmux-tui/bindings/rust/examples/e2e.rs | 2 +- cmux-tui/bindings/rust/src/lib.rs | 39 +- cmux-tui/bindings/typescript/e2e/e2e.ts | 28 +- cmux-tui/bindings/typescript/src/client.ts | 2 +- cmux-tui/bindings/typescript/src/types.ts | 2 +- .../crates/cmux-tui-core/src/event_bus.rs | 230 ++++++++++ cmux-tui/crates/cmux-tui-core/src/lib.rs | 2 + cmux-tui/crates/cmux-tui-core/src/mux.rs | 15 +- cmux-tui/crates/cmux-tui-core/src/server.rs | 2 +- cmux-tui/crates/cmux-tui/src/app.rs | 396 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/session/mod.rs | 23 +- .../crates/cmux-tui/src/session/remote.rs | 23 +- cmux-tui/crates/cmux-tui/tests/cli.rs | 2 +- cmux-tui/crates/ghostty-vt/src/mouse.rs | 25 ++ cmux-tui/docs/README.md | 2 +- cmux-tui/docs/browser-panes.md | 2 +- cmux-tui/docs/concepts.md | 2 +- cmux-tui/docs/protocol.md | 12 +- cmux-tui/spec/README.md | 6 +- cmux-tui/spec/bindings.md | 5 +- cmux-tui/spec/commands.md | 8 +- cmux-tui/spec/events.md | 19 +- cmux-tui/spec/transports.md | 8 +- 37 files changed, 844 insertions(+), 131 deletions(-) create mode 100644 cmux-tui/bindings/go/events_test.go create mode 100644 cmux-tui/bindings/java/src/com/cmux/TitleChangedEvent.java create mode 100644 cmux-tui/crates/cmux-tui-core/src/event_bus.rs diff --git a/cmux-tui/bindings/conformance/fixtures.json b/cmux-tui/bindings/conformance/fixtures.json index cffc0401d745..d171b71a07d2 100644 --- a/cmux-tui/bindings/conformance/fixtures.json +++ b/cmux-tui/bindings/conformance/fixtures.json @@ -7,7 +7,7 @@ { "type": "command", "request": { "id": 1, "cmd": "identify" }, - "expect": { "id": 1, "ok": true, "data": { "app": "cmux-tui", "protocol": 6 } }, + "expect": { "id": 1, "ok": true, "data": { "app": "cmux-tui", "protocol": 7 } }, "match": "partial" } ] @@ -18,7 +18,7 @@ { "type": "command", "request": { "id": 1, "cmd": "ping" }, - "expect": { "id": 1, "ok": true, "data": { "ok": true, "protocol": 6 } }, + "expect": { "id": 1, "ok": true, "data": { "ok": true, "protocol": 7 } }, "match": "partial" }, { @@ -191,6 +191,38 @@ } ] }, + { + "name": "subscribe-title-changed-carries-title", + "steps": [ + { + "type": "command", + "request": { "id": 1, "cmd": "new-workspace", "name": "conf-title", "cols": 80, "rows": 24 }, + "expect": { "id": 1, "ok": true }, + "match": "partial", + "bind": { "surface0": "data.surface" } + }, + { + "type": "stream", + "name": "events", + "request": { "id": 2, "cmd": "subscribe" }, + "expect": { "id": 2, "ok": true }, + "match": "partial" + }, + { + "type": "command", + "request": { "id": 3, "cmd": "send", "surface": "$surface0", "text": "printf '\\033]2;conformance title\\007'; sleep 1\r" }, + "expect": { "id": 3, "ok": true }, + "match": "partial" + }, + { + "type": "expect_events", + "stream": "events", + "expect": [ + { "event": "title-changed", "surface": "$surface0", "title": "conformance title" } + ] + } + ] + }, { "name": "send-read-screen-round-trip", "steps": [ diff --git a/cmux-tui/bindings/generate.sh b/cmux-tui/bindings/generate.sh index cad83d06bc50..636ced222a90 100755 --- a/cmux-tui/bindings/generate.sh +++ b/cmux-tui/bindings/generate.sh @@ -41,7 +41,7 @@ assemble_prompt() { echo "- This is a temporary output directory. The harness swaps it into cmux-tui/bindings/$lang/ only after generation succeeds." echo "- Regeneration must overwrite stale generated files inside the temporary output directory." echo "- Follow the style sheet exactly." - echo "- Implement protocol-v5 commands marked implemented, plus consumer-side implemented move commands." + echo "- Implement protocol-v7 commands and events marked implemented, plus consumer-side implemented move commands." echo "- Preserve command names, params, result shapes, event names, and error behavior from the spec." echo "- Do not implement proposed commands unless the style sheet explicitly asks for version-gated stubs." echo diff --git a/cmux-tui/bindings/go/client.go b/cmux-tui/bindings/go/client.go index e3dd6a47902f..3682c9904a78 100644 --- a/cmux-tui/bindings/go/client.go +++ b/cmux-tui/bindings/go/client.go @@ -352,7 +352,7 @@ func (c *Client) AttachSurface(ctx context.Context, surface uint64) (*Stream, er } protocol = &info.Protocol } - if *protocol > 6 || (*protocol > 5 && !c.allowProtocolV6Attach) { + if *protocol > 7 || (*protocol > 5 && !c.allowProtocolV6Attach) { return nil, &protocolError{msg: fmt.Sprintf("unsupported attach protocol %d", *protocol)} } return c.openStream(ctx, map[string]any{"id": c.nextRequestID(), "cmd": "attach-surface", "surface": surface}) diff --git a/cmux-tui/bindings/go/cmd/e2e/main.go b/cmux-tui/bindings/go/cmd/e2e/main.go index 468589b0e7ee..4485c134efbb 100644 --- a/cmux-tui/bindings/go/cmd/e2e/main.go +++ b/cmux-tui/bindings/go/cmd/e2e/main.go @@ -41,7 +41,7 @@ func run() error { if err != nil { return err } - if info.App != "cmux-tui" || info.Protocol < 5 || info.Protocol > 6 { + if info.App != "cmux-tui" || info.Protocol < 5 || info.Protocol > 7 { return fmt.Errorf("unexpected identify result: %+v", info) } cols, rows := uint16(80), uint16(24) diff --git a/cmux-tui/bindings/go/events.go b/cmux-tui/bindings/go/events.go index b0de1266346e..01c13e7db73e 100644 --- a/cmux-tui/bindings/go/events.go +++ b/cmux-tui/bindings/go/events.go @@ -16,11 +16,16 @@ func parseEvent(raw map[string]any) Event { return TreeChangedEvent{} case "empty": return EmptyEvent{} - case "surface-output", "surface-exited", "title-changed", "bell": + case "surface-output", "surface-exited", "bell": var event SurfaceEvent if mustDecode(&event) { return event } + case "title-changed": + var event TitleChangedEvent + if mustDecode(&event) { + return event + } case "surface-resized": var event SurfaceResizedEvent if mustDecode(&event) { diff --git a/cmux-tui/bindings/go/events_test.go b/cmux-tui/bindings/go/events_test.go new file mode 100644 index 000000000000..2a420c0eb105 --- /dev/null +++ b/cmux-tui/bindings/go/events_test.go @@ -0,0 +1,26 @@ +package cmux + +import "testing" + +func TestParseTitleChangedIncludesAuthoritativeTitle(t *testing.T) { + event := parseEvent(map[string]any{ + "event": "title-changed", + "surface": float64(7), + "title": "build logs", + }) + title, ok := event.(TitleChangedEvent) + if !ok { + t.Fatalf("event type = %T, want TitleChangedEvent", event) + } + if title.Surface != 7 || title.Title == nil || *title.Title != "build logs" { + t.Fatalf("event = %+v", title) + } + + legacy, ok := parseEvent(map[string]any{ + "event": "title-changed", + "surface": float64(7), + }).(TitleChangedEvent) + if !ok || legacy.Title != nil { + t.Fatalf("legacy event = %#v", legacy) + } +} diff --git a/cmux-tui/bindings/go/types.go b/cmux-tui/bindings/go/types.go index d968eda1c226..f789ba555b11 100644 --- a/cmux-tui/bindings/go/types.go +++ b/cmux-tui/bindings/go/types.go @@ -131,6 +131,13 @@ type SurfaceEvent struct { func (e SurfaceEvent) EventName() string { return e.Event } +type TitleChangedEvent struct { + Surface uint64 `json:"surface"` + Title *string `json:"title"` +} + +func (TitleChangedEvent) EventName() string { return "title-changed" } + type SurfaceResizedEvent struct { Surface uint64 `json:"surface"` Cols uint16 `json:"cols"` diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java index a61bec234d25..61b1a4901eb6 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java @@ -290,7 +290,7 @@ public CmuxStream subscribe() throws CmuxException { public CmuxStream attachSurface(long surface) throws CmuxException { int negotiated = protocol != null ? protocol : identify().protocol(); - if (negotiated > 6 || (negotiated > 5 && !allowProtocolV6Attach)) { + if (negotiated > 7 || (negotiated > 5 && !allowProtocolV6Attach)) { throw new CmuxProtocolMismatchException("unsupported attach protocol " + negotiated); } Map params = new LinkedHashMap<>(); diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java index 99a28c9dc76e..6c4a6cfdca60 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java @@ -2,7 +2,7 @@ import java.util.Map; -public sealed interface CmuxEvent permits TreeChangedEvent, EmptyEvent, SurfaceEvent, SurfaceResizedEvent, VtStateEvent, OutputEvent, ResizedEvent, UnknownEvent { +public sealed interface CmuxEvent permits TreeChangedEvent, EmptyEvent, SurfaceEvent, TitleChangedEvent, SurfaceResizedEvent, VtStateEvent, OutputEvent, ResizedEvent, UnknownEvent { String event(); static CmuxEvent from(Map raw) { @@ -10,8 +10,12 @@ static CmuxEvent from(Map raw) { return switch (event) { case "tree-changed" -> new TreeChangedEvent(); case "empty" -> new EmptyEvent(); - case "surface-output", "surface-exited", "title-changed", "bell", "detached" -> + case "surface-output", "surface-exited", "bell", "detached" -> new SurfaceEvent(event, CmuxClient.asLong(raw.get("surface"))); + case "title-changed" -> new TitleChangedEvent( + CmuxClient.asLong(raw.get("surface")), + raw.get("title") instanceof String title ? title : null + ); case "surface-resized" -> new SurfaceResizedEvent( CmuxClient.asLong(raw.get("surface")), (int) CmuxClient.asLong(raw.get("cols")), diff --git a/cmux-tui/bindings/java/src/com/cmux/E2e.java b/cmux-tui/bindings/java/src/com/cmux/E2e.java index ef26dee7a04f..6203fd34e2d4 100644 --- a/cmux-tui/bindings/java/src/com/cmux/E2e.java +++ b/cmux-tui/bindings/java/src/com/cmux/E2e.java @@ -13,7 +13,7 @@ public static void main(String[] args) throws Exception { try (CmuxClient client = CmuxClient.builder().socketPath(socket).build()) { IdentifyResult identify = client.identify(); check("cmux-tui".equals(identify.app()), "unexpected app " + identify.app()); - check(identify.protocol() >= 5 && identify.protocol() <= 6, "unsupported protocol " + identify.protocol()); + check(identify.protocol() >= 5 && identify.protocol() <= 7, "unsupported protocol " + identify.protocol()); SurfaceResult created = client.newWorkspace(NewWorkspaceRequest.builder().name(marker).cols(80).rows(24).build()); client.send(created.surface(), "printf '" + marker + "\\n'\r"); waitForMarker(client, created.surface(), marker); diff --git a/cmux-tui/bindings/java/src/com/cmux/TitleChangedEvent.java b/cmux-tui/bindings/java/src/com/cmux/TitleChangedEvent.java new file mode 100644 index 000000000000..5193e0798be8 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/TitleChangedEvent.java @@ -0,0 +1,8 @@ +package com.cmux; + +public record TitleChangedEvent(long surface, String title) implements CmuxEvent { + @Override + public String event() { + return "title-changed"; + } +} diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index 07d08fcd2f98..714cfafdce3a 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -32,6 +32,18 @@ public static void main(String[] args) { assertReject("\"\\u1234\""); assertStringifyReject(Double.NaN); assertStringifyReject(Double.POSITIVE_INFINITY); + + CmuxEvent event = CmuxEvent.from((Map) Json.parse( + "{\"event\":\"title-changed\",\"surface\":7,\"title\":\"build logs\"}" + )); + assertTrue(event instanceof TitleChangedEvent, "title event type"); + TitleChangedEvent title = (TitleChangedEvent) event; + assertEquals(7L, title.surface(), "title event surface"); + assertEquals("build logs", title.title(), "title event title"); + TitleChangedEvent legacyTitle = (TitleChangedEvent) CmuxEvent.from( + (Map) Json.parse("{\"event\":\"title-changed\",\"surface\":7}") + ); + assertEquals(null, legacyTitle.title(), "legacy title event title"); } private static void assertReject(String input) { diff --git a/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java b/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java index e9e029547238..f140ff02dd64 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java @@ -25,7 +25,7 @@ public static void main(String[] args) throws Exception { private static byte[] captureIdentify() throws Exception { Path socket = freshSocketPath(); CaptureServer server = new CaptureServer(socket, new String[] { - "{\"id\":1,\"ok\":true,\"data\":{\"app\":\"cmux-tui\",\"version\":\"test\",\"protocol\":6,\"session\":\"wire\",\"pid\":1}}" + "{\"id\":1,\"ok\":true,\"data\":{\"app\":\"cmux-tui\",\"version\":\"test\",\"protocol\":7,\"session\":\"wire\",\"pid\":1}}" }); server.start(); try (CmuxClient client = CmuxClient.builder().socketPath(socket.toString()).timeout(Duration.ofSeconds(2)).build()) { @@ -39,7 +39,7 @@ private static byte[] captureIdentify() throws Exception { private static byte[] captureAttach(long surface) throws Exception { Path socket = freshSocketPath(); CaptureServer server = new CaptureServer(socket, new String[] { - "{\"id\":1,\"ok\":true,\"data\":{\"app\":\"cmux-tui\",\"version\":\"test\",\"protocol\":6,\"session\":\"wire\",\"pid\":1}}", + "{\"id\":1,\"ok\":true,\"data\":{\"app\":\"cmux-tui\",\"version\":\"test\",\"protocol\":7,\"session\":\"wire\",\"pid\":1}}", "{\"event\":\"vt-state\",\"surface\":" + surface + ",\"cols\":80,\"rows\":24,\"data\":\"\"}" }); server.start(); diff --git a/cmux-tui/bindings/python/e2e.py b/cmux-tui/bindings/python/e2e.py index 6a141381bca6..e8a9fda96493 100755 --- a/cmux-tui/bindings/python/e2e.py +++ b/cmux-tui/bindings/python/e2e.py @@ -20,7 +20,7 @@ def main() -> int: with CmuxClient(socket_path=socket_path, timeout=5.0, allow_protocol_v6_attach=True) as client: info = client.identify() assert info.app == "cmux-tui", info - assert 5 <= info.protocol <= 6, info + assert 5 <= info.protocol <= 7, info created = client.new_workspace(name=marker, cols=80, rows=24) client.send(created.surface, text=f"printf '{marker}\\n'\r") wait_for_marker(client, created.surface, marker) diff --git a/cmux-tui/bindings/rust/examples/e2e.rs b/cmux-tui/bindings/rust/examples/e2e.rs index 7f83c6c0bf08..a704380bc200 100644 --- a/cmux-tui/bindings/rust/examples/e2e.rs +++ b/cmux-tui/bindings/rust/examples/e2e.rs @@ -13,7 +13,7 @@ fn main() -> Result<()> { let identify = client.identify()?; assert!(identify.app == "cmux-tui", "unexpected app {}", identify.app); - assert!((5..=6).contains(&identify.protocol), "unsupported protocol {}", identify.protocol); + assert!((5..=7).contains(&identify.protocol), "unsupported protocol {}", identify.protocol); let created = client.new_workspace(Some(&marker), Some(80), Some(24))?; client.send(created.surface, Some(&format!("printf '{marker}\\n'\r")), None)?; diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index f6382114ae4d..f058cdab0582 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -173,6 +173,12 @@ pub struct SurfaceEvent { pub surface: u64, } +#[derive(Debug, Clone, Deserialize)] +pub struct TitleChangedEvent { + pub surface: u64, + pub title: Option, +} + #[derive(Debug, Clone, Deserialize)] pub struct SurfaceResizedEvent { pub surface: u64, @@ -215,7 +221,7 @@ pub enum Event { SurfaceOutput(SurfaceEvent), SurfaceResized(SurfaceResizedEvent), SurfaceExited(SurfaceEvent), - TitleChanged(SurfaceEvent), + TitleChanged(TitleChangedEvent), Bell(SurfaceEvent), Empty, VtState(VtStateEvent), @@ -512,7 +518,7 @@ impl CmuxClient { Some(protocol) => protocol, None => self.identify()?.protocol, }; - if protocol > 6 || (protocol > 5 && !self.config.allow_protocol_v6_attach) { + if protocol > 7 || (protocol > 5 && !self.config.allow_protocol_v6_attach) { return Err(CmuxError::ProtocolVersion(format!( "unsupported attach protocol {protocol}" ))); @@ -718,3 +724,32 @@ fn insert_opt(params: &mut Map, key: &str, value: O ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn title_changed_decodes_authoritative_title() { + let event = parse_event(serde_json::json!({ + "event": "title-changed", + "surface": 7, + "title": "build logs", + })); + + assert!(matches!( + event, + Event::TitleChanged(TitleChangedEvent { surface: 7, title }) + if title.as_deref() == Some("build logs") + )); + + let legacy = parse_event(serde_json::json!({ + "event": "title-changed", + "surface": 7, + })); + assert!(matches!( + legacy, + Event::TitleChanged(TitleChangedEvent { surface: 7, title: None }) + )); + } +} diff --git a/cmux-tui/bindings/typescript/e2e/e2e.ts b/cmux-tui/bindings/typescript/e2e/e2e.ts index f6a9430fa1e5..ba77843ab187 100644 --- a/cmux-tui/bindings/typescript/e2e/e2e.ts +++ b/cmux-tui/bindings/typescript/e2e/e2e.ts @@ -10,7 +10,7 @@ async function main(): Promise { try { const identify = await client.identify(); assert(identify.app === "cmux-tui", `unexpected app ${identify.app}`); - assert(identify.protocol >= 5 && identify.protocol <= 6, `unsupported protocol ${identify.protocol}`); + assert(identify.protocol >= 5 && identify.protocol <= 7, `unsupported protocol ${identify.protocol}`); const created = await client.newWorkspace({ name: marker, cols: 80, rows: 24 }); await client.send(created.surface, { text: `printf '${marker}\\n'\r` }); @@ -24,6 +24,10 @@ async function main(): Promise { await client.renameSurface(created.surface, `${marker}-renamed`); const events = await client.subscribe(); + const title = `${marker}-title`; + await client.send(created.surface, { text: `printf '\\033]2;${title}\\007'; sleep 1\r` }); + const titleChanged = await nextTitleChanged(events, created.surface, title, 1000); + assert(titleChanged.title === title, `bad title event ${JSON.stringify(titleChanged)}`); await client.resizeSurface(created.surface, 100, 31); const resized = await nextSurfaceResized(events, created.surface, 1000); assert(resized.cols === 100 && resized.rows === 31, `bad resize event ${JSON.stringify(resized)}`); @@ -58,6 +62,28 @@ async function main(): Promise { } } +async function nextTitleChanged( + events: Awaited>, + surface: number, + title: string, + timeoutMs: number, +): Promise<{ event: "title-changed"; surface: number; title: string }> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const event = await events.next(Math.max(1, deadline - Date.now())); + if ( + event.event === "title-changed" && + "surface" in event && + event.surface === surface && + "title" in event && + event.title === title + ) { + return { event: "title-changed", surface: event.surface, title: event.title }; + } + } + throw new Error("title-changed event not observed"); +} + async function waitForMarker(client: CmuxClient, surface: number, marker: string): Promise { const deadline = Date.now() + 5000; let last = ""; diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index b665e9862d83..f2f8af832b13 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -295,7 +295,7 @@ export class CmuxClient { async attachSurface(surface: number): Promise> { const protocol = this.protocol ?? (await this.identify()).protocol; - if (protocol > 6 || (protocol > 5 && !this.allowProtocolV6Attach)) { + if (protocol > 7 || (protocol > 5 && !this.allowProtocolV6Attach)) { throw new CmuxProtocolError(`unsupported attach protocol ${protocol}`); } return CmuxStream.open(this.socketPath, this.timeoutMs, { id: this.nextId(), cmd: "attach-surface", surface }); diff --git a/cmux-tui/bindings/typescript/src/types.ts b/cmux-tui/bindings/typescript/src/types.ts index 4434d81f88f2..60ca8d6c0237 100644 --- a/cmux-tui/bindings/typescript/src/types.ts +++ b/cmux-tui/bindings/typescript/src/types.ts @@ -58,7 +58,7 @@ export type SubscribeEvent = | { event: "surface-output"; surface: number } | { event: "surface-resized"; surface: number; cols: number; rows: number } | { event: "surface-exited"; surface: number } - | { event: "title-changed"; surface: number } + | { event: "title-changed"; surface: number; title?: string } | { event: "bell"; surface: number } | { event: "empty" } | UnknownEvent; diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs new file mode 100644 index 000000000000..e906e01da4d8 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -0,0 +1,230 @@ +//! Per-subscriber mux event delivery with bounded latest-title state. + +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::sync::mpsc::{RecvError, RecvTimeoutError, TryRecvError}; +use std::sync::{Arc, Condvar, Mutex, Weak}; +use std::time::{Duration, Instant}; + +use crate::{MuxEvent, SurfaceId}; + +#[derive(Default)] +pub struct MuxEventBroadcaster { + subscribers: Mutex>>, +} + +pub struct MuxEventReceiver { + mailbox: Arc, +} + +#[derive(Default)] +struct MuxEventMailbox { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct MuxEventMailboxState { + next_sequence: u128, + events: VecDeque<(u128, MuxEvent)>, + title_sequences: HashMap, + titles: BTreeMap, + closed: bool, +} + +impl MuxEventBroadcaster { + pub fn subscribe(&self) -> MuxEventReceiver { + let mailbox = Arc::new(MuxEventMailbox::default()); + self.subscribers.lock().unwrap().push(Arc::downgrade(&mailbox)); + MuxEventReceiver { mailbox } + } + + pub fn emit(&self, event: MuxEvent) { + let mut subscribers = self.subscribers.lock().unwrap(); + subscribers.retain(|subscriber| { + let Some(mailbox) = subscriber.upgrade() else { return false }; + mailbox.push(event.clone()); + true + }); + } +} + +impl Drop for MuxEventBroadcaster { + fn drop(&mut self) { + for subscriber in self.subscribers.get_mut().unwrap().drain(..) { + if let Some(mailbox) = subscriber.upgrade() { + mailbox.close(); + } + } + } +} + +impl MuxEventMailbox { + fn push(&self, event: MuxEvent) { + let mut state = self.state.lock().unwrap(); + if state.closed { + return; + } + let sequence = state.next_sequence; + state.next_sequence = state.next_sequence.saturating_add(1); + match event { + MuxEvent::TitleChanged { surface, title } => { + if let Some(previous) = state.title_sequences.insert(surface, sequence) { + state.titles.remove(&previous); + } + state.titles.insert(sequence, (surface, title)); + } + MuxEvent::SurfaceExited(surface) => { + if let Some(previous) = state.title_sequences.remove(&surface) { + state.titles.remove(&previous); + } + state.events.push_back((sequence, MuxEvent::SurfaceExited(surface))); + } + event => state.events.push_back((sequence, event)), + } + self.changed.notify_one(); + } + + fn close(&self) { + self.state.lock().unwrap().closed = true; + self.changed.notify_all(); + } +} + +impl MuxEventMailboxState { + fn pop(&mut self) -> Option { + let event_sequence = self.events.front().map(|(sequence, _)| *sequence); + let title_sequence = self.titles.first_key_value().map(|(sequence, _)| *sequence); + match (event_sequence, title_sequence) { + (Some(event), Some(title)) if event <= title => { + self.events.pop_front().map(|(_, event)| event) + } + (Some(_), Some(_)) | (None, Some(_)) => { + let (_, (surface, title)) = self.titles.pop_first()?; + self.title_sequences.remove(&surface); + Some(MuxEvent::TitleChanged { surface, title }) + } + (Some(_), None) => self.events.pop_front().map(|(_, event)| event), + (None, None) => None, + } + } +} + +impl MuxEventReceiver { + pub fn recv(&self) -> Result { + let mut state = self.mailbox.state.lock().unwrap(); + loop { + if let Some(event) = state.pop() { + return Ok(event); + } + if state.closed { + return Err(RecvError); + } + state = self.mailbox.changed.wait(state).unwrap(); + } + } + + pub fn try_recv(&self) -> Result { + let mut state = self.mailbox.state.lock().unwrap(); + if let Some(event) = state.pop() { + Ok(event) + } else if state.closed { + Err(TryRecvError::Disconnected) + } else { + Err(TryRecvError::Empty) + } + } + + pub fn try_iter(&self) -> impl Iterator + '_ { + std::iter::from_fn(|| self.try_recv().ok()) + } + + pub fn recv_timeout(&self, timeout: Duration) -> Result { + let started = Instant::now(); + let mut remaining = timeout; + let mut state = self.mailbox.state.lock().unwrap(); + loop { + if let Some(event) = state.pop() { + return Ok(event); + } + if state.closed { + return Err(RecvTimeoutError::Disconnected); + } + let (next, waited) = self.mailbox.changed.wait_timeout(state, remaining).unwrap(); + state = next; + if waited.timed_out() { + if let Some(event) = state.pop() { + return Ok(event); + } + if state.closed { + return Err(RecvTimeoutError::Disconnected); + } + return Err(RecvTimeoutError::Timeout); + } + remaining = timeout.saturating_sub(started.elapsed()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn title_churn_keeps_one_latest_value_per_surface_and_subscriber() { + let broadcaster = MuxEventBroadcaster::default(); + let fast = broadcaster.subscribe(); + let slow = broadcaster.subscribe(); + + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "first".to_string() }); + assert!(matches!( + fast.recv().unwrap(), + MuxEvent::TitleChanged { surface: 1, title } if title == "first" + )); + for index in 0..10_000 { + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: format!("one-{index}") }); + broadcaster.emit(MuxEvent::TitleChanged { surface: 2, title: format!("two-{index}") }); + } + + for receiver in [&fast, &slow] { + assert!(matches!( + receiver.recv().unwrap(), + MuxEvent::TitleChanged { surface: 1, title } if title == "one-9999" + )); + assert!(matches!( + receiver.recv().unwrap(), + MuxEvent::TitleChanged { surface: 2, title } if title == "two-9999" + )); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + } + } + + #[test] + fn coalesced_title_keeps_its_latest_position_between_other_events() { + let broadcaster = MuxEventBroadcaster::default(); + let events = broadcaster.subscribe(); + + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "old".to_string() }); + broadcaster.emit(MuxEvent::Bell(2)); + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "latest".to_string() }); + broadcaster.emit(MuxEvent::SurfaceExited(3)); + + assert!(matches!(events.recv().unwrap(), MuxEvent::Bell(2))); + assert!(matches!( + events.recv().unwrap(), + MuxEvent::TitleChanged { surface: 1, title } if title == "latest" + )); + assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(3))); + } + + #[test] + fn surface_exit_discards_its_pending_title() { + let broadcaster = MuxEventBroadcaster::default(); + let events = broadcaster.subscribe(); + + broadcaster.emit(MuxEvent::TitleChanged { surface: 4, title: "gone".to_string() }); + broadcaster.emit(MuxEvent::SurfaceExited(4)); + + assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(4))); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 94d0e1c89f14..b3206ba14e99 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -9,6 +9,7 @@ //! themselves, which is what makes the backend attachable. mod browser; +mod event_bus; mod model; mod mux; mod short_id; @@ -19,6 +20,7 @@ pub mod platform; pub mod server; pub use browser::normalize_url; +pub use event_bus::{MuxEventBroadcaster, MuxEventReceiver}; pub use layout::{ LayoutResult, Rect, SplitEdge, SplitResize, directional_neighbor, layout_screen, split_for_pane_edge, split_sides, diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index b678d43a1a13..f86ac203d068 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -3,11 +3,11 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::browser::{self, BrowserBootstrap, BrowserRuntime}; +use crate::event_bus::{MuxEventBroadcaster, MuxEventReceiver}; use crate::layout::{Rect, layout_screen}; use crate::model::{Node, Pane, Screen, State, Workspace}; use crate::surface::{DefaultColors, Surface, SurfaceOptions}; @@ -223,7 +223,7 @@ struct SidebarPluginRuntime { /// The multiplexer. Shared by frontends and the control socket server. pub struct Mux { state: Mutex, - subscribers: Mutex>>, + subscribers: MuxEventBroadcaster, next_id: AtomicU64, next_notification_id: AtomicU64, next_active_at: AtomicU64, @@ -259,7 +259,7 @@ impl Mux { panes: HashMap::new(), surfaces: HashMap::new(), }), - subscribers: Mutex::new(Vec::new()), + subscribers: MuxEventBroadcaster::default(), next_id: AtomicU64::new(1), next_notification_id: AtomicU64::new(1), next_active_at: AtomicU64::new(1), @@ -296,15 +296,12 @@ impl Mux { self.next_notification_id.fetch_add(1, Ordering::Relaxed) } - pub fn subscribe(&self) -> Receiver { - let (tx, rx) = channel(); - self.subscribers.lock().unwrap().push(tx); - rx + pub fn subscribe(&self) -> MuxEventReceiver { + self.subscribers.subscribe() } pub fn emit(&self, event: MuxEvent) { - let mut subs = self.subscribers.lock().unwrap(); - subs.retain(|tx| tx.send(event.clone()).is_ok()); + self.subscribers.emit(event); } fn spawn_surface_with_command( diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 4d785913e3fe..f8fa05c69064 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -38,7 +38,7 @@ use crate::{ SplitDir, SurfaceId, SurfaceKind, SurfaceNotification, WorkspaceId, ZoomMode, assign_short_ids, }; -pub const PROTOCOL_VERSION: u32 = 6; +pub const PROTOCOL_VERSION: u32 = 7; /// Default socket path for a session. pub fn default_socket_path(session: &str) -> PathBuf { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 12736c298a08..cd2fb9146424 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, VecDeque}; use std::io::Write; use std::ops::Deref; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -63,16 +63,34 @@ pub enum AppEvent { } pub enum SessionMutationOutcome { - Success { tree: Option, completion: Option }, - IdentityRefreshSucceeded(TreeView), - CommittedTreeStale { error: Option, completion: Option }, + Success { + tree: Option, + }, + AuthoritativeMutationSucceeded { + tree: TreeView, + authoritative_generation: u64, + completion: Option, + }, + IdentityRefreshSucceeded { + tree: TreeView, + authoritative_generation: u64, + }, + CommittedTreeStale { + error: Option, + completion: Option, + }, IdentityRefreshFailed(String), Failed(String), Canceled, } -pub enum SessionCompletion { - BrowserTabCreated { pane: Option }, +pub struct SessionCompletion { + mutation_generation: u64, + action: SessionCompletionAction, +} + +enum SessionCompletionAction { + BrowserTabCreated { surface: SurfaceId }, } struct PendingSessionMutation { @@ -115,6 +133,7 @@ pub struct OrderedSession { events: Sender, remote: bool, pending_mutations: Arc, + committed_mutation_generation: Arc, remote_refresh_queued: Arc, remote_background_dirty: Arc, } @@ -128,6 +147,7 @@ impl OrderedSession { events, remote, pending_mutations: Arc::new(AtomicUsize::new(0)), + committed_mutation_generation: Arc::new(AtomicU64::new(0)), remote_refresh_queued: Arc::new(AtomicBool::new(false)), remote_background_dirty: Arc::new(AtomicBool::new(false)), } @@ -160,6 +180,7 @@ impl OrderedSession { } self.inner.invalidate_remote_tree(); let session = self.inner.clone(); + let authoritative_generation = self.committed_mutation_generation.load(Ordering::Acquire); let pending = self.pending_mutation(); let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); let spawn = @@ -168,7 +189,10 @@ impl OrderedSession { drop(claim); match result { Ok(tree) => { - pending.settle(SessionMutationOutcome::IdentityRefreshSucceeded(tree)); + pending.settle(SessionMutationOutcome::IdentityRefreshSucceeded { + tree, + authoritative_generation, + }); } Err(error) => { pending.settle(SessionMutationOutcome::IdentityRefreshFailed( @@ -227,31 +251,48 @@ impl OrderedSession { label: &'static str, operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, ) { - self.enqueue_with_completion(label, operation, None); + self.enqueue_with_completion(label, move |session| { + operation(session)?; + Ok(None) + }); } fn enqueue_with_completion( &self, label: &'static str, - operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, - completion: Option, + operation: impl FnOnce(Session) -> anyhow::Result> + + Send + + 'static, ) { let session = self.inner.clone(); let pending = self.pending_mutation(); let remote = self.remote; + let committed_mutation_generation = self.committed_mutation_generation.clone(); self.operations.enqueue_session_mutation(label, self.remote, move || { - if let Err(error) = operation(session.clone()) { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); - return Err(error); - } + let completion = match operation(session.clone()) { + Ok(completion) => completion, + Err(error) => { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + return Err(error); + } + }; + let mutation_generation = + committed_mutation_generation.fetch_add(1, Ordering::AcqRel) + 1; + let completion = + completion.map(|action| SessionCompletion { mutation_generation, action }); session.invalidate_remote_tree(); if remote { pending .settle(SessionMutationOutcome::CommittedTreeStale { error: None, completion }); } else { match session.refresh_tree() { - Ok(tree) => pending - .settle(SessionMutationOutcome::Success { tree: Some(tree), completion }), + Ok(tree) => { + pending.settle(SessionMutationOutcome::AuthoritativeMutationSucceeded { + tree, + authoritative_generation: mutation_generation, + completion, + }); + } Err(error) => pending.settle(SessionMutationOutcome::CommittedTreeStale { error: Some(error.to_string()), completion, @@ -271,6 +312,7 @@ impl OrderedSession { reassert: bool, ) { let pending = self.pending_mutation(); + let committed_mutation_generation = self.committed_mutation_generation.clone(); self.operations.enqueue_coalescing_mutation( "resize PTY surface", ("surface resize", surface_id), @@ -281,7 +323,8 @@ impl OrderedSession { } else { surface.resize(cols, rows)?; } - pending.settle(SessionMutationOutcome::Success { tree: None, completion: None }); + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + pending.settle(SessionMutationOutcome::Success { tree: None }); Ok(()) }, ); @@ -298,11 +341,10 @@ impl OrderedSession { pane: Option, size: Option<(u16, u16)>, ) -> anyhow::Result<()> { - self.enqueue_with_completion( - "create browser tab", - move |session| session.new_browser_tab(url, pane, size), - Some(SessionCompletion::BrowserTabCreated { pane }), - ); + self.enqueue_with_completion("create browser tab", move |session| { + let surface = session.new_browser_tab(url, pane, size)?; + Ok(Some(SessionCompletionAction::BrowserTabCreated { surface })) + }); Ok(()) } @@ -361,6 +403,7 @@ impl OrderedSession { }; let pending = self.pending_mutation(); let remote = self.remote; + let committed_mutation_generation = self.committed_mutation_generation.clone(); self.operations.enqueue_coalescing_mutation( "resize pane split", (direction, pane), @@ -370,15 +413,14 @@ impl OrderedSession { pending.settle(SessionMutationOutcome::Failed(error.to_string())); return Err(error); } + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); if remote { - pending - .settle(SessionMutationOutcome::Success { tree: None, completion: None }); + pending.settle(SessionMutationOutcome::Success { tree: None }); } else { match session.refresh_tree() { - Ok(tree) => pending.settle(SessionMutationOutcome::Success { - tree: Some(tree), - completion: None, - }), + Ok(tree) => { + pending.settle(SessionMutationOutcome::Success { tree: Some(tree) }); + } Err(error) => { pending.settle(SessionMutationOutcome::CommittedTreeStale { error: Some(error.to_string()), @@ -1239,15 +1281,40 @@ impl App { } fn apply_session_completion(&mut self, completion: SessionCompletion) { - match completion { - SessionCompletion::BrowserTabCreated { pane } => { - if let Some(pane) = pane.or_else(|| self.active_pane()) { + match completion.action { + SessionCompletionAction::BrowserTabCreated { surface } => { + let pane = self + .tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .find(|pane| { + pane.active_surface() == Some(surface) + && pane + .tabs + .get(pane.active_tab) + .is_some_and(|tab| tab.kind == SurfaceKind::Browser) + }) + .map(|pane| pane.id); + if let Some(pane) = pane { self.focus_omnibar_with_buffer(pane, String::new(), false); } } } } + fn apply_session_completions_through(&mut self, authoritative_generation: u64) { + while self + .pending_session_completions + .front() + .is_some_and(|completion| completion.mutation_generation <= authoritative_generation) + { + let completion = self.pending_session_completions.pop_front().unwrap(); + self.apply_session_completion(completion); + } + } + fn complete_remote_tree_refresh(&self, refresh_stale: bool) { let background_dirty = self.session.take_background_refresh_dirty(); if self.session.remote_tree_is_stale() { @@ -1558,6 +1625,11 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::PtyOperationFailed(failure) => { + if failure.kind == Some(PtyInputKind::Motion) + && failure.delivery == PtyOperationDelivery::KnownNotDelivered + { + self.mouse_encoder.reset_motion_dedupe(); + } let failed_active_press = failure.kind == Some(PtyInputKind::Press) && failure.delivery == PtyOperationDelivery::KnownNotDelivered && failure.surface_id.is_some_and(|surface| { @@ -1572,24 +1644,31 @@ impl App { AppEvent::SessionMutationSettled(outcome) => { self.session.settle_pending_mutation(); match outcome { - SessionMutationOutcome::Success { tree, completion } => { + SessionMutationOutcome::Success { tree } => { if let Some(tree) = tree { self.tree = tree; } self.routing_refresh_retries_remaining = 0; + } + SessionMutationOutcome::AuthoritativeMutationSucceeded { + tree, + authoritative_generation, + completion, + } => { + self.tree = tree; + self.routing_refresh_retries_remaining = 0; if let Some(completion) = completion { - self.apply_session_completion(completion); - } - while let Some(completion) = self.pending_session_completions.pop_front() { - self.apply_session_completion(completion); + self.pending_session_completions.push_back(completion); } + self.apply_session_completions_through(authoritative_generation); } - SessionMutationOutcome::IdentityRefreshSucceeded(tree) => { + SessionMutationOutcome::IdentityRefreshSucceeded { + tree, + authoritative_generation, + } => { self.tree = tree; self.routing_refresh_retries_remaining = 0; - while let Some(completion) = self.pending_session_completions.pop_front() { - self.apply_session_completion(completion); - } + self.apply_session_completions_through(authoritative_generation); self.complete_remote_tree_refresh(true); } SessionMutationOutcome::CommittedTreeStale { error, completion } => { @@ -1645,9 +1724,6 @@ impl App { Ok(tree) => { self.tree = tree; self.routing_refresh_retries_remaining = 0; - while let Some(completion) = self.pending_session_completions.pop_front() { - self.apply_session_completion(completion); - } } Err(error) => { self.status_message = Some(format!("refresh remote tree failed: {error}")); @@ -3215,9 +3291,22 @@ impl App { kind: PtyInputKind, ) -> bool { let result = self.pty_input.enqueue(PtyInputEvent::input(surface_id, surface, bytes, kind)); + self.rollback_mouse_motion_for_enqueue_failure(kind, result); self.handle_pty_enqueue_result(result) } + fn rollback_mouse_motion_for_enqueue_failure( + &mut self, + kind: PtyInputKind, + result: PtyInputEnqueueResult, + ) { + if kind == PtyInputKind::Motion + && matches!(result, PtyInputEnqueueResult::Saturated | PtyInputEnqueueResult::Failed) + { + self.mouse_encoder.reset_motion_dedupe(); + } + } + fn handle_pty_enqueue_result(&mut self, result: PtyInputEnqueueResult) -> bool { match result { PtyInputEnqueueResult::Accepted => true, @@ -4284,8 +4373,8 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, AppEvent, Drag, OrderedSession, PaneArea, browser_content_size_for_rect, - browser_hover_forward_allowed, pane_parts_for_rect, + App, AppEvent, Drag, OrderedSession, PaneArea, SessionCompletion, SessionCompletionAction, + browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::Ordering; @@ -4293,11 +4382,13 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; - use cmux_tui_core::{BrowserStatus, Mux, Node, Rect, SurfaceKind, SurfaceOptions}; + use cmux_tui_core::{BrowserStatus, Mux, Node, Rect, SurfaceId, SurfaceKind, SurfaceOptions}; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; - use ghostty_vt::{KeyEncoder, MouseEncoder, RenderState}; + use ghostty_vt::{ + Callbacks, KeyEncoder, Mods, MouseAction, MouseEncoder, MouseInput, RenderState, + }; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -4515,6 +4606,64 @@ mod tests { assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); } + #[test] + fn rejected_motion_enqueue_rolls_back_mouse_encoder_dedupe() { + let mux = Mux::new("motion-enqueue-rollback-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let mut terminal = ghostty_vt::Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1003h\x1b[?1006h"); + let input = test_mouse_motion(); + + assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + + app.rollback_mouse_motion_for_enqueue_failure( + PtyInputKind::Motion, + PtyInputEnqueueResult::Saturated, + ); + assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + + app.rollback_mouse_motion_for_enqueue_failure( + PtyInputKind::Motion, + PtyInputEnqueueResult::Failed, + ); + assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + } + + #[test] + fn canceled_known_undelivered_motion_rolls_back_mouse_encoder_dedupe() { + let mux = Mux::new("motion-cancel-rollback-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let mut terminal = ghostty_vt::Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1003h\x1b[?1006h"); + let input = test_mouse_motion(); + + assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Motion), + label: "PTY input", + error: "remote session did not respond".to_string(), + lane_failed: false, + delivery: PtyOperationDelivery::Ambiguous, + })) + .unwrap(); + assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Motion), + label: "PTY input", + error: "canceled before delivery".to_string(), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + })) + .unwrap(); + assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + } + #[test] fn lane_failure_clears_pty_drag_after_nonpress_failure() { let mux = Mux::new("lane-failure-drag-test", SurfaceOptions::default()); @@ -4605,7 +4754,10 @@ mod tests { app.session.remote_background_dirty.store(true, Ordering::Release); app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded(TreeView::default()), + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: TreeView::default(), + authoritative_generation: 0, + }, )) .unwrap(); @@ -4617,6 +4769,78 @@ mod tests { )); } + #[test] + fn browser_completion_waits_for_its_authoritative_identity_generation() { + let mux = Mux::new("browser-completion-generation-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let surface = 41; + app.pane_areas.push(browser_completion_area(surface)); + app.pending_session_completions.push_back(SessionCompletion { + mutation_generation: 4, + action: SessionCompletionAction::BrowserTabCreated { surface }, + }); + let tree = browser_completion_tree(surface, surface); + + app.handle(AppEvent::RemoteTreeUpdated(Ok(tree.clone()))).unwrap(); + assert_eq!(app.pending_session_completions.len(), 1); + assert!(app.omnibar.is_none()); + + app.session.pending_mutations.store(1, Ordering::Release); + app.handle(AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Success { + tree: None, + })) + .unwrap(); + assert_eq!(app.pending_session_completions.len(), 1); + assert!(app.omnibar.is_none()); + + app.session.pending_mutations.store(1, Ordering::Release); + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: tree.clone(), + authoritative_generation: 3, + }, + )) + .unwrap(); + assert_eq!(app.pending_session_completions.len(), 1); + assert!(app.omnibar.is_none()); + + app.session.pending_mutations.store(1, Ordering::Release); + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree, + authoritative_generation: 4, + }, + )) + .unwrap(); + assert!(app.pending_session_completions.is_empty()); + assert_eq!(app.omnibar.as_ref().map(|state| state.surface), Some(surface)); + } + + #[test] + fn browser_completion_fails_closed_when_created_surface_is_not_active() { + let mux = Mux::new("browser-completion-inactive-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let created_surface = 41; + let active_surface = 42; + app.pane_areas.push(browser_completion_area(active_surface)); + app.pending_session_completions.push_back(SessionCompletion { + mutation_generation: 4, + action: SessionCompletionAction::BrowserTabCreated { surface: created_surface }, + }); + app.session.pending_mutations.store(1, Ordering::Release); + + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: browser_completion_tree(created_surface, active_surface), + authoritative_generation: 4, + }, + )) + .unwrap(); + + assert!(app.pending_session_completions.is_empty()); + assert!(app.omnibar.is_none()); + } + #[test] fn failed_mutation_discards_input_deferred_for_its_destination() { let mux = Mux::new("failed-mutation-input-test", SurfaceOptions::default()); @@ -4994,6 +5218,82 @@ mod tests { mux.close_surface(surface.id); } + fn test_mouse_motion() -> MouseInput { + MouseInput { + action: MouseAction::Motion, + button: None, + mods: Mods::default(), + position: (36.0, 40.0), + screen_size: (640, 384), + cell_size: (8, 16), + any_button_pressed: false, + } + } + + fn encode_test_mouse_motion( + app: &mut App, + terminal: &ghostty_vt::Terminal, + input: MouseInput, + ) -> Vec { + let mut output = Vec::new(); + app.mouse_encoder.sync_from_terminal(terminal); + app.mouse_encoder.encode(input, &mut output).unwrap(); + output + } + + fn browser_completion_area(surface: SurfaceId) -> PaneArea { + PaneArea { + pane: 2, + surface, + rect: Rect { x: 0, y: 0, width: 40, height: 12 }, + bar: Some(Rect { x: 0, y: 0, width: 40, height: 1 }), + omnibar: Some(Rect { x: 0, y: 1, width: 40, height: 1 }), + content: Rect { x: 0, y: 2, width: 40, height: 10 }, + track: None, + } + } + + fn browser_completion_tree(created_surface: SurfaceId, active_surface: SurfaceId) -> TreeView { + let tab = |surface| TabView { + surface, + short_id: format!("{surface:06}"), + name: None, + title: String::new(), + kind: SurfaceKind::Browser, + browser_source: None, + browser_frames_stalled: false, + notification: None, + }; + let mut tabs = vec![tab(created_surface)]; + if active_surface != created_surface { + tabs.push(tab(active_surface)); + } + TreeView { + active_workspace: 0, + workspaces: vec![WorkspaceView { + id: 4, + short_id: "000004".to_string(), + name: "work".to_string(), + active_screen: 0, + screens: vec![ScreenView { + id: 3, + short_id: "000003".to_string(), + name: None, + layout: Node::Leaf(2), + active_pane: 2, + zoomed_pane: None, + panes: vec![PaneView { + id: 2, + short_id: "000002".to_string(), + name: None, + tabs, + active_tab: usize::from(active_surface != created_surface), + }], + }], + }], + } + } + fn test_app(session: Session) -> App { test_app_with_events(session).0 } diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 5fe9aeba36c9..96f431833f86 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -12,10 +12,9 @@ pub(crate) mod tree; use std::sync::Arc; use std::sync::atomic::Ordering; -use std::sync::mpsc::Receiver; use cmux_tui_core::{ - BrowserFrame, BrowserStatus, DefaultColors, Mux, MuxEvent, PaneId, ScreenId, + BrowserFrame, BrowserStatus, DefaultColors, Mux, MuxEventReceiver, PaneId, ScreenId, SidebarPluginStatus, SplitDir, Surface, SurfaceId, SurfaceKind, WorkspaceId, ZoomMode, }; use ghostty_vt::{RenderState, Terminal}; @@ -146,7 +145,7 @@ impl Session { } } - pub fn events(&self) -> Receiver { + pub fn events(&self) -> MuxEventReceiver { match self { Session::Local(mux) => mux.subscribe(), Session::Remote(remote) => remote.subscribe(), @@ -274,19 +273,21 @@ impl Session { url: String, pane: Option, size: Option<(u16, u16)>, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { match self { - Session::Local(mux) => mux.new_browser_tab(url, pane, size).map(|_| ()), + Session::Local(mux) => mux.new_browser_tab(url, pane, size).map(|surface| surface.id), Session::Remote(remote) => { if !remote.supports_browser_attach() { anyhow::bail!("browser panes are not supported over attach yet"); } - remote - .request(with_size( - json!({"cmd": "new-browser-tab", "url": url, "pane": pane}), - size, - )) - .map(|_| ()) + let result = remote.request(with_size( + json!({"cmd": "new-browser-tab", "url": url, "pane": pane}), + size, + ))?; + result + .get("surface") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("remote browser creation omitted its surface")) } } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 33c677e404c5..74db0ba76467 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -6,21 +6,21 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::mpsc::{Sender, channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use base64::Engine; use cmux_tui_core::{ - BrowserFrame, BrowserSource, BrowserStatus, DefaultColors, MuxEvent, Rgb, SurfaceId, - SurfaceKind, platform::transport, + BrowserFrame, BrowserSource, BrowserStatus, DefaultColors, MuxEvent, MuxEventBroadcaster, + MuxEventReceiver, Rgb, SurfaceId, SurfaceKind, platform::transport, }; use ghostty_vt::{Callbacks, RenderState, Terminal}; use serde_json::{Value, json}; use super::tree::{TreeView, parse_tree}; -const SUPPORTED_PROTOCOL_VERSION: u64 = 6; +const SUPPORTED_PROTOCOL_VERSION: u64 = 7; #[derive(Debug)] pub(crate) enum RemoteRequestError { @@ -292,7 +292,7 @@ pub struct RemoteSession { tree: Mutex, tree_refresh: Mutex<()>, tree_stale: AtomicBool, - subscribers: Mutex>>, + subscribers: MuxEventBroadcaster, frame_logs: Mutex>>, } @@ -315,7 +315,7 @@ impl RemoteSession { tree: Mutex::new(RemoteTreeCache::default()), tree_refresh: Mutex::new(()), tree_stale: AtomicBool::new(true), - subscribers: Mutex::new(Vec::new()), + subscribers: MuxEventBroadcaster::default(), frame_logs: Mutex::new(HashMap::new()), }); @@ -342,7 +342,7 @@ impl RemoteSession { let protocol = ident.get("protocol").and_then(|v| v.as_u64()).unwrap_or(0); if protocol != SUPPORTED_PROTOCOL_VERSION { anyhow::bail!( - "unsupported cmux-tui protocol {protocol}; this client requires protocol 6; restart the cmux-tui server" + "unsupported cmux-tui protocol {protocol}; this client requires protocol 7; restart the cmux-tui server" ); } session.request(json!({"cmd": "subscribe"}))?; @@ -350,14 +350,11 @@ impl RemoteSession { } fn emit(&self, event: MuxEvent) { - let mut subs = self.subscribers.lock().unwrap(); - subs.retain(|tx| tx.send(event.clone()).is_ok()); + self.subscribers.emit(event); } - pub fn subscribe(&self) -> Receiver { - let (tx, rx) = channel(); - self.subscribers.lock().unwrap().push(tx); - rx + pub fn subscribe(&self) -> MuxEventReceiver { + self.subscribers.subscribe() } fn handle_line(self: &Arc, value: Value) { diff --git a/cmux-tui/crates/cmux-tui/tests/cli.rs b/cmux-tui/crates/cmux-tui/tests/cli.rs index 6a4e044d290b..2af3dc768d5d 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli.rs @@ -69,7 +69,7 @@ fn cli_verbs_cover_command_output_errors_and_streams() { assert_success(&ping_json); let ping: serde_json::Value = serde_json::from_slice(&ping_json.stdout).unwrap(); assert_eq!(ping.get("ok").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(ping.get("protocol").and_then(|v| v.as_u64()), Some(6)); + assert_eq!(ping.get("protocol").and_then(|v| v.as_u64()), Some(7)); let title = cli(&server, &["set-window-title", "--title", "hello"]); assert_success(&title); diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index 531d7855dc0d..50e48cd6454f 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -83,6 +83,12 @@ impl MouseEncoder { self.terminal_state = Some(state); } + /// Forget the last encoded motion cell so an event that was not delivered + /// can be encoded again at the same coordinates. + pub fn reset_motion_dedupe(&mut self) { + unsafe { sys::ghostty_mouse_encoder_reset(self.encoder) }; + } + pub fn encode(&mut self, input: MouseInput, out: &mut Vec) -> Result<()> { let action = match input.action { MouseAction::Press => sys::GHOSTTY_MOUSE_ACTION_PRESS, @@ -282,4 +288,23 @@ mod tests { encoder.encode(event, &mut out).unwrap(); assert_eq!(out, b"\x1b[<35;10;6M"); } + + #[test] + fn reset_allows_same_cell_motion_to_be_encoded_again() { + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1003h\x1b[?1006h"); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + let event = input(MouseAction::Motion, None); + let mut out = Vec::new(); + + encoder.encode(event, &mut out).unwrap(); + out.clear(); + encoder.encode(event, &mut out).unwrap(); + assert!(out.is_empty()); + + encoder.reset_motion_dedupe(); + encoder.encode(event, &mut out).unwrap(); + assert_eq!(out, b"\x1b[<35;5;3M"); + } } diff --git a/cmux-tui/docs/README.md b/cmux-tui/docs/README.md index 1c27256bac17..2fb11447b862 100644 --- a/cmux-tui/docs/README.md +++ b/cmux-tui/docs/README.md @@ -9,5 +9,5 @@ - [Keyboard](keyboard.md): prefix model, modeless Alt layer, default bindings, and `cmux-tui.json` key remapping. - [Mouse](mouse.md): clickable UI, drag reorder, resize, scrollbars, menus, selection, pointer shape, and dialogs. - [Configuration](configuration.md): full `cmux-tui.json` reference with defaults and a worked example. -- [Control socket protocol](protocol.md): JSON-lines framing, protocol v6 attach streams, events, and compatibility rules. +- [Control socket protocol](protocol.md): JSON-lines framing, protocol v7 events, attach streams, and compatibility rules. - [Browser panes](browser-panes.md): CDP-backed browser tabs, rendering, input, profiles, and current limitations. diff --git a/cmux-tui/docs/browser-panes.md b/cmux-tui/docs/browser-panes.md index af8f3e4af26c..3ac85861db60 100644 --- a/cmux-tui/docs/browser-panes.md +++ b/cmux-tui/docs/browser-panes.md @@ -49,6 +49,6 @@ The default launched profile is `~/Library/Application Support/cmux-tui/chrome-p ## Limitations -Browser panes are local-only as of protocol v6. `attach-surface` returns an error for browser surfaces, attach clients do not receive browser frame streams, and a remote TUI shows a placeholder for browser panes. +Browser panes are local-only as of protocol v7. `attach-surface` returns an error for browser surfaces, attach clients do not receive browser frame streams, and a remote TUI shows a placeholder for browser panes. Headful external Chrome can throttle screencast frames when the window or tab is hidden or occluded. Chrome 136 and newer do not allow `--remote-debugging-port` with the default user data directory, so reusable everyday Chrome profiles may not expose CDP. diff --git a/cmux-tui/docs/concepts.md b/cmux-tui/docs/concepts.md index 35f351820634..0ab1e835e155 100644 --- a/cmux-tui/docs/concepts.md +++ b/cmux-tui/docs/concepts.md @@ -46,4 +46,4 @@ Closing a pane closes all tabs in that pane. Closing a screen closes every pane A PTY surface parses child-process output with libghostty-vt. Frontends render snapshots of that terminal state. Attach clients receive a VT replay first, then a base64 stream of subsequent PTY bytes, plus ordered resize frames when the surface geometry changes. -A browser surface is a local Chrome/Chromium target controlled through the Chrome DevTools Protocol. The local TUI draws browser frames with kitty graphics and forwards keyboard, mouse, and wheel input over CDP. Browser surfaces are listed in the tree, but `attach-surface` does not stream browser pixels as of protocol v6. +A browser surface is a local Chrome/Chromium target controlled through the Chrome DevTools Protocol. The local TUI draws browser frames with kitty graphics and forwards keyboard, mouse, and wheel input over CDP. Browser surfaces are listed in the tree, but `attach-surface` does not stream browser pixels as of protocol v7. diff --git a/cmux-tui/docs/protocol.md b/cmux-tui/docs/protocol.md index 06313c8cc0bd..39124aaee9d1 100644 --- a/cmux-tui/docs/protocol.md +++ b/cmux-tui/docs/protocol.md @@ -1,6 +1,6 @@ # Control Socket Protocol -As of protocol v6, every server speaks JSON Lines over a Unix domain socket. Send one JSON object per line. Every request receives one response line. `subscribe` and `attach-surface` also push event lines on the same connection. +As of protocol v7, every server speaks JSON Lines over a Unix domain socket. Send one JSON object per line. Every request receives one response line. `subscribe` and `attach-surface` also push event lines on the same connection. For shell use, prefer `cmux-tui `; it wraps the same socket commands and preserves JSON output with `--json`. @@ -14,7 +14,7 @@ $TMPDIR/cmux-tui-/.sock ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"...","protocol":6,"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"...","protocol":7,"session":"main","pid":12345}} ``` Responses have this shape: @@ -93,7 +93,7 @@ Subscribed event lines are: {"event":"surface-output","surface":4} {"event":"surface-resized","surface":4,"cols":120,"rows":40} {"event":"surface-exited","surface":4} -{"event":"title-changed","surface":4} +{"event":"title-changed","surface":4,"title":"build logs"} {"event":"bell","surface":4} {"event":"tree-changed"} {"event":"empty"} @@ -101,6 +101,8 @@ Subscribed event lines are: `surface-resized` reports the final clamped cell size and is emitted only when the surface size actually changes. +Protocol v7 `title-changed` carries the authoritative current `title`. Slow subscribers coalesce repeated pending title changes per surface to the latest value. + ## Attach Surface `attach-surface` streams a PTY surface. Browser surfaces return `browser panes are not supported over attach yet`. @@ -132,7 +134,7 @@ When the stream ends, it sends: ## Client Compatibility -The remote TUI requires protocol v6. It refuses servers reporting any other protocol version because attach streams need resize markers carrying replay data. +The remote TUI requires protocol v7. It refuses servers reporting any other protocol version because it relies on resized attach replays and authoritative title events. Attach clients mirror PTY surfaces locally. On first render, a client can resize the server surface before requesting `attach-surface`, so the initial VT replay is captured at the visible geometry. @@ -140,4 +142,4 @@ When several attach clients render the same surface at different sizes, sizing f ## Browser Limitations -Browser surfaces appear in `list-workspaces` as `kind: "browser"` with `browser_source: "external"` or `"launched"`. PTY and VT commands against browser surfaces return errors. `attach-surface` does not stream browser pixels as of protocol v6, and the remote TUI shows a placeholder for browser panes. +Browser surfaces appear in `list-workspaces` as `kind: "browser"` with `browser_source: "external"` or `"launched"`. PTY and VT commands against browser surfaces return errors. `attach-surface` does not stream browser pixels as of protocol v7, and the remote TUI shows a placeholder for browser panes. diff --git a/cmux-tui/spec/README.md b/cmux-tui/spec/README.md index b24974af34f3..aab1bf907c53 100644 --- a/cmux-tui/spec/README.md +++ b/cmux-tui/spec/README.md @@ -1,6 +1,6 @@ # cmux-tui Programmability Contract -This directory is the source of truth for the cmux-tui control protocol, the generated `cmux-tui` command surface, plugin contracts, and future generated language bindings. The implemented protocol described here is protocol version 6, as defined by `cmux-tui-core/src/server.rs`. +This directory is the source of truth for the cmux-tui control protocol, the generated `cmux-tui` command surface, plugin contracts, and future generated language bindings. The implemented protocol described here is protocol version 7, as defined by `cmux-tui-core/src/server.rs`. The spec is intentionally stricter than prose docs. Implemented commands and events describe the current server behavior exactly, including awkward result shapes and no-op cases. Proposed commands, events, transports, and config are marked `proposed` and are not part of the implemented protocol. @@ -14,7 +14,7 @@ The spec version tracks the mux protocol version. | Additive command, event, field, CLI flag, binding helper, or transport option | Minor protocol version | | Removal, rename, incompatible type change, changed error semantics, or changed ordering guarantee | Major protocol version | -Protocol v6 is the implemented baseline. Proposed additions in this directory target the next minor protocol unless a later spec says otherwise. +Protocol v7 is the implemented baseline. Proposed additions in this directory target the next minor protocol unless a later spec says otherwise. Generated clients must inspect `identify.protocol` before using features newer than the connected server. Bindings may expose proposed APIs behind version checks, but they must not send proposed commands to an older server unless the caller explicitly opts into probing. @@ -39,4 +39,4 @@ The generator must preserve the wire command names, parameter names, result shap ## Implemented Inventory -Protocol v6 implements the socket commands listed in `commands.md` and the event names listed in `events.md`. Events include subscribe events, attach-stream events, and the implemented `empty` and `detached` lifecycle events. +Protocol v7 implements the socket commands listed in `commands.md` and the event names listed in `events.md`. Events include subscribe events, attach-stream events, and the implemented `empty` and `detached` lifecycle events. diff --git a/cmux-tui/spec/bindings.md b/cmux-tui/spec/bindings.md index 3f8c87adcada..59194a4e8be0 100644 --- a/cmux-tui/spec/bindings.md +++ b/cmux-tui/spec/bindings.md @@ -2,7 +2,7 @@ Generated bindings live under `cmux-tui/bindings//` in a future round. They are generated from this spec and validated by the conformance suite in this file. -All bindings must expose the implemented protocol v5 commands, events, and socket transport. Proposed protocol v6 APIs may be generated behind explicit version checks or feature gates. +All bindings must expose the implemented protocol v7 commands, events, and socket transport. APIs newer than the connected server must be guarded by explicit version checks or feature gates. ## Shared Requirements @@ -16,9 +16,10 @@ Bindings must: | Error handling | Preserve the server error string and expose a typed transport vs command distinction | | Events | Route response lines and event lines correctly on full-duplex connections | | Attach | Preserve attach ordering for the negotiated protocol: v5 `vt-state`, then `output`, then `detached`; v6 `vt-state`, then `(resized | output)*`, then `detached` | +| Title changes | Decode `title-changed` as a typed event with `surface` and an optional `title`; protocol v7 guarantees the authoritative title, while v5-v6 omit it | | JSON mode | Provide a way to send raw command JSON for forward compatibility | | Timeouts | Let callers configure request timeout without changing wire schema | -| Ids | Use numeric ids for v5 and `IdRef` for proposed v6 | +| Ids | Use numeric ids for v5 and `IdRef` where supported by the negotiated protocol | ## Rust diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 17f6cc0ad703..dd1cc7dcf26b 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -1,6 +1,6 @@ # Command Contract -This file specifies the JSON command contract for the cmux-tui protocol. Implemented commands match protocol v6 in `cmux-tui/crates/cmux-tui-core/src/server.rs`. +This file specifies the JSON command contract for the cmux-tui protocol. Implemented commands match protocol v7 in `cmux-tui/crates/cmux-tui-core/src/server.rs`. ## Notation @@ -131,7 +131,7 @@ Example: ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","protocol":6,"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","protocol":7,"session":"main","pid":12345}} ``` ### ping @@ -160,7 +160,7 @@ Example: ```json {"id":2,"cmd":"ping"} -{"id":2,"ok":true,"data":{"ok":true,"version":"0.1.0","protocol":6}} +{"id":2,"ok":true,"data":{"ok":true,"version":"0.1.0","protocol":7}} ``` ### reload-config @@ -2272,7 +2272,7 @@ Example: ## Proposed Hooks Config -Hooks are proposed protocol v6 config, not a socket command. They are declared in `~/.config/cmux/cmux-tui.json` under `hooks`, with legacy `mux.json` still accepted. +Hooks are proposed protocol v8 config, not a socket command. They are declared in `~/.config/cmux/cmux-tui.json` under `hooks`, with legacy `mux.json` still accepted. Schema: diff --git a/cmux-tui/spec/events.md b/cmux-tui/spec/events.md index 60c2563dfef4..be24e431bded 100644 --- a/cmux-tui/spec/events.md +++ b/cmux-tui/spec/events.md @@ -1,6 +1,6 @@ # Event Contract -This file specifies event lines emitted by protocol v5 and proposed protocol v6. Event lines are JSON objects with an `event` string and no response envelope. +This file specifies event lines emitted by protocol v7, including compatibility notes for fields and attach behavior introduced in earlier versions. Event lines are JSON objects with an `event` string and no response envelope. Implemented event lines can appear on two stream types: @@ -16,7 +16,9 @@ Events and command responses share one JSON-lines connection. Clients must route The socket writes each response or event as one complete JSON line. Lines are not interleaved at the byte level. -For a single subscription, events are delivered in the order the mux broadcasts them. The server does not create a total order across unrelated producer threads beyond the order in which events enter the mux broadcaster. +For a single subscription, ordinary events are delivered in the order the mux broadcasts them. The server does not create a total order across unrelated producer threads beyond the order in which events enter the mux broadcaster. + +Protocol v7 treats `title-changed` as a latest-state notification. A slow subscriber retains at most one pending title per surface. Repeated pending titles for the same surface coalesce to the newest `title` and take the newest event's position relative to ordinary events. Subscribers are independent, and a pending title is discarded when its surface exits. `subscribe` registers the event receiver before the command response is written. A client must not treat the `subscribe` response as an event-stream barrier. @@ -171,19 +173,20 @@ Example: | event | `title-changed` | | status | implemented | | since | protocol 5 | +| `title` field | protocol 7 | Payload: ```text -object{event:"title-changed",surface:Id} +object{event:"title-changed",surface:Id,title:string} ``` -Meaning: A surface title changed. The event does not include the new title. Clients should call `list-workspaces` to read the current tab title. +Meaning: A surface title changed. Protocol v7 includes the authoritative current title, so clients can update that surface directly without fetching the workspace tree. Protocol v5-v6 events omit `title`; clients connected to those versions must call `list-workspaces`. Example: ```json -{"event":"title-changed","surface":1} +{"event":"title-changed","surface":1,"title":"build logs"} ``` ### bell @@ -394,7 +397,7 @@ Example: | --- | --- | | event | `agent-state-changed` | | status | proposed | -| since | proposed protocol 6 | +| since | proposed protocol 8 | Payload: @@ -424,7 +427,7 @@ Example: | --- | --- | | event | `notification` | | status | proposed | -| since | proposed protocol 6 | +| since | proposed protocol 8 | Payload: @@ -450,7 +453,7 @@ Example: ## Proposed Subscribe Filters -Protocol v6 extends `subscribe` with optional filters: +Proposed protocol v8 extends `subscribe` with optional filters: Params: diff --git a/cmux-tui/spec/transports.md b/cmux-tui/spec/transports.md index a2f3d223c9bb..ee8b305dcdac 100644 --- a/cmux-tui/spec/transports.md +++ b/cmux-tui/spec/transports.md @@ -1,6 +1,6 @@ # Transport Contract -The command schema is transport-independent. Protocol v5 implements a Unix domain socket JSON-lines transport. Protocol v6 proposes HTTP, SSE, and WebSocket transports that preserve the same command and event payloads. +The command schema is transport-independent. Protocol v5 implements a Unix domain socket JSON-lines transport. Proposed protocol v8 adds HTTP, SSE, and WebSocket transports that preserve the same command and event payloads. ## Unix Socket @@ -69,7 +69,7 @@ Access to the Unix socket is equivalent to access to the mux session. A client c ### Optional Socket Token -Protocol v6 may add an optional token to the socket transport for parity with HTTP. Filesystem permissions remain the primary protection. The exact framing is deferred until the v6 implementation. +Protocol v8 may add an optional token to the socket transport for parity with HTTP. Filesystem permissions remain the primary protection. The exact framing is deferred until that implementation. A compatible deferred design is an initial auth line before any command: @@ -85,7 +85,7 @@ If auth fails, the server responds with `{"ok":false,"error":"invalid token"}` a | Field | Value | | --- | --- | | status | proposed | -| since | proposed protocol 6 | +| since | proposed protocol 8 | HTTP is opt-in. The server binds localhost by default when enabled: @@ -158,7 +158,7 @@ The attach ordering contract is identical to the socket `attach-surface` command | Field | Value | | --- | --- | | status | proposed | -| since | proposed protocol 6 | +| since | proposed protocol 8 | When HTTP is enabled securely, the server mints one token per mux session at: From 14dfa79f2a4a09cfb59c90f78037f19d34021e33 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:34:33 -0700 Subject: [PATCH 032/103] Bound UI title and drag hot paths --- cmux-tui/bindings/python/cmux/client.py | 2 + cmux-tui/bindings/python/e2e.py | 23 ++ cmux-tui/bindings/python/tests/test_events.py | 31 ++ cmux-tui/crates/cmux-tui/src/app.rs | 333 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/pty_input.rs | 37 +- 5 files changed, 366 insertions(+), 60 deletions(-) create mode 100644 cmux-tui/bindings/python/tests/test_events.py diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index 8a5370eb2c3f..b974f85a6bff 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -151,6 +151,7 @@ class Event: replay: Optional[str] = None offset: Optional[int] = None at_bottom: Optional[bool] = None + title: Optional[str] = None @property def bytes_data(self) -> Optional[bytes]: @@ -614,4 +615,5 @@ def _parse_event(value: Dict[str, Any]) -> Event: replay=value.get("replay"), offset=value.get("offset"), at_bottom=value.get("at_bottom"), + title=value.get("title"), ) diff --git a/cmux-tui/bindings/python/e2e.py b/cmux-tui/bindings/python/e2e.py index e8a9fda96493..0765ac019555 100755 --- a/cmux-tui/bindings/python/e2e.py +++ b/cmux-tui/bindings/python/e2e.py @@ -30,6 +30,10 @@ def main() -> int: client.rename_surface(created.surface, f"{marker}-renamed") events = client.subscribe() try: + title = f"{marker}_TITLE" + client.send(created.surface, text=f"printf '\\033]2;{title}\\007'; sleep 1\r") + title_changed = next_title_changed(events, created.surface, title, 1.0) + assert title_changed.title == title, title_changed client.resize_surface(created.surface, 100, 31) resized = next_resized(events, created.surface, 1.0) assert (resized.cols, resized.rows) == (100, 31) @@ -87,6 +91,25 @@ def next_resized(stream, surface: int, timeout: float): raise CmuxTimeoutError("surface-resized not observed") +def next_title_changed(stream, surface: int, title: str, timeout: float): + deadline = time.time() + timeout + old_timeout = stream._conn.sock.gettimeout() + stream._conn.sock.settimeout(timeout) + try: + while time.time() < deadline: + stream._conn.sock.settimeout(max(deadline - time.time(), 0.001)) + event = next(stream) + if ( + event.event == "title-changed" + and event.surface == surface + and event.title == title + ): + return event + finally: + stream._conn.sock.settimeout(old_timeout) + raise CmuxTimeoutError("title-changed not observed") + + def next_attach_output(stream, timeout: float) -> None: deadline = time.time() + timeout old_timeout = stream._conn.sock.gettimeout() diff --git a/cmux-tui/bindings/python/tests/test_events.py b/cmux-tui/bindings/python/tests/test_events.py new file mode 100644 index 000000000000..a2297c3431bd --- /dev/null +++ b/cmux-tui/bindings/python/tests/test_events.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import unittest + +from cmux.client import _parse_event + + +class EventTests(unittest.TestCase): + def test_title_changed_decodes_authoritative_title(self) -> None: + event = _parse_event( + { + "event": "title-changed", + "surface": 7, + "title": "build logs", + } + ) + + self.assertEqual(event.event, "title-changed") + self.assertEqual(event.surface, 7) + self.assertEqual(event.title, "build logs") + + def test_legacy_title_changed_keeps_title_optional(self) -> None: + event = _parse_event({"event": "title-changed", "surface": 7}) + + self.assertEqual(event.event, "title-changed") + self.assertEqual(event.surface, 7) + self.assertIsNone(event.title) + + +if __name__ == "__main__": + unittest.main() diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index cd2fb9146424..5abfc9194d31 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -56,12 +56,48 @@ const ROUTING_REFRESH_RETRIES: u8 = 1; pub enum AppEvent { Mux(MuxEvent), + MuxTitlesReady, Input(Event), PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), RemoteTreeUpdated(Result), } +#[derive(Default)] +struct MuxTitleIngress { + state: Mutex, +} + +#[derive(Default)] +struct MuxTitleIngressState { + wake_queued: bool, + titles: HashMap, +} + +impl MuxTitleIngress { + /// Returns true when the app channel needs one wake event. + fn push(&self, surface: SurfaceId, title: String) -> bool { + let mut state = self.state.lock().unwrap(); + state.titles.insert(surface, title); + if state.wake_queued { + false + } else { + state.wake_queued = true; + true + } + } + + fn remove(&self, surface: SurfaceId) { + self.state.lock().unwrap().titles.remove(&surface); + } + + fn take(&self) -> HashMap { + let mut state = self.state.lock().unwrap(); + state.wake_queued = false; + std::mem::take(&mut state.titles) + } +} + pub enum SessionMutationOutcome { Success { tree: Option, @@ -207,6 +243,7 @@ impl OrderedSession { let _ = self.events.send(AppEvent::PtyOperationFailed(PtyOperationFailure { surface_id: None, kind: None, + reservation_id: None, label: "remote tree refresh", error: error.to_string(), lane_failed: false, @@ -402,7 +439,6 @@ impl OrderedSession { SplitDir::Down => "vertical split ratio", }; let pending = self.pending_mutation(); - let remote = self.remote; let committed_mutation_generation = self.committed_mutation_generation.clone(); self.operations.enqueue_coalescing_mutation( "resize pane split", @@ -414,21 +450,7 @@ impl OrderedSession { return Err(error); } committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - if remote { - pending.settle(SessionMutationOutcome::Success { tree: None }); - } else { - match session.refresh_tree() { - Ok(tree) => { - pending.settle(SessionMutationOutcome::Success { tree: Some(tree) }); - } - Err(error) => { - pending.settle(SessionMutationOutcome::CommittedTreeStale { - error: Some(error.to_string()), - completion: None, - }); - } - } - } + pending.settle(SessionMutationOutcome::Success { tree: None }); Ok(()) }, ); @@ -495,6 +517,7 @@ impl Deref for OrderedSession { enum RenderAction { None, Graphics, + Paint, Draw, } @@ -502,6 +525,7 @@ impl RenderAction { fn merge(self, other: Self) -> Self { match (self, other) { (RenderAction::Draw, _) | (_, RenderAction::Draw) => RenderAction::Draw, + (RenderAction::Paint, _) | (_, RenderAction::Paint) => RenderAction::Paint, (RenderAction::Graphics, _) | (_, RenderAction::Graphics) => RenderAction::Graphics, (RenderAction::None, RenderAction::None) => RenderAction::None, } @@ -818,6 +842,7 @@ enum Drag { /// Mouse reporting owned by the PTY application in this pane. PtyMouse { surface: SurfaceId, + reservation_id: u64, content: Rect, button: MouseButton, position: (u16, u16), @@ -838,6 +863,12 @@ enum PtyMousePressResult { Started, } +#[derive(Debug, Clone, Copy)] +struct PtyInputForwardResult { + accepted: bool, + reservation_id: Option, +} + pub struct App { pub session: OrderedSession, pub config: Config, @@ -888,6 +919,7 @@ pub struct App { routing_refresh_pending: bool, routing_refresh_retries_remaining: u8, pending_session_completions: VecDeque, + mux_titles: Arc, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -1049,13 +1081,27 @@ pub fn run( })?; let session = OrderedSession::new(session, pty_input.sender(), tx.clone()); let stdout_lock = Arc::new(Mutex::new(())); + let mux_titles = Arc::new(MuxTitleIngress::default()); // Session events → app channel. let session_events = session.events(); std::thread::Builder::new().name("mux-events".into()).spawn({ let tx = tx.clone(); + let mux_titles = mux_titles.clone(); move || { while let Ok(event) = session_events.recv() { + match event { + MuxEvent::TitleChanged { surface, title } => { + if mux_titles.push(surface, title) + && tx.send(AppEvent::MuxTitlesReady).is_err() + { + break; + } + continue; + } + MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), + _ => {} + } if tx.send(AppEvent::Mux(event)).is_err() { break; } @@ -1155,6 +1201,7 @@ pub fn run( routing_refresh_pending: false, routing_refresh_retries_remaining: 0, pending_session_completions: VecDeque::new(), + mux_titles, drag: None, encoder, mouse_encoder, @@ -1265,6 +1312,10 @@ impl App { self.draw_terminal(terminal)?; self.emit_graphics()?; } + RenderAction::Paint => { + self.draw_terminal(terminal)?; + self.emit_graphics()?; + } RenderAction::Graphics => self.emit_graphics()?, RenderAction::None => {} } @@ -1304,6 +1355,30 @@ impl App { } } + fn apply_mux_titles(&mut self) -> bool { + let mut titles = self.mux_titles.take(); + if titles.is_empty() { + return false; + } + let mut changed = false; + for tab in self + .tree + .workspaces + .iter_mut() + .flat_map(|workspace| workspace.screens.iter_mut()) + .flat_map(|screen| screen.panes.iter_mut()) + .flat_map(|pane| pane.tabs.iter_mut()) + { + if let Some(title) = titles.remove(&tab.surface) + && tab.title != title + { + tab.title = title; + changed = true; + } + } + changed + } + fn apply_session_completions_through(&mut self, authoritative_generation: u64) { while self .pending_session_completions @@ -1574,6 +1649,9 @@ impl App { event => event, }; match event { + AppEvent::MuxTitlesReady => { + Ok(if self.apply_mux_titles() { RenderAction::Paint } else { RenderAction::None }) + } AppEvent::Mux(MuxEvent::Empty) => { self.quit = true; Ok(RenderAction::None) @@ -1632,9 +1710,11 @@ impl App { } let failed_active_press = failure.kind == Some(PtyInputKind::Press) && failure.delivery == PtyOperationDelivery::KnownNotDelivered - && failure.surface_id.is_some_and(|surface| { - matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) - }); + && failure.surface_id.zip(failure.reservation_id).is_some_and( + |(surface, reservation_id)| { + matches!(&self.drag, Some(Drag::PtyMouse { surface: active_surface, reservation_id: active_reservation, .. }) if *active_surface == surface && *active_reservation == reservation_id) + }, + ); if failed_active_press || failure.lane_failed { self.drag = None; } @@ -3057,7 +3137,7 @@ impl App { } self.sidebar_focused = false; self.selection = None; - if !self.forward_pty_mouse_to_surface( + let forwarded = self.forward_pty_mouse_to_surface( area.surface, area.content, x, @@ -3066,11 +3146,16 @@ impl App { Some(Self::ghostty_mouse_button(button)), modifiers, true, - ) { + ); + if !forwarded.accepted { return PtyMousePressResult::Consumed; } + let Some(reservation_id) = forwarded.reservation_id else { + return PtyMousePressResult::Consumed; + }; self.drag = Some(Drag::PtyMouse { surface: area.surface, + reservation_id, content: area.content, button, position: (x, y), @@ -3131,16 +3216,18 @@ impl App { return true; } let content = self.current_pty_content(surface).unwrap_or(content); - let delivered = self.forward_pty_mouse_to_surface( - surface, - content, - x, - y, - MouseAction::Release, - Some(Self::ghostty_mouse_button(active_button)), - modifiers, - false, - ); + let delivered = self + .forward_pty_mouse_to_surface( + surface, + content, + x, + y, + MouseAction::Release, + Some(Self::ghostty_mouse_button(active_button)), + modifiers, + false, + ) + .accepted; if delivered { self.drag = None; } @@ -3148,7 +3235,7 @@ impl App { } fn cancel_pty_mouse_drag(&mut self) { - let Some(Drag::PtyMouse { surface, content, button, position, modifiers }) = self.drag + let Some(Drag::PtyMouse { surface, content, button, position, modifiers, .. }) = self.drag else { return; }; @@ -3158,16 +3245,18 @@ impl App { return; } let content = self.current_pty_content(surface).unwrap_or(content); - let delivered = self.forward_pty_mouse_to_surface( - surface, - content, - position.0, - position.1, - MouseAction::Release, - Some(Self::ghostty_mouse_button(button)), - modifiers, - false, - ); + let delivered = self + .forward_pty_mouse_to_surface( + surface, + content, + position.0, + position.1, + MouseAction::Release, + Some(Self::ghostty_mouse_button(button)), + modifiers, + false, + ) + .accepted; if delivered { self.drag = None; } @@ -3216,8 +3305,10 @@ impl App { button: Option, modifiers: KeyModifiers, any_button_pressed: bool, - ) -> bool { - let Some(surface) = self.session.surface(surface_id) else { return false }; + ) -> PtyInputForwardResult { + let Some(surface) = self.session.surface(surface_id) else { + return PtyInputForwardResult { accepted: false, reservation_id: None }; + }; let cell_width = u32::from(self.cell_pixels.0.max(1)); let cell_height = u32::from(self.cell_pixels.1.max(1)); let position = ( @@ -3242,16 +3333,16 @@ impl App { self.mouse_encoder.sync_from_terminal(terminal); self.mouse_encoder.encode(input, &mut self.encode_buf) }) else { - return false; + return PtyInputForwardResult { accepted: false, reservation_id: None }; }; if encoded.is_err() { - return false; + return PtyInputForwardResult { accepted: false, reservation_id: None }; } if self.encode_buf.is_empty() { if action == MouseAction::Release { self.pty_input.cancel_release_reservation(); } - return true; + return PtyInputForwardResult { accepted: true, reservation_id: None }; } let kind = match action { MouseAction::Press @@ -3278,9 +3369,9 @@ impl App { surface_id: SurfaceId, surface: SurfaceHandle, kind: PtyInputKind, - ) -> bool { + ) -> PtyInputForwardResult { let bytes = PtyInputBytes::from_slice(&self.encode_buf); - self.write_pty_bytes(surface_id, surface, bytes, kind) + self.enqueue_pty_bytes(surface_id, surface, bytes, kind) } fn write_pty_bytes( @@ -3290,9 +3381,21 @@ impl App { bytes: PtyInputBytes, kind: PtyInputKind, ) -> bool { - let result = self.pty_input.enqueue(PtyInputEvent::input(surface_id, surface, bytes, kind)); + self.enqueue_pty_bytes(surface_id, surface, bytes, kind).accepted + } + + fn enqueue_pty_bytes( + &mut self, + surface_id: SurfaceId, + surface: SurfaceHandle, + bytes: PtyInputBytes, + kind: PtyInputKind, + ) -> PtyInputForwardResult { + let (result, reservation_id) = self + .pty_input + .enqueue_with_reservation(PtyInputEvent::input(surface_id, surface, bytes, kind)); self.rollback_mouse_motion_for_enqueue_failure(kind, result); - self.handle_pty_enqueue_result(result) + PtyInputForwardResult { accepted: self.handle_pty_enqueue_result(result), reservation_id } } fn rollback_mouse_motion_for_enqueue_failure( @@ -4373,8 +4476,9 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, AppEvent, Drag, OrderedSession, PaneArea, SessionCompletion, SessionCompletionAction, - browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, + App, AppEvent, Drag, MuxTitleIngress, OrderedSession, PaneArea, RenderAction, + SessionCompletion, SessionCompletionAction, browser_content_size_for_rect, + browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::Ordering; @@ -4382,7 +4486,9 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; - use cmux_tui_core::{BrowserStatus, Mux, Node, Rect, SurfaceId, SurfaceKind, SurfaceOptions}; + use cmux_tui_core::{ + BrowserStatus, Mux, Node, Rect, SplitDir, SurfaceId, SurfaceKind, SurfaceOptions, + }; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -4587,6 +4693,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + reservation_id: 1, content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Right, position: (4, 3), @@ -4596,6 +4703,7 @@ mod tests { app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { surface_id: Some(42), kind: Some(PtyInputKind::Motion), + reservation_id: None, label: "PTY input", error: "write failed".to_string(), lane_failed: false, @@ -4644,6 +4752,7 @@ mod tests { app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { surface_id: Some(42), kind: Some(PtyInputKind::Motion), + reservation_id: None, label: "PTY input", error: "remote session did not respond".to_string(), lane_failed: false, @@ -4655,6 +4764,7 @@ mod tests { app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { surface_id: Some(42), kind: Some(PtyInputKind::Motion), + reservation_id: None, label: "PTY input", error: "canceled before delivery".to_string(), lane_failed: false, @@ -4670,6 +4780,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + reservation_id: 1, content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Right, position: (4, 3), @@ -4679,6 +4790,7 @@ mod tests { app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { surface_id: Some(42), kind: Some(PtyInputKind::Motion), + reservation_id: None, label: "PTY input", error: "remote session did not respond".to_string(), lane_failed: true, @@ -4695,6 +4807,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + reservation_id: 7, content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Left, position: (4, 3), @@ -4704,6 +4817,7 @@ mod tests { app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { surface_id: Some(42), kind: Some(PtyInputKind::Press), + reservation_id: Some(7), label: "PTY input", error: "remote session did not respond".to_string(), lane_failed: false, @@ -4714,6 +4828,44 @@ mod tests { assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Left, .. }))); } + #[test] + fn old_press_failure_does_not_clear_new_press_on_the_same_surface() { + let mux = Mux::new("press-reservation-identity-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.drag = Some(Drag::PtyMouse { + surface: 42, + reservation_id: 9, + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Left, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Press), + reservation_id: Some(8), + label: "PTY input", + error: "old press rejected".to_string(), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + })) + .unwrap(); + assert!(matches!(app.drag, Some(Drag::PtyMouse { reservation_id: 9, .. }))); + + app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Press), + reservation_id: Some(9), + label: "PTY input", + error: "current press rejected".to_string(), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + })) + .unwrap(); + assert!(app.drag.is_none()); + } + #[test] fn input_waits_for_prior_session_mutation_to_settle() { let mux = Mux::new("pending-mutation-input-test", SurfaceOptions::default()); @@ -4769,6 +4921,32 @@ mod tests { )); } + #[test] + fn title_ingress_keeps_latest_per_surface_with_one_app_wake() { + let mux = Mux::new("title-ingress-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let updated_surface = 41; + let untouched_surface = 42; + app.tree = browser_completion_tree(updated_surface, untouched_surface); + let (wake_tx, wake_rx) = std::sync::mpsc::channel(); + + for index in 0..10_000 { + if app.mux_titles.push(updated_surface, format!("title-{index}")) { + wake_tx.send(AppEvent::MuxTitlesReady).unwrap(); + } + if app.mux_titles.push(99, format!("unknown-{index}")) { + wake_tx.send(AppEvent::MuxTitlesReady).unwrap(); + } + } + + let wake = wake_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(wake_rx.try_recv().is_err(), "title churn must queue one app wake"); + assert!(matches!(app.handle(wake).unwrap(), RenderAction::Paint)); + assert_eq!(app.tree.pane(2).unwrap().tabs[0].title, "title-9999"); + assert_eq!(app.tree.pane(2).unwrap().tabs[1].title, ""); + assert!(app.mux_titles.push(updated_surface, "next".to_string())); + } + #[test] fn browser_completion_waits_for_its_authoritative_identity_generation() { let mux = Mux::new("browser-completion-generation-test", SurfaceOptions::default()); @@ -4908,6 +5086,51 @@ mod tests { assert!(app.drag.is_none()); } + #[test] + fn split_ratio_samples_coalesce_without_snapshots_before_final_settlement() { + let mux = Mux::new("split-ratio-snapshot-test", SurfaceOptions::default()); + mux.new_workspace(None, Some((40, 12))).unwrap(); + let target = Session::Local(mux.clone()).tree().active_screen().unwrap().active_pane; + mux.split(target, SplitDir::Right, Some((20, 12))).unwrap(); + let (app, events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session.operations.enqueue_session_mutation("block ratio lane", false, move || { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + for ratio in [0.2, 0.4, 0.8] { + app.session.set_ratio_deferred(target, SplitDir::Right, ratio); + } + app.session.settle_split_ratio(); + release_tx.send(()).unwrap(); + + let mut canceled = 0; + let mut sample_without_tree = 0; + let mut authoritative_snapshots = 0; + for _ in 0..4 { + match events.recv_timeout(Duration::from_secs(1)).unwrap() { + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Canceled) => { + canceled += 1; + } + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Success { + tree: None, + }) => sample_without_tree += 1, + AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::AuthoritativeMutationSucceeded { .. }, + ) => authoritative_snapshots += 1, + _ => panic!("unexpected split ratio settlement"), + } + } + + assert_eq!(canceled, 2, "only the latest deferred ratio should execute"); + assert_eq!(sample_without_tree, 1, "the retained sample must not build a tree"); + assert_eq!(authoritative_snapshots, 1, "final settlement must snapshot exactly once"); + } + #[test] fn deferred_pointer_motion_keeps_only_the_latest_sample() { let mux = Mux::new("deferred-motion-test", SurfaceOptions::default()); @@ -4975,6 +5198,7 @@ mod tests { app.session.pending_mutations.store(1, Ordering::Release); app.drag = Some(Drag::PtyMouse { surface: 42, + reservation_id: 1, content: Rect { x: 2, y: 3, width: 20, height: 8 }, button: MouseButton::Right, position: (5, 5), @@ -5341,6 +5565,7 @@ mod tests { routing_refresh_pending: false, routing_refresh_retries_remaining: 0, pending_session_completions: VecDeque::new(), + mux_titles: Arc::new(MuxTitleIngress::default()), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 670fab6f729c..ec08509c9a21 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -101,6 +101,7 @@ impl PtyInputEvent { pub struct PtyOperationFailure { pub surface_id: Option, pub kind: Option, + pub reservation_id: Option, pub label: &'static str, pub error: String, pub lane_failed: bool, @@ -193,10 +194,18 @@ impl PtyInputDispatcher { Ok(Self { sender: PtyInputSender { queue, on_failure }, worker: Some(worker) }) } + #[cfg(test)] pub fn enqueue(&self, event: PtyInputEvent) -> PtyInputEnqueueResult { self.sender.enqueue(event) } + pub fn enqueue_with_reservation( + &self, + event: PtyInputEvent, + ) -> (PtyInputEnqueueResult, Option) { + self.sender.enqueue_with_reservation(event) + } + pub fn sender(&self) -> PtyInputSender { self.sender.clone() } @@ -241,16 +250,24 @@ impl PtyInputDispatcher { impl PtyInputSender { pub fn enqueue(&self, event: PtyInputEvent) -> PtyInputEnqueueResult { + self.enqueue_with_reservation(event).0 + } + + fn enqueue_with_reservation( + &self, + event: PtyInputEvent, + ) -> (PtyInputEnqueueResult, Option) { let mut state = self.queue.state.lock().unwrap(); if state.closed { - return PtyInputEnqueueResult::Saturated; + return (PtyInputEnqueueResult::Saturated, None); } if state.remote_failed && event.remote { - return PtyInputEnqueueResult::Failed; + return (PtyInputEnqueueResult::Failed, None); } if event.bytes.len() > MAX_QUEUED_BYTES { - return PtyInputEnqueueResult::Oversized; + return (PtyInputEnqueueResult::Oversized, None); } + let reserves_release = event.kind == PtyInputKind::Press; let QueueState { events, queued_bytes, release_reservations, .. } = &mut *state; let accepted = enqueue_bounded( events, @@ -261,10 +278,11 @@ impl PtyInputSender { MAX_QUEUED_BYTES, ); if accepted { + let reservation_id = reserves_release.then_some(release_reservations.next_id); self.queue.changed.notify_one(); - PtyInputEnqueueResult::Accepted + (PtyInputEnqueueResult::Accepted, reservation_id) } else { - PtyInputEnqueueResult::Saturated + (PtyInputEnqueueResult::Saturated, None) } } @@ -305,6 +323,7 @@ impl PtyInputSender { (self.on_failure)(PtyOperationFailure { surface_id: None, kind: None, + reservation_id: None, label, error: match result { PtyInputEnqueueResult::Failed => { @@ -453,6 +472,7 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc Date: Fri, 10 Jul 2026 02:15:18 -0700 Subject: [PATCH 033/103] Enforce ordered session lifecycles --- cmux-tui/crates/cmux-tui/src/app.rs | 713 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/pty_input.rs | 147 +++- cmux-tui/crates/cmux-tui/src/session/mod.rs | 20 +- .../crates/cmux-tui/src/session/remote.rs | 76 +- 4 files changed, 804 insertions(+), 152 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5abfc9194d31..9ab3541e5c12 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -8,7 +8,6 @@ use std::collections::{HashMap, VecDeque}; use std::io::Write; -use std::ops::Deref; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::sync::{Arc, Mutex}; @@ -42,7 +41,7 @@ use crate::pty_input::{ PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind, PtyInputSender, PtyOperationDelivery, PtyOperationFailure, }; -use crate::session::{Session, SurfaceHandle, TreeView}; +use crate::session::{Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_timeout}; use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::GraphicsWriter; use crate::ui::input::{InputEvent, TextInput}; @@ -61,6 +60,7 @@ pub enum AppEvent { PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), RemoteTreeUpdated(Result), + SidebarPluginUpdated(SidebarPluginSurface), } #[derive(Default)] @@ -116,6 +116,7 @@ pub enum SessionMutationOutcome { completion: Option, }, IdentityRefreshFailed(String), + MutationTimedOut(String), Failed(String), Canceled, } @@ -159,6 +160,62 @@ impl Drop for RemoteRefreshClaim { } } +struct SurfaceResizeClaim { + claims: Arc>>, + surface: SurfaceId, + desired: (u16, u16), +} + +enum SurfaceResizeDecision { + Noop, + AlreadyClaimed, + NeedsQueue(SurfaceResizeClaim), +} + +#[derive(Default)] +struct SidebarPluginSyncState { + claimed: Option<((u16, u16), u64)>, + applied: Option<((u16, u16), u64)>, +} + +struct SidebarPluginSyncClaim { + state: Arc>, + desired: ((u16, u16), u64), + applied: bool, +} + +impl SidebarPluginSyncClaim { + fn mark_applied(&mut self) { + let mut state = self.state.lock().unwrap(); + state.applied = Some(self.desired); + if state.claimed == Some(self.desired) { + state.claimed = None; + } + self.applied = true; + } +} + +impl Drop for SidebarPluginSyncClaim { + fn drop(&mut self) { + if self.applied { + return; + } + let mut state = self.state.lock().unwrap(); + if state.claimed == Some(self.desired) { + state.claimed = None; + } + } +} + +impl Drop for SurfaceResizeClaim { + fn drop(&mut self) { + let mut claims = self.claims.lock().unwrap(); + if claims.get(&self.surface) == Some(&self.desired) { + claims.remove(&self.surface); + } + } +} + /// Read access stays synchronous, while every UI-originated session mutation /// enters the same ordered worker as PTY input. Accepted keys, mouse releases, /// resizes, and closes therefore have one execution order without blocking the @@ -172,6 +229,9 @@ pub struct OrderedSession { committed_mutation_generation: Arc, remote_refresh_queued: Arc, remote_background_dirty: Arc, + surface_resize_claims: Arc>>, + config_generation: Arc, + sidebar_plugin_sync: Arc>, } impl OrderedSession { @@ -186,6 +246,9 @@ impl OrderedSession { committed_mutation_generation: Arc::new(AtomicU64::new(0)), remote_refresh_queued: Arc::new(AtomicBool::new(false)), remote_background_dirty: Arc::new(AtomicBool::new(false)), + surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), + config_generation: Arc::new(AtomicU64::new(0)), + sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), } } @@ -194,6 +257,57 @@ impl OrderedSession { PendingSessionMutation { events: self.events.clone(), settled: false } } + fn events(&self) -> cmux_tui_core::MuxEventReceiver { + self.inner.events() + } + + fn tree(&self) -> TreeView { + self.inner.tree() + } + + pub(crate) fn surface(&self, id: SurfaceId) -> Option { + self.inner.cached_surface(id) + } + + fn has_surface(&self, id: SurfaceId) -> bool { + self.inner.has_surface(id) + } + + fn forget_surface(&self, id: SurfaceId) { + self.inner.forget_surface(id); + } + + fn invalidate_remote_tree(&self) { + self.inner.invalidate_remote_tree(); + } + + fn begin_shutdown(&self) { + self.inner.begin_shutdown(); + } + + fn attach_surface(&self, id: SurfaceId, size: Option<(u16, u16)>) { + if self.inner.cached_surface(id).is_some() { + return; + } + let session = self.inner.clone(); + let pending = self.pending_mutation(); + self.operations.enqueue_coalescing_mutation( + "attach surface", + ("attach surface", id), + self.remote, + move || { + if session.surface_sized(id, size).is_none() { + pending.settle(SessionMutationOutcome::Failed(format!( + "surface {id} is unavailable" + ))); + anyhow::bail!("surface {id} is unavailable"); + } + pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) + }, + ); + } + fn has_pending_mutations(&self) -> bool { self.pending_mutations.load(Ordering::Acquire) > 0 } @@ -309,7 +423,12 @@ impl OrderedSession { let completion = match operation(session.clone()) { Ok(completion) => completion, Err(error) => { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); + if remote && is_remote_timeout(&error) { + session.invalidate_remote_tree(); + pending.settle(SessionMutationOutcome::MutationTimedOut(error.to_string())); + } else { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + } return Err(error); } }; @@ -347,6 +466,7 @@ impl OrderedSession { cols: u16, rows: u16, reassert: bool, + claim: SurfaceResizeClaim, ) { let pending = self.pending_mutation(); let committed_mutation_generation = self.committed_mutation_generation.clone(); @@ -355,6 +475,7 @@ impl OrderedSession { ("surface resize", surface_id), self.remote, move || { + let _claim = claim; if reassert { surface.reassert_size(cols, rows)?; } else { @@ -367,6 +488,87 @@ impl OrderedSession { ); } + fn surface_resize_decision( + &self, + surface_id: SurfaceId, + desired: (u16, u16), + surface_needs_resize: bool, + ) -> SurfaceResizeDecision { + { + let mut claims = self.surface_resize_claims.lock().unwrap(); + if claims.get(&surface_id) == Some(&desired) { + return SurfaceResizeDecision::AlreadyClaimed; + } + if !claims.contains_key(&surface_id) && !surface_needs_resize { + return SurfaceResizeDecision::Noop; + } + claims.insert(surface_id, desired); + } + SurfaceResizeDecision::NeedsQueue(SurfaceResizeClaim { + claims: self.surface_resize_claims.clone(), + surface: surface_id, + desired, + }) + } + + fn apply_config(&self, config: Config) { + let session = self.inner.clone(); + let pending = self.pending_mutation(); + let committed_mutation_generation = self.committed_mutation_generation.clone(); + let config_generation = self.config_generation.clone(); + self.operations.enqueue_coalescing_mutation( + "apply config", + ("apply config", 0), + self.remote, + move || { + session.apply_config(&config); + config_generation.fetch_add(1, Ordering::AcqRel); + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) + }, + ); + } + + fn sidebar_plugin(&self, size: (u16, u16), relaunch: bool) { + let config_generation = self.config_generation.load(Ordering::Acquire); + let claim = if relaunch { + None + } else { + let desired = (size, config_generation); + let mut state = self.sidebar_plugin_sync.lock().unwrap(); + if state.claimed == Some(desired) || state.applied == Some(desired) { + return; + } + state.claimed = Some(desired); + Some(SidebarPluginSyncClaim { + state: self.sidebar_plugin_sync.clone(), + desired, + applied: false, + }) + }; + let session = self.inner.clone(); + let events = self.events.clone(); + let pending = self.pending_mutation(); + let committed_mutation_generation = self.committed_mutation_generation.clone(); + self.operations.enqueue_coalescing_mutation( + "sync sidebar plugin", + ("sidebar plugin", 0), + self.remote, + move || { + let mut claim = claim; + let status = session.sidebar_plugin(size, relaunch); + let _ = events.send(AppEvent::SidebarPluginUpdated(status)); + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + if let Some(claim) = &mut claim { + claim.mark_applied(); + } + pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) + }, + ); + } + pub fn new_tab(&self, pane: Option, size: Option<(u16, u16)>) -> anyhow::Result<()> { self.enqueue("create tab", move |session| session.new_tab(pane, size)); Ok(()) @@ -505,14 +707,6 @@ impl OrderedSession { } } -impl Deref for OrderedSession { - type Target = Session; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RenderAction { None, @@ -843,6 +1037,7 @@ enum Drag { PtyMouse { surface: SurfaceId, reservation_id: u64, + release_bytes: PtyInputBytes, content: Rect, button: MouseButton, position: (u16, u16), @@ -869,11 +1064,18 @@ struct PtyInputForwardResult { reservation_id: Option, } +enum PtyMouseReleaseCapture { + Bytes(PtyInputBytes), + NotReported, + Failed, +} + pub struct App { pub session: OrderedSession, pub config: Config, pub chrome: ChromeTheme, pub tree: TreeView, + tab_locations: HashMap, pub render_states: HashMap, pub graphics_writer: Option, pub graphics_supported: bool, @@ -1167,6 +1369,7 @@ pub fn run( config, chrome, tree: TreeView::default(), + tab_locations: HashMap::new(), render_states: HashMap::new(), graphics_writer, graphics_supported, @@ -1211,6 +1414,7 @@ pub fn run( let result = app.event_loop(&mut terminal, rx); app.cancel_pty_mouse_drag(); + app.session.begin_shutdown(); let _ = app.pty_input.shutdown(Duration::from_secs(3)); if let Some(writer) = app.graphics_writer.as_mut() { writer.shutdown(Duration::from_millis(200)); @@ -1356,22 +1560,27 @@ impl App { } fn apply_mux_titles(&mut self) -> bool { - let mut titles = self.mux_titles.take(); + let titles = self.mux_titles.take(); if titles.is_empty() { return false; } let mut changed = false; - for tab in self - .tree - .workspaces - .iter_mut() - .flat_map(|workspace| workspace.screens.iter_mut()) - .flat_map(|screen| screen.panes.iter_mut()) - .flat_map(|pane| pane.tabs.iter_mut()) - { - if let Some(title) = titles.remove(&tab.surface) - && tab.title != title - { + for (surface, title) in titles { + let Some([workspace, screen, pane, tab]) = self.tab_locations.get(&surface).copied() + else { + continue; + }; + let Some(tab) = self + .tree + .workspaces + .get_mut(workspace) + .and_then(|workspace| workspace.screens.get_mut(screen)) + .and_then(|screen| screen.panes.get_mut(pane)) + .and_then(|pane| pane.tabs.get_mut(tab)) + else { + continue; + }; + if tab.title != title { tab.title = title; changed = true; } @@ -1379,6 +1588,23 @@ impl App { changed } + fn replace_tree(&mut self, tree: TreeView) { + self.tab_locations.clear(); + for (workspace_index, workspace) in tree.workspaces.iter().enumerate() { + for (screen_index, screen) in workspace.screens.iter().enumerate() { + for (pane_index, pane) in screen.panes.iter().enumerate() { + for (tab_index, tab) in pane.tabs.iter().enumerate() { + self.tab_locations.insert( + tab.surface, + [workspace_index, screen_index, pane_index, tab_index], + ); + } + } + } + } + self.tree = tree; + } + fn apply_session_completions_through(&mut self, authoritative_generation: u64) { while self .pending_session_completions @@ -1487,7 +1713,7 @@ impl App { fn reload_config(&mut self) { let mut config = crate::config::load(); config.apply_chrome_defaults(self.chrome); - self.session.apply_config(&config); + self.session.apply_config(config.clone()); self.config = config; } @@ -1519,7 +1745,7 @@ impl App { }; self.content_area = area; self.sync_sidebar_plugin(false); - self.tree = self.session.tree(); + self.replace_tree(self.session.tree()); let layout = self .tree .active_screen() @@ -1559,22 +1785,31 @@ impl App { // server-side resize, so no post-attach reflow artifacts). let size = Some((content.width, content.height)); for tab in &pane.tabs { - if !self.session.has_surface(tab.surface) - && !self.prepare_pty_input_before_mutation() - { - return; + if !self.session.has_surface(tab.surface) { + if self.prepare_pty_input_before_mutation() { + self.session.attach_surface(tab.surface, size); + } + continue; } - if let Some(surface) = self.session.surface_sized(tab.surface, size) - && surface.resize_needed(content.width, content.height, false) - && self.prepare_pty_input_before_mutation() - { - self.enqueue_surface_resize( - tab.surface, - surface, - content.width, - content.height, - false, - ); + if let Some(surface) = self.session.surface(tab.surface) { + let needs = surface.resize_needed(content.width, content.height, false); + if let SurfaceResizeDecision::NeedsQueue(claim) = + self.session.surface_resize_decision( + tab.surface, + (content.width, content.height), + needs, + ) + && self.prepare_pty_input_before_mutation() + { + self.enqueue_surface_resize( + tab.surface, + surface, + content.width, + content.height, + false, + Some(claim), + ); + } } } } @@ -1604,7 +1839,10 @@ impl App { if relaunch && !self.prepare_pty_input_before_mutation() { return; } - let status = self.session.sidebar_plugin((rect.width, rect.height), relaunch); + self.session.sidebar_plugin((rect.width, rect.height), relaunch); + } + + fn apply_sidebar_plugin_status(&mut self, status: SidebarPluginSurface) { self.sidebar_plugin_surface = status.surface_id; self.sidebar_plugin_error = status.error; self.sidebar_plugin_retry_after_ms = status.retry_after_ms; @@ -1652,6 +1890,10 @@ impl App { AppEvent::MuxTitlesReady => { Ok(if self.apply_mux_titles() { RenderAction::Paint } else { RenderAction::None }) } + AppEvent::SidebarPluginUpdated(status) => { + self.apply_sidebar_plugin_status(status); + Ok(RenderAction::Draw) + } AppEvent::Mux(MuxEvent::Empty) => { self.quit = true; Ok(RenderAction::None) @@ -1726,7 +1968,7 @@ impl App { match outcome { SessionMutationOutcome::Success { tree } => { if let Some(tree) = tree { - self.tree = tree; + self.replace_tree(tree); } self.routing_refresh_retries_remaining = 0; } @@ -1735,7 +1977,7 @@ impl App { authoritative_generation, completion, } => { - self.tree = tree; + self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; if let Some(completion) = completion { self.pending_session_completions.push_back(completion); @@ -1746,7 +1988,7 @@ impl App { tree, authoritative_generation, } => { - self.tree = tree; + self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; self.apply_session_completions_through(authoritative_generation); self.complete_remote_tree_refresh(true); @@ -1776,6 +2018,15 @@ impl App { self.complete_remote_tree_refresh(refresh_stale); return Ok(RenderAction::Draw); } + SessionMutationOutcome::MutationTimedOut(error) => { + self.status_message = Some(format!( + "session operation may have committed; refreshing its layout: {error}" + )); + self.routing_refresh_retries_remaining = ROUTING_REFRESH_RETRIES; + self.session.invalidate_remote_tree(); + self.session.refresh_remote_tree_if_stale(); + return Ok(RenderAction::Draw); + } SessionMutationOutcome::Failed(error) => { self.deferred_input.clear(); self.pending_session_completions.clear(); @@ -1802,7 +2053,7 @@ impl App { AppEvent::RemoteTreeUpdated(result) => { match result { Ok(tree) => { - self.tree = tree; + self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; } Err(error) => { @@ -1971,12 +2222,30 @@ impl App { let rect = self.sidebar_plugin_rect(); if rect.width > 0 && rect.height > 0 { let needs_barrier = surface.resize_needed(rect.width, rect.height, true); - if !needs_barrier { - let _ = surface.reassert_size(rect.width, rect.height); - } else if self.prepare_pty_input_before_mutation() - && let Some(surface_id) = self.sidebar_plugin_surface - { - self.enqueue_surface_resize(surface_id, surface, rect.width, rect.height, true); + if let Some(surface_id) = self.sidebar_plugin_surface { + match self.session.surface_resize_decision( + surface_id, + (rect.width, rect.height), + needs_barrier, + ) { + SurfaceResizeDecision::Noop => { + let _ = surface.reassert_size(rect.width, rect.height); + } + SurfaceResizeDecision::AlreadyClaimed => {} + SurfaceResizeDecision::NeedsQueue(claim) + if self.prepare_pty_input_before_mutation() => + { + self.enqueue_surface_resize( + surface_id, + surface, + rect.width, + rect.height, + true, + Some(claim), + ); + } + SurfaceResizeDecision::NeedsQueue(_) => {} + } } } } @@ -1989,23 +2258,35 @@ impl App { { return; } - if let Some(surface) = self - .session - .surface_sized(area.surface, Some((area.content.width, area.content.height))) - { + if let Some(surface) = self.session.surface(area.surface) { let needs_barrier = surface.resize_needed(area.content.width, area.content.height, true); - if !needs_barrier { - let _ = surface.reassert_size(area.content.width, area.content.height); - } else if self.prepare_pty_input_before_mutation() { - self.enqueue_surface_resize( - area.surface, - surface, - area.content.width, - area.content.height, - true, - ); + match self.session.surface_resize_decision( + area.surface, + (area.content.width, area.content.height), + needs_barrier, + ) { + SurfaceResizeDecision::Noop => { + let _ = surface.reassert_size(area.content.width, area.content.height); + } + SurfaceResizeDecision::AlreadyClaimed => {} + SurfaceResizeDecision::NeedsQueue(claim) + if self.prepare_pty_input_before_mutation() => + { + self.enqueue_surface_resize( + area.surface, + surface, + area.content.width, + area.content.height, + true, + Some(claim), + ); + } + SurfaceResizeDecision::NeedsQueue(_) => {} } + } else if self.prepare_pty_input_before_mutation() { + self.session + .attach_surface(area.surface, Some((area.content.width, area.content.height))); } } } @@ -2024,6 +2305,7 @@ impl App { cols: u16, rows: u16, reassert: bool, + claim: Option, ) { if surface.kind() == SurfaceKind::Browser { self.browser_input.enqueue(BrowserInputEvent { @@ -2032,7 +2314,8 @@ impl App { kind: BrowserInputKind::Resize { cols, rows, reassert }, }); } else { - self.session.resize_surface(surface_id, surface, cols, rows, reassert); + let Some(claim) = claim else { return }; + self.session.resize_surface(surface_id, surface, cols, rows, reassert, claim); } } @@ -3137,6 +3420,11 @@ impl App { } self.sidebar_focused = false; self.selection = None; + let release_capture = + self.capture_pty_mouse_release(area.surface, area.content, x, y, button, modifiers); + if matches!(release_capture, PtyMouseReleaseCapture::Failed) { + return PtyMousePressResult::Consumed; + } let forwarded = self.forward_pty_mouse_to_surface( area.surface, area.content, @@ -3153,9 +3441,14 @@ impl App { let Some(reservation_id) = forwarded.reservation_id else { return PtyMousePressResult::Consumed; }; + let PtyMouseReleaseCapture::Bytes(release_bytes) = release_capture else { + self.pty_input.cancel_release_reservation(reservation_id); + return PtyMousePressResult::Consumed; + }; self.drag = Some(Drag::PtyMouse { surface: area.surface, reservation_id, + release_bytes, content: area.content, button, position: (x, y), @@ -3205,60 +3498,123 @@ impl App { _reported_button: MouseButton, modifiers: KeyModifiers, ) -> bool { - let Some(Drag::PtyMouse { surface, content, button: active_button, .. }) = self.drag else { + let Some(Drag::PtyMouse { + surface, reservation_id, release_bytes, content, button, .. + }) = &self.drag + else { return false; }; - // Xterm SGR releases carry no button identity and crossterm exposes - // them as left. Release the one button this TUI accepted. - if !self.pty_mouse_tracking(surface) { - self.cancel_pty_release_reservation(); - self.drag = None; - return true; - } + let (surface, reservation_id, fallback, content, button) = + (*surface, *reservation_id, release_bytes.clone(), *content, *button); let content = self.current_pty_content(surface).unwrap_or(content); - let delivered = self - .forward_pty_mouse_to_surface( - surface, - content, - x, - y, - MouseAction::Release, - Some(Self::ghostty_mouse_button(active_button)), - modifiers, - false, - ) - .accepted; - if delivered { - self.drag = None; + let release_bytes = + match self.capture_pty_mouse_release(surface, content, x, y, button, modifiers) { + PtyMouseReleaseCapture::Bytes(bytes) => bytes, + PtyMouseReleaseCapture::NotReported | PtyMouseReleaseCapture::Failed => fallback, + }; + let delivered = self.enqueue_pty_release(surface, reservation_id, release_bytes); + self.drag = None; + if !delivered { + self.pty_input.cancel_release_reservation(reservation_id); } true } + fn capture_pty_mouse_release( + &mut self, + surface_id: SurfaceId, + content: Rect, + x: u16, + y: u16, + button: MouseButton, + modifiers: KeyModifiers, + ) -> PtyMouseReleaseCapture { + let Some(surface) = self.session.surface(surface_id) else { + return PtyMouseReleaseCapture::Failed; + }; + let cell_width = u32::from(self.cell_pixels.0.max(1)); + let cell_height = u32::from(self.cell_pixels.1.max(1)); + let input = MouseInput { + action: MouseAction::Release, + button: Some(Self::ghostty_mouse_button(button)), + mods: Self::ghostty_mouse_mods(modifiers), + position: ( + (x as f32 - content.x as f32 + 0.5) * cell_width as f32, + (y as f32 - content.y as f32 + 0.5) * cell_height as f32, + ), + screen_size: ( + u32::from(content.width).saturating_mul(cell_width), + u32::from(content.height).saturating_mul(cell_height), + ), + cell_size: (cell_width, cell_height), + any_button_pressed: false, + }; + let Ok(mut encoder) = MouseEncoder::new() else { + return PtyMouseReleaseCapture::Failed; + }; + let mut output = Vec::new(); + let encoded = surface.with_terminal(|terminal| { + encoder.sync_from_terminal(terminal); + encoder.encode(input, &mut output) + }); + match encoded { + Some(Ok(())) if output.is_empty() => PtyMouseReleaseCapture::NotReported, + Some(Ok(())) => PtyMouseReleaseCapture::Bytes(PtyInputBytes::from_slice(&output)), + Some(Err(_)) | None => PtyMouseReleaseCapture::Failed, + } + } + + fn enqueue_pty_release( + &mut self, + surface_id: SurfaceId, + reservation_id: u64, + bytes: PtyInputBytes, + ) -> bool { + let Some(surface) = self.session.surface(surface_id) else { return false }; + self.encode_buf.clear(); + self.encode_buf.extend_from_slice(bytes.as_ref()); + let (result, _) = self.pty_input.enqueue_with_reservation(PtyInputEvent::release( + surface_id, + surface, + bytes, + reservation_id, + )); + self.handle_pty_enqueue_result(result) + } + fn cancel_pty_mouse_drag(&mut self) { - let Some(Drag::PtyMouse { surface, content, button, position, modifiers, .. }) = self.drag + let Some(Drag::PtyMouse { + surface, + reservation_id, + release_bytes, + content, + button, + position, + modifiers, + }) = &self.drag else { return; }; - if !self.pty_mouse_tracking(surface) { - self.cancel_pty_release_reservation(); - self.drag = None; - return; - } + let (surface, reservation_id, fallback, content, button, position, modifiers) = ( + *surface, + *reservation_id, + release_bytes.clone(), + *content, + *button, + *position, + *modifiers, + ); let content = self.current_pty_content(surface).unwrap_or(content); - let delivered = self - .forward_pty_mouse_to_surface( - surface, - content, - position.0, - position.1, - MouseAction::Release, - Some(Self::ghostty_mouse_button(button)), - modifiers, - false, - ) - .accepted; - if delivered { - self.drag = None; + let release_bytes = match self + .capture_pty_mouse_release(surface, content, position.0, position.1, button, modifiers) + { + PtyMouseReleaseCapture::Bytes(bytes) => bytes, + PtyMouseReleaseCapture::NotReported | PtyMouseReleaseCapture::Failed => fallback, + }; + let delivered = self.enqueue_pty_release(surface, reservation_id, release_bytes); + self.drag = None; + if !delivered { + self.pty_input.cancel_release_reservation(reservation_id); } } @@ -3340,7 +3696,7 @@ impl App { } if self.encode_buf.is_empty() { if action == MouseAction::Release { - self.pty_input.cancel_release_reservation(); + self.cancel_pty_release_reservation(); } return PtyInputForwardResult { accepted: true, reservation_id: None }; } @@ -3446,7 +3802,9 @@ impl App { } fn cancel_pty_release_reservation(&self) { - self.pty_input.cancel_release_reservation(); + if let Some(Drag::PtyMouse { reservation_id, .. }) = &self.drag { + self.pty_input.cancel_release_reservation(*reservation_id); + } } fn pty_mouse_tracking(&self, surface_id: SurfaceId) -> bool { @@ -4477,8 +4835,8 @@ fn browser_key_mapping( mod tests { use super::{ App, AppEvent, Drag, MuxTitleIngress, OrderedSession, PaneArea, RenderAction, - SessionCompletion, SessionCompletionAction, browser_content_size_for_rect, - browser_hover_forward_allowed, pane_parts_for_rect, + SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, + browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::Ordering; @@ -4501,8 +4859,8 @@ mod tests { use crate::browser_input::BrowserInputDispatcher; use crate::config::{ChromeTheme, Config, ScrollbarPosition}; use crate::pty_input::{ - PtyInputDispatcher, PtyInputEnqueueResult, PtyInputKind, PtyOperationDelivery, - PtyOperationFailure, + PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputKind, + PtyOperationDelivery, PtyOperationFailure, }; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{Session, TreeView}; @@ -4561,7 +4919,7 @@ mod tests { surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); let mut app = test_app(Session::Local(mux.clone())); - app.tree = app.session.tree(); + app.replace_tree(app.session.tree()); let pane = app.tree.active_screen().unwrap().active_pane; let content = Rect { x: 2, y: 3, width: 20, height: 8 }; app.pane_areas.push(PaneArea { @@ -4587,9 +4945,11 @@ mod tests { assert!(app.selection.is_none()); assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Left, .. }))); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002l\x1b[?1006l")); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); assert!(app.drag.is_none()); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); app.handle_mouse(event(MouseEventKind::ScrollDown, KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<65;5;3M"); @@ -4687,6 +5047,32 @@ mod tests { assert!(!app.quit); } + #[test] + fn rejected_release_clears_the_active_pty_drag() { + let mux = Mux::new("rejected-release-drag-test", SurfaceOptions::default()); + let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1000h\x1b[?1006h")); + let mut app = test_app(Session::Local(mux)); + app.drag = Some(Drag::PtyMouse { + surface: surface.id, + reservation_id: 41, + release_bytes: PtyInputBytes::from_slice(b"fallback-release"), + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Left, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + assert!(app.pty_input.shutdown(Duration::from_secs(1))); + + assert!(app.finish_pty_mouse_drag(4, 3, MouseButton::Left, KeyModifiers::NONE)); + + assert!(app.drag.is_none()); + assert_eq!( + app.status_message.as_deref(), + Some("PTY input queue is full; input was not sent") + ); + } + #[test] fn motion_failure_preserves_pty_drag_for_the_physical_release() { let mux = Mux::new("motion-failure-drag-test", SurfaceOptions::default()); @@ -4694,6 +5080,7 @@ mod tests { app.drag = Some(Drag::PtyMouse { surface: 42, reservation_id: 1, + release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Right, position: (4, 3), @@ -4781,6 +5168,7 @@ mod tests { app.drag = Some(Drag::PtyMouse { surface: 42, reservation_id: 1, + release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Right, position: (4, 3), @@ -4808,6 +5196,7 @@ mod tests { app.drag = Some(Drag::PtyMouse { surface: 42, reservation_id: 7, + release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Left, position: (4, 3), @@ -4835,6 +5224,7 @@ mod tests { app.drag = Some(Drag::PtyMouse { surface: 42, reservation_id: 9, + release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, button: MouseButton::Left, position: (4, 3), @@ -4927,7 +5317,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); let updated_surface = 41; let untouched_surface = 42; - app.tree = browser_completion_tree(updated_surface, untouched_surface); + app.replace_tree(browser_completion_tree(updated_surface, untouched_surface)); let (wake_tx, wake_rx) = std::sync::mpsc::channel(); for index in 0..10_000 { @@ -4947,6 +5337,87 @@ mod tests { assert!(app.mux_titles.push(updated_surface, "next".to_string())); } + #[test] + fn resize_claims_skip_identical_work_and_replace_a_b_a_reversions() { + let mux = Mux::new("resize-claim-test", SurfaceOptions::default()); + let app = test_app(Session::Local(mux)); + assert!(matches!( + app.session.surface_resize_decision(7, (80, 24), false), + SurfaceResizeDecision::Noop + )); + let first = match app.session.surface_resize_decision(7, (100, 30), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("first changed size must queue"), + }; + for _ in 0..1_000 { + assert!(matches!( + app.session.surface_resize_decision(7, (100, 30), true), + SurfaceResizeDecision::AlreadyClaimed + )); + } + let reverted = match app.session.surface_resize_decision(7, (80, 24), false) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("different outstanding size must be replaced even when server is current"), + }; + drop(first); + assert!(matches!( + app.session.surface_resize_decision(7, (80, 24), false), + SurfaceResizeDecision::AlreadyClaimed + )); + drop(reverted); + assert!(matches!( + app.session.surface_resize_decision(7, (80, 24), false), + SurfaceResizeDecision::Noop + )); + } + + #[test] + fn sidebar_plugin_sync_is_deduped_after_it_is_applied() { + let mux = Mux::new("sidebar-plugin-sync-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + + for _ in 0..1_000 { + app.session.sidebar_plugin((24, 8), false); + } + + let mut updates = 0; + while let Ok(event) = events.recv_timeout(Duration::from_millis(100)) { + if matches!(event, AppEvent::SidebarPluginUpdated(_)) { + updates += 1; + } + app.handle(event).unwrap(); + if !app.session.has_pending_mutations() { + break; + } + } + assert_eq!(updates, 1); + assert!(!app.session.has_pending_mutations()); + assert_eq!(app.session.sidebar_plugin_sync.lock().unwrap().applied, Some(((24, 8), 0))); + } + + #[test] + fn remote_mutation_timeout_keeps_deferred_routing_state() { + let mux = Mux::new("mutation-timeout-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.session.remote = true; + app.deferred_input + .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + app.session + .enqueue("timed out mutation", |_| Err(crate::session::test_remote_timeout_error())); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!( + settled, + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::MutationTimedOut(_)) + )); + app.handle(settled).unwrap(); + assert_eq!(app.deferred_input.len(), 1); + assert!( + app.status_message + .as_deref() + .is_some_and(|message| message.contains("may have committed")) + ); + } + #[test] fn browser_completion_waits_for_its_authoritative_identity_generation() { let mux = Mux::new("browser-completion-generation-test", SurfaceOptions::default()); @@ -5199,6 +5670,7 @@ mod tests { app.drag = Some(Drag::PtyMouse { surface: 42, reservation_id: 1, + release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 2, y: 3, width: 20, height: 8 }, button: MouseButton::Right, position: (5, 5), @@ -5312,7 +5784,7 @@ mod tests { let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1000h\x1b[?1006h")); let mut app = test_app(Session::Local(mux.clone())); - app.tree = app.session.tree(); + app.replace_tree(app.session.tree()); let pane = app.tree.active_screen().unwrap().active_pane; let content = Rect { x: 2, y: 3, width: 20, height: 8 }; app.pane_areas.push(PaneArea { @@ -5362,7 +5834,7 @@ mod tests { let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); let mut app = test_app(Session::Local(mux.clone())); app.sidebar_width = 12; - app.tree = notify_tree(surface.id, true); + app.replace_tree(notify_tree(surface.id, true)); app.pane_areas.push(PaneArea { pane: 2, surface: surface.id, @@ -5385,7 +5857,7 @@ mod tests { assert!(row_contains(buffer, 1, "•"), "tab bar should contain unread dot"); assert_eq!(buffer[(0, 2)].symbol(), "•", "sidebar should contain unread dot"); - app.tree = notify_tree(surface.id, false); + app.replace_tree(notify_tree(surface.id, false)); let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); terminal .draw(|frame| { @@ -5420,7 +5892,7 @@ mod tests { command: vec!["/bin/sh".to_string(), "-c".to_string(), "sleep 30".to_string()], cwd: None, }); - app.tree = notify_tree(surface.id, false); + app.replace_tree(notify_tree(surface.id, false)); let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); terminal @@ -5531,6 +6003,7 @@ mod tests { config: Config::default(), chrome: ChromeTheme::dark(), tree: TreeView::default(), + tab_locations: HashMap::new(), render_states: HashMap::::new(), graphics_writer: None, graphics_supported: false, diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index ec08509c9a21..02b7220c174f 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -77,6 +77,17 @@ impl PtyInputEvent { } } + pub fn release( + surface_id: SurfaceId, + surface: SurfaceHandle, + bytes: PtyInputBytes, + reservation_id: u64, + ) -> Self { + let mut event = Self::input(surface_id, surface, bytes, PtyInputKind::Release); + event.reservation_id = Some(reservation_id); + event + } + fn mutation( label: &'static str, coalesce_key: Option<(&'static str, u64)>, @@ -146,10 +157,15 @@ impl ReleaseReservations { id.is_some_and(|id| self.outstanding.remove(&id).is_some()) } - fn cancel_latest(&mut self) { - if let Some(id) = self.outstanding.keys().copied().max() { - self.outstanding.remove(&id); + fn consume_id(&mut self, reservation_id: u64, surface_id: SurfaceId) -> bool { + if self.outstanding.get(&reservation_id) != Some(&surface_id) { + return false; } + self.outstanding.remove(&reservation_id).is_some() + } + + fn cancel(&mut self, reservation_id: u64) { + self.outstanding.remove(&reservation_id); } fn clear(&mut self) { @@ -210,8 +226,8 @@ impl PtyInputDispatcher { self.sender.clone() } - pub fn cancel_release_reservation(&self) { - self.sender.cancel_release_reservation(); + pub fn cancel_release_reservation(&self, reservation_id: u64) { + self.sender.cancel_release_reservation(reservation_id); } /// Drain queued writes during detach/normal shutdown, bounded so a @@ -219,27 +235,23 @@ impl PtyInputDispatcher { pub fn shutdown(&mut self, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; let mut state = self.sender.queue.state.lock().unwrap(); + let releases = state + .events + .drain(..) + .filter(|event| event.kind == PtyInputKind::Release) + .collect::>(); + state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); + state.events = releases; + state.release_reservations.clear(); + state.closed = true; + state.shutdown_release_drain = true; + self.sender.queue.changed.notify_all(); while (!state.events.is_empty() || state.in_flight.is_some()) && Instant::now() < deadline { let remaining = deadline.saturating_duration_since(Instant::now()); let (next, _) = self.sender.queue.changed.wait_timeout(state, remaining).unwrap(); state = next; } let drained = state.events.is_empty() && state.in_flight.is_none(); - state.closed = true; - if !drained { - let releases = state - .events - .drain(..) - .filter(|event| event.kind == PtyInputKind::Release) - .collect::>(); - state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); - state.release_reservations.clear(); - if !releases.is_empty() { - state.events = releases; - state.shutdown_release_drain = true; - } - } - self.sender.queue.changed.notify_all(); drop(state); if drained && let Some(worker) = self.worker.take() { let _ = worker.join(); @@ -286,9 +298,9 @@ impl PtyInputSender { } } - pub fn cancel_release_reservation(&self) { + pub fn cancel_release_reservation(&self, reservation_id: u64) { let mut state = self.queue.state.lock().unwrap(); - state.release_reservations.cancel_latest(); + state.release_reservations.cancel(reservation_id); self.queue.changed.notify_all(); } @@ -393,7 +405,15 @@ fn enqueue_bounded( previous.kind == PtyInputKind::Ordered && previous.surface_id == event.surface_id }); let consumes_reservation = event.kind == PtyInputKind::Release - && release_reservations.outstanding.values().any(|surface| *surface == event.surface_id); + && match event.reservation_id { + Some(reservation_id) => { + release_reservations.outstanding.get(&reservation_id) == Some(&event.surface_id) + } + None => release_reservations + .outstanding + .values() + .any(|surface| *surface == event.surface_id), + }; let mut projected = events.len() + release_reservations.len() + usize::from(!merge_stream) @@ -422,7 +442,11 @@ fn enqueue_bounded( if event.kind == PtyInputKind::Press { event.reservation_id = Some(release_reservations.reserve(event.surface_id)); } else if consumes_reservation { - release_reservations.consume(event.surface_id); + if let Some(reservation_id) = event.reservation_id { + release_reservations.consume_id(reservation_id, event.surface_id); + } else { + release_reservations.consume(event.surface_id); + } } if merge_stream { @@ -735,6 +759,38 @@ mod tests { assert!(reservations.outstanding.contains_key(&second)); } + #[test] + fn release_consumes_only_its_exact_reservation() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = ReleaseReservations::default(); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(7, 1, PtyInputKind::Press), + 8, + 1024, + )); + let first = releases.next_id; + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(7, 2, PtyInputKind::Press), + 8, + 1024, + )); + let second = releases.next_id; + + let mut release = event(7, 3, PtyInputKind::Release); + release.reservation_id = Some(first); + assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, release, 8, 1024,)); + + assert!(!releases.outstanding.contains_key(&first)); + assert_eq!(releases.outstanding.get(&second), Some(&7)); + } + #[test] fn ordered_batch_respects_byte_budget() { let mut events = VecDeque::new(); @@ -760,12 +816,19 @@ mod tests { } #[test] - fn shutdown_timeout_preserves_release_behind_any_in_flight_operation() { + fn shutdown_retains_release_for_worker_behind_in_flight_operation() { let queue = Arc::new(SharedQueue::default()); + let released = Arc::new(std::sync::atomic::AtomicBool::new(false)); { let mut state = queue.state.lock().unwrap(); state.events.push_back(event(1, 1, PtyInputKind::Motion)); - state.events.push_back(event(1, 2, PtyInputKind::Release)); + let released_flag = released.clone(); + let mut release = event(1, 2, PtyInputKind::Release); + release.mutation = Some(Box::new(move || { + released_flag.store(true, std::sync::atomic::Ordering::Release); + Ok(()) + })); + state.events.push_back(release); state.queued_bytes = 2; state.in_flight = Some(InFlightInput { surface_id: 2, kind: PtyInputKind::Ordered }); } @@ -779,10 +842,36 @@ mod tests { let state = queue.state.lock().unwrap(); assert!(state.closed); assert_eq!(state.events.len(), 1); - assert_eq!(state.events[0].kind, PtyInputKind::Release); assert_eq!(state.queued_bytes, 1); assert_eq!(state.release_reservations.len(), 0); - assert!(state.shutdown_release_drain); + assert!(!released.load(std::sync::atomic::Ordering::Acquire)); + } + + #[test] + fn shutdown_waits_for_worker_ordered_release_after_response_wait_is_canceled() { + let (line_tx, line_rx) = std::sync::mpsc::channel(); + let (cancel_tx, cancel_rx) = std::sync::mpsc::channel(); + let mut dispatcher = PtyInputDispatcher::spawn(|_| {}).unwrap(); + dispatcher.sender().enqueue_session_mutation("in flight", true, move || { + line_tx.send("request").unwrap(); + cancel_rx.recv().unwrap(); + Ok(()) + }); + assert_eq!(line_rx.recv_timeout(Duration::from_secs(1)).unwrap(), "request"); + + let released = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let released_flag = released.clone(); + let mut release = event(1, 2, PtyInputKind::Release); + release.mutation = Some(Box::new(move || { + released_flag.store(true, std::sync::atomic::Ordering::Release); + Ok(()) + })); + assert_eq!(dispatcher.enqueue(release), PtyInputEnqueueResult::Accepted); + + let shutdown = std::thread::spawn(move || dispatcher.shutdown(Duration::from_secs(1))); + cancel_tx.send(()).unwrap(); + assert!(shutdown.join().unwrap()); + assert!(released.load(std::sync::atomic::Ordering::Acquire)); } #[test] @@ -803,7 +892,7 @@ mod tests { let state = queue.state.lock().unwrap(); assert!(state.events.is_empty()); - assert!(!state.shutdown_release_drain); + assert!(state.shutdown_release_drain); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 96f431833f86..32f4ce4764c8 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -101,6 +101,11 @@ pub enum SurfaceHandle { } impl Session { + pub fn begin_shutdown(&self) { + if let Session::Remote(remote) = self { + remote.begin_shutdown(); + } + } pub fn invalidate_remote_tree(&self) { if let Session::Remote(remote) = self { remote.invalidate_tree(); @@ -224,8 +229,19 @@ impl Session { } } - pub fn surface(&self, id: SurfaceId) -> Option { - self.surface_sized(id, None) + pub fn cached_surface(&self, id: SurfaceId) -> Option { + match self { + Session::Local(mux) => mux.surface(id).map(SurfaceHandle::Local), + Session::Remote(remote) => { + if remote.surface_kind(id) == SurfaceKind::Browser + && !remote.supports_browser_attach() + { + Some(SurfaceHandle::RemoteBrowserUnsupported) + } else { + remote.surface(id).map(|surface| SurfaceHandle::Remote(surface, remote.clone())) + } + } + } } pub fn has_surface(&self, id: SurfaceId) -> bool { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 74db0ba76467..cbe743bf79ce 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -28,6 +28,7 @@ pub(crate) enum RemoteRequestError { Transport(std::io::Error), Timeout, Rejected(String), + Shutdown, } impl RemoteRequestError { @@ -47,6 +48,7 @@ impl std::fmt::Display for RemoteRequestError { Self::Transport(error) => write!(formatter, "remote transport write failed: {error}"), Self::Timeout => write!(formatter, "remote session did not respond"), Self::Rejected(error) => write!(formatter, "remote command rejected: {error}"), + Self::Shutdown => write!(formatter, "remote response wait canceled for shutdown"), } } } @@ -287,6 +289,7 @@ pub struct RemoteSession { writer: Mutex>, pending: Mutex>>, next_id: AtomicU64, + shutdown: AtomicBool, surfaces: Mutex>>, exited_surfaces: Mutex>, tree: Mutex, @@ -301,6 +304,10 @@ impl RemoteSession { self.surfaces.lock().unwrap().contains_key(&id) } + pub(super) fn surface(&self, id: SurfaceId) -> Option> { + self.surfaces.lock().unwrap().get(&id).cloned() + } + pub fn connect(path: &Path) -> anyhow::Result> { let stream = transport::connect(path).map_err(|e| { anyhow::anyhow!("cannot connect to session socket {}: {e}", path.display()) @@ -310,6 +317,7 @@ impl RemoteSession { writer: Mutex::new(stream), pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + shutdown: AtomicBool::new(false), surfaces: Mutex::new(HashMap::new()), exited_surfaces: Mutex::new(HashSet::new()), tree: Mutex::new(RemoteTreeCache::default()), @@ -547,6 +555,11 @@ impl RemoteSession { return Err(RemoteRequestError::Transport(err).into()); } + if self.shutdown.load(Ordering::Acquire) { + self.pending.lock().unwrap().remove(&id); + return Err(RemoteRequestError::Shutdown.into()); + } + let response = match rx.recv_timeout(Duration::from_secs(10)) { Ok(response) => response, Err(_) => { @@ -557,6 +570,9 @@ impl RemoteSession { return Err(RemoteRequestError::Timeout.into()); } }; + if response.get("shutdown").and_then(Value::as_bool) == Some(true) { + return Err(RemoteRequestError::Shutdown.into()); + } if response.get("ok").and_then(|v| v.as_bool()) == Some(true) { Ok(response.get("data").cloned().unwrap_or(Value::Null)) } else { @@ -570,6 +586,14 @@ impl RemoteSession { self.request(json!({"cmd": "send", "surface": surface, "bytes": encoded})).map(|_| ()) } + pub fn begin_shutdown(&self) { + self.shutdown.store(true, Ordering::Release); + let pending = std::mem::take(&mut *self.pending.lock().unwrap()); + for (_, sender) in pending { + let _ = sender.send(json!({"shutdown": true})); + } + } + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result<()> { self.request(json!({ "cmd": "set-cell-pixels", @@ -802,14 +826,64 @@ fn parse_browser_frame(value: &Value) -> Option { #[cfg(test)] mod tests { + use std::io::BufRead; + #[cfg(unix)] + use std::os::unix::net::UnixStream; use std::sync::Mutex; - use std::sync::atomic::AtomicBool; + use std::sync::atomic::{AtomicBool, AtomicU64}; use ghostty_vt::{Callbacks, Terminal}; use serde_json::json; use super::*; + #[cfg(unix)] + fn socket_test_session(stream: UnixStream) -> Arc { + Arc::new(RemoteSession { + writer: Mutex::new(Box::new(stream)), + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + shutdown: AtomicBool::new(false), + surfaces: Mutex::new(HashMap::new()), + exited_surfaces: Mutex::new(HashSet::new()), + tree: Mutex::new(RemoteTreeCache::default()), + tree_refresh: Mutex::new(()), + tree_stale: AtomicBool::new(true), + subscribers: MuxEventBroadcaster::default(), + frame_logs: Mutex::new(HashMap::new()), + }) + } + + #[cfg(unix)] + #[test] + fn shutdown_cancels_response_wait_before_ordered_release_write() { + let (client, server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let waiting_session = session.clone(); + let waiting = std::thread::spawn(move || { + waiting_session.request(json!({"cmd": "mutation"})).unwrap_err() + }); + + let mut peer = BufReader::new(server); + let mut first_line = String::new(); + peer.read_line(&mut first_line).unwrap(); + let first: Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first["cmd"], "mutation"); + + session.begin_shutdown(); + assert!(waiting.join().unwrap().to_string().contains("canceled for shutdown")); + + let release_error = session.send_bytes(7, b"release").unwrap_err(); + assert!(release_error.to_string().contains("canceled for shutdown")); + let mut release_line = String::new(); + peer.read_line(&mut release_line).unwrap(); + let release: Value = serde_json::from_str(&release_line).unwrap(); + assert_eq!(release["cmd"], "send"); + assert_eq!(release["surface"], 7); + assert_eq!(release["bytes"], "cmVsZWFzZQ=="); + assert!(release["id"].as_u64().unwrap() > first["id"].as_u64().unwrap()); + } + #[test] fn indexed_title_update_changes_only_the_addressed_surface() { let mut cache = RemoteTreeCache::default(); From e6a0ec82f03c1967c0d5363f54b7cd3cea18b522 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:07:10 -0700 Subject: [PATCH 034/103] Restore Windows TUI portability --- cmux-tui/crates/cmux-tui/src/main.rs | 5 +++++ cmux-tui/crates/cmux-tui/src/ui/graphics.rs | 17 ++++++++++++++++- cmux-tui/scripts/smoke-tui.py | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 4674c12443fe..30b1e3431ff0 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -26,6 +26,7 @@ use session::{RemoteSession, Session}; static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] extern "C" fn handle_signal(_: libc::c_int) { SHUTDOWN_REQUESTED.store(true, Ordering::Release); } @@ -34,6 +35,7 @@ pub(crate) fn shutdown_requested() -> bool { SHUTDOWN_REQUESTED.load(Ordering::Acquire) } +#[cfg(unix)] fn install_signal_handlers() { unsafe { libc::signal(libc::SIGTERM, handle_signal as *const () as libc::sighandler_t); @@ -42,6 +44,9 @@ fn install_signal_handlers() { } } +#[cfg(not(unix))] +fn install_signal_handlers() {} + const USAGE: &str = "\ cmux-tui - terminal multiplexer backed by libghostty-vt diff --git a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs index ef6f7dfc2682..a75e9011fdb7 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs @@ -1,6 +1,9 @@ use std::collections::{HashMap, HashSet}; use std::io::Write; -use std::time::{Duration, Instant}; +use std::time::Duration; + +#[cfg(unix)] +use std::time::Instant; use cmux_tui_core::{Rect, SurfaceId}; @@ -114,6 +117,7 @@ pub fn detect_cell_pixels(query_fallback: bool) -> (u16, u16) { (8, 16) } +#[cfg(unix)] fn ioctl_cell_pixels() -> Option<(u16, u16)> { let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0; @@ -125,6 +129,11 @@ fn ioctl_cell_pixels() -> Option<(u16, u16)> { Some((w, h)) } +#[cfg(not(unix))] +fn ioctl_cell_pixels() -> Option<(u16, u16)> { + None +} + fn query_cell_pixels() -> Option<(u16, u16)> { let (cols, rows) = crossterm::terminal::size().ok()?; if cols == 0 || rows == 0 { @@ -144,6 +153,7 @@ fn query_cell_pixels() -> Option<(u16, u16)> { Some((((width / cols as u32).max(1)) as u16, ((height / rows as u32).max(1)) as u16)) } +#[cfg(unix)] fn read_stdin_for(timeout: Duration) -> Vec { let start = Instant::now(); let mut out = Vec::new(); @@ -168,6 +178,11 @@ fn read_stdin_for(timeout: Duration) -> Vec { out } +#[cfg(not(unix))] +fn read_stdin_for(_timeout: Duration) -> Vec { + Vec::new() +} + fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|window| window == needle) } diff --git a/cmux-tui/scripts/smoke-tui.py b/cmux-tui/scripts/smoke-tui.py index d32c1f79008d..904f617bfb28 100644 --- a/cmux-tui/scripts/smoke-tui.py +++ b/cmux-tui/scripts/smoke-tui.py @@ -348,7 +348,7 @@ def render_text_snapshot(data, rows=30, cols=100): ident = rpc({"id": 1, "cmd": "identify"}) assert ident["ok"] and ident["data"]["app"] == "cmux-tui", ident -assert ident["data"]["protocol"] == 6, ident +assert ident["data"]["protocol"] == 7, ident print("identify ok:", ident["data"]) ws0 = tree()[0] From 12e392c0bb26fa1e6d89f3020ea05ff7abed5180 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:12:40 -0700 Subject: [PATCH 035/103] Finish ordered TUI lifecycle handling --- cmux-tui/crates/cmux-tui/src/app.rs | 248 +++++++++++++++++----- cmux-tui/crates/cmux-tui/src/pty_input.rs | 150 ++++++++++--- 2 files changed, 312 insertions(+), 86 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 9ab3541e5c12..1def2f3f7ad9 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -60,7 +60,7 @@ pub enum AppEvent { PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), RemoteTreeUpdated(Result), - SidebarPluginUpdated(SidebarPluginSurface), + SidebarPluginUpdated { status: SidebarPluginSurface, relaunch: bool }, } #[derive(Default)] @@ -459,6 +459,32 @@ impl OrderedSession { }); } + fn enqueue_coalescing_session_mutation( + &self, + label: &'static str, + key: (&'static str, u64), + operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, + ) { + let session = self.inner.clone(); + let pending = self.pending_mutation(); + let remote = self.remote; + let committed_mutation_generation = self.committed_mutation_generation.clone(); + self.operations.enqueue_coalescing_mutation(label, key, remote, move || { + if let Err(error) = operation(session.clone()) { + if remote && is_remote_timeout(&error) { + session.invalidate_remote_tree(); + pending.settle(SessionMutationOutcome::MutationTimedOut(error.to_string())); + } else { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + } + return Err(error); + } + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) + }); + } + fn resize_surface( &self, surface_id: SurfaceId, @@ -468,21 +494,16 @@ impl OrderedSession { reassert: bool, claim: SurfaceResizeClaim, ) { - let pending = self.pending_mutation(); - let committed_mutation_generation = self.committed_mutation_generation.clone(); - self.operations.enqueue_coalescing_mutation( + self.enqueue_coalescing_session_mutation( "resize PTY surface", ("surface resize", surface_id), - self.remote, - move || { + move |_| { let _claim = claim; if reassert { surface.reassert_size(cols, rows)?; } else { surface.resize(cols, rows)?; } - committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - pending.settle(SessionMutationOutcome::Success { tree: None }); Ok(()) }, ); @@ -551,22 +572,31 @@ impl OrderedSession { let events = self.events.clone(); let pending = self.pending_mutation(); let committed_mutation_generation = self.committed_mutation_generation.clone(); - self.operations.enqueue_coalescing_mutation( - "sync sidebar plugin", - ("sidebar plugin", 0), - self.remote, - move || { - let mut claim = claim; - let status = session.sidebar_plugin(size, relaunch); - let _ = events.send(AppEvent::SidebarPluginUpdated(status)); - committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - if let Some(claim) = &mut claim { - claim.mark_applied(); - } - pending.settle(SessionMutationOutcome::Success { tree: None }); - Ok(()) - }, - ); + let operation = move || { + let mut claim = claim; + let status = session.sidebar_plugin(size, relaunch); + let _ = events.send(AppEvent::SidebarPluginUpdated { status, relaunch }); + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + if let Some(claim) = &mut claim { + claim.mark_applied(); + } + pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) + }; + if relaunch { + self.operations.enqueue_session_mutation( + "relaunch sidebar plugin", + self.remote, + operation, + ); + } else { + self.operations.enqueue_coalescing_mutation( + "sync sidebar plugin", + ("sidebar plugin", 0), + self.remote, + operation, + ); + } } pub fn new_tab(&self, pane: Option, size: Option<(u16, u16)>) -> anyhow::Result<()> { @@ -635,26 +665,14 @@ impl OrderedSession { } fn set_ratio_deferred(&self, pane: PaneId, dir: SplitDir, ratio: f32) { - let session = self.inner.clone(); let direction = match dir { SplitDir::Right => "horizontal split ratio", SplitDir::Down => "vertical split ratio", }; - let pending = self.pending_mutation(); - let committed_mutation_generation = self.committed_mutation_generation.clone(); - self.operations.enqueue_coalescing_mutation( + self.enqueue_coalescing_session_mutation( "resize pane split", (direction, pane), - self.remote, - move || { - if let Err(error) = session.set_ratio(pane, dir, ratio) { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); - return Err(error); - } - committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - pending.settle(SessionMutationOutcome::Success { tree: None }); - Ok(()) - }, + move |session| session.set_ratio(pane, dir, ratio), ); } @@ -1085,6 +1103,7 @@ pub struct App { pub session_label: String, pub sidebar_visible: bool, pub sidebar_focused: bool, + pub sidebar_focus_pending: bool, /// Width of the sidebar in the current frame (0 when hidden). pub sidebar_width: u16, pub sidebar_plugin_surface: Option, @@ -1379,6 +1398,7 @@ pub fn run( session_label, sidebar_visible: true, sidebar_focused: false, + sidebar_focus_pending: false, sidebar_width: 0, sidebar_plugin_surface: None, sidebar_plugin_error: None, @@ -1744,7 +1764,7 @@ impl App { height: height.saturating_sub(1), // status bar }; self.content_area = area; - self.sync_sidebar_plugin(false); + let _ = self.sync_sidebar_plugin(false); self.replace_tree(self.session.tree()); let layout = self .tree @@ -1824,28 +1844,40 @@ impl App { } } - fn sync_sidebar_plugin(&mut self, relaunch: bool) { + fn sync_sidebar_plugin(&mut self, relaunch: bool) -> bool { if self.config.sidebar.plugin.is_none() || self.sidebar_width < 3 || !self.sidebar_visible { self.sidebar_plugin_surface = None; self.sidebar_plugin_error = None; self.sidebar_plugin_retry_after_ms = None; self.sidebar_focused = false; - return; + self.sidebar_focus_pending = false; + return false; } let rect = self.sidebar_plugin_rect(); if rect.width == 0 || rect.height == 0 { - return; + return false; } if relaunch && !self.prepare_pty_input_before_mutation() { - return; + return false; } self.session.sidebar_plugin((rect.width, rect.height), relaunch); + true } - fn apply_sidebar_plugin_status(&mut self, status: SidebarPluginSurface) { + fn apply_sidebar_plugin_status(&mut self, status: SidebarPluginSurface, relaunch: bool) { self.sidebar_plugin_surface = status.surface_id; self.sidebar_plugin_error = status.error; self.sidebar_plugin_retry_after_ms = status.retry_after_ms; + if self.sidebar_focus_pending && (self.sidebar_plugin_surface.is_some() || relaunch) { + self.sidebar_focus_pending = false; + if self.sidebar_plugin_surface.is_some() { + self.sidebar_focused = true; + self.menu = None; + self.prompt = None; + self.omnibar = None; + self.selection = None; + } + } if self.sidebar_focused && self.sidebar_plugin_surface.is_none() { self.sidebar_focused = false; } @@ -1890,8 +1922,8 @@ impl App { AppEvent::MuxTitlesReady => { Ok(if self.apply_mux_titles() { RenderAction::Paint } else { RenderAction::None }) } - AppEvent::SidebarPluginUpdated(status) => { - self.apply_sidebar_plugin_status(status); + AppEvent::SidebarPluginUpdated { status, relaunch } => { + self.apply_sidebar_plugin_status(status, relaunch); Ok(RenderAction::Draw) } AppEvent::Mux(MuxEvent::Empty) => { @@ -1935,16 +1967,19 @@ impl App { } AppEvent::Mux(MuxEvent::SurfaceOutput(id)) => { if self.sidebar_plugin_surface == Some(id) { - return Ok(RenderAction::Draw); + return Ok(RenderAction::Paint); } if self.frame_only_browser_update(id) { Ok(RenderAction::Graphics) } else { - Ok(RenderAction::Draw) + Ok(RenderAction::Paint) } } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::PtyOperationFailed(failure) => { + if failure.label == "relaunch sidebar plugin" { + self.sidebar_focus_pending = false; + } if failure.kind == Some(PtyInputKind::Motion) && failure.delivery == PtyOperationDelivery::KnownNotDelivered { @@ -3016,19 +3051,26 @@ impl App { fn toggle_sidebar_focus(&mut self) { if self.sidebar_focused { self.sidebar_focused = false; + self.sidebar_focus_pending = false; + return; + } + if self.sidebar_focus_pending { + self.sidebar_focus_pending = false; return; } if self.config.sidebar.plugin.is_none() { return; } self.sidebar_visible = true; - self.sync_sidebar_plugin(true); + let requested = self.sync_sidebar_plugin(true); if self.sidebar_plugin_surface.is_some() { self.sidebar_focused = true; self.menu = None; self.prompt = None; self.omnibar = None; self.selection = None; + } else if requested { + self.sidebar_focus_pending = true; } } @@ -4059,14 +4101,16 @@ impl App { && self.sidebar_plugin_rect().contains(x, y) && self.sidebar_visible { - self.sync_sidebar_plugin(true); + let requested = self.sync_sidebar_plugin(true); self.sidebar_focused = self.sidebar_plugin_surface.is_some(); + self.sidebar_focus_pending = requested && self.sidebar_plugin_surface.is_none(); return Ok(RenderAction::Draw); } // Any click outside the plugin rect returns keyboard focus to the // panes; otherwise typing would keep going to the plugin PTY after // the user clicked into a pane. self.sidebar_focused = false; + self.sidebar_focus_pending = false; if let Some(hit) = self.hit_at(x, y) { match hit { @@ -4845,7 +4889,7 @@ mod tests { use std::time::Duration; use cmux_tui_core::{ - BrowserStatus, Mux, Node, Rect, SplitDir, SurfaceId, SurfaceKind, SurfaceOptions, + BrowserStatus, Mux, MuxEvent, Node, Rect, SplitDir, SurfaceId, SurfaceKind, SurfaceOptions, }; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -4863,7 +4907,7 @@ mod tests { PtyOperationDelivery, PtyOperationFailure, }; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; - use crate::session::{Session, TreeView}; + use crate::session::{Session, SidebarPluginSurface, TreeView}; #[test] fn browser_omnibar_reduces_content_rect_for_graphics_and_input() { @@ -5382,7 +5426,7 @@ mod tests { let mut updates = 0; while let Ok(event) = events.recv_timeout(Duration::from_millis(100)) { - if matches!(event, AppEvent::SidebarPluginUpdated(_)) { + if matches!(event, AppEvent::SidebarPluginUpdated { .. }) { updates += 1; } app.handle(event).unwrap(); @@ -5395,6 +5439,76 @@ mod tests { assert_eq!(app.session.sidebar_plugin_sync.lock().unwrap().applied, Some(((24, 8), 0))); } + #[test] + fn explicit_sidebar_relaunch_is_a_barrier_before_passive_sync() { + let mux = Mux::new("sidebar-plugin-relaunch-barrier-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (unblock_tx, unblock_rx) = std::sync::mpsc::channel(); + app.session.operations.enqueue_session_mutation("blocker", false, move || { + started_tx.send(()).unwrap(); + unblock_rx.recv().unwrap(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + app.session.sidebar_plugin((24, 8), true); + app.session.sidebar_plugin((24, 8), false); + unblock_tx.send(()).unwrap(); + + let mut updates = Vec::new(); + while updates.len() < 2 { + let event = events.recv_timeout(Duration::from_secs(1)).unwrap(); + if let AppEvent::SidebarPluginUpdated { relaunch, .. } = &event { + updates.push(*relaunch); + } + app.handle(event).unwrap(); + } + assert_eq!(updates, vec![true, false]); + } + + #[test] + fn pending_sidebar_focus_is_fulfilled_by_async_relaunch_success() { + let mux = Mux::new("sidebar-plugin-focus-intent-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.plugin = Some(cmux_tui_core::SidebarPluginOptions { + command: vec!["unused".to_string()], + cwd: None, + }); + app.sidebar_width = 12; + app.content_area.height = 8; + + app.toggle_sidebar_focus(); + assert!(app.sidebar_focus_pending); + assert!(!app.sidebar_focused); + + app.handle(AppEvent::SidebarPluginUpdated { + status: SidebarPluginSurface { + surface_id: Some(42), + error: None, + retry_after_ms: None, + }, + relaunch: true, + }) + .unwrap(); + + assert!(!app.sidebar_focus_pending); + assert!(app.sidebar_focused); + assert_eq!(app.sidebar_plugin_surface, Some(42)); + } + + #[test] + fn surface_output_is_paint_only_and_preserves_the_topology_index() { + let mux = Mux::new("surface-output-paint-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.tab_locations.insert(77, [1, 2, 3, 4]); + + let action = app.handle(AppEvent::Mux(MuxEvent::SurfaceOutput(77))).unwrap(); + + assert_eq!(action, RenderAction::Paint); + assert_eq!(app.tab_locations.get(&77), Some(&[1, 2, 3, 4])); + } + #[test] fn remote_mutation_timeout_keeps_deferred_routing_state() { let mux = Mux::new("mutation-timeout-test", SurfaceOptions::default()); @@ -5418,6 +5532,33 @@ mod tests { ); } + #[test] + fn remote_coalesced_resize_timeouts_settle_as_ambiguous() { + let mux = Mux::new("coalesced-timeout-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.session.remote = true; + app.deferred_input + .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + + for (label, key) in [ + ("resize PTY surface", ("surface resize", 7)), + ("resize pane split", ("horizontal split ratio", 8)), + ] { + app.session.enqueue_coalescing_session_mutation(label, key, |_| { + Err(crate::session::test_remote_timeout_error()) + }); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!( + settled, + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::MutationTimedOut( + _ + )) + )); + app.handle(settled).unwrap(); + assert_eq!(app.deferred_input.len(), 1); + } + } + #[test] fn browser_completion_waits_for_its_authoritative_identity_generation() { let mux = Mux::new("browser-completion-generation-test", SurfaceOptions::default()); @@ -6013,6 +6154,7 @@ mod tests { session_label: "test".to_string(), sidebar_visible: true, sidebar_focused: false, + sidebar_focus_pending: false, sidebar_width: 0, sidebar_plugin_surface: None, sidebar_plugin_error: None, diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 02b7220c174f..62c40ba2ccce 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -5,7 +5,7 @@ //! motion is coalesced, and every accepted mouse press reserves its release //! capacity. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -235,16 +235,7 @@ impl PtyInputDispatcher { pub fn shutdown(&mut self, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; let mut state = self.sender.queue.state.lock().unwrap(); - let releases = state - .events - .drain(..) - .filter(|event| event.kind == PtyInputKind::Release) - .collect::>(); - state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); - state.events = releases; - state.release_reservations.clear(); state.closed = true; - state.shutdown_release_drain = true; self.sender.queue.changed.notify_all(); while (!state.events.is_empty() || state.in_flight.is_some()) && Instant::now() < deadline { let remaining = deadline.saturating_duration_since(Instant::now()); @@ -252,7 +243,21 @@ impl PtyInputDispatcher { state = next; } let drained = state.events.is_empty() && state.in_flight.is_none(); + let canceled = if drained { + Vec::new() + } else { + state.shutdown_release_drain = true; + let canceled = prune_to_recovery_releases( + &mut state, + "canceled after the shutdown drain timed out", + ); + self.sender.queue.changed.notify_all(); + canceled + }; drop(state); + for failure in canceled { + (self.sender.on_failure)(failure); + } if drained && let Some(worker) = self.worker.take() { let _ = worker.join(); } @@ -537,29 +542,10 @@ fn worker(queue: Arc, on_failure: Arc>(); - for event in queued { - if event.kind == PtyInputKind::Release { - releases.push_back(event); - } else { - if let Some(reservation_id) = event.reservation_id { - state.release_reservations.outstanding.remove(&reservation_id); - } - canceled.push(PtyOperationFailure { - surface_id: (event.kind != PtyInputKind::Mutation) - .then_some(event.surface_id), - kind: (event.kind != PtyInputKind::Mutation).then_some(event.kind), - reservation_id: event.reservation_id, - label: event.label, - error: "canceled after a remote request timed out".into(), - lane_failed: false, - delivery: PtyOperationDelivery::KnownNotDelivered, - }); - } - } - state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); - state.events = releases; + canceled.extend(prune_to_recovery_releases( + &mut state, + "canceled after a remote request timed out", + )); } state.in_flight = None; queue.changed.notify_all(); @@ -573,6 +559,45 @@ fn worker(queue: Arc, on_failure: Arc Vec { + let canceled_press_reservations = state + .events + .iter() + .filter(|event| event.kind == PtyInputKind::Press) + .filter_map(|event| event.reservation_id) + .collect::>(); + let mut releases = VecDeque::new(); + let mut canceled = Vec::new(); + for event in state.events.drain(..) { + let retain_release = event.kind == PtyInputKind::Release + && event.reservation_id.is_some_and(|id| !canceled_press_reservations.contains(&id)); + if retain_release { + releases.push_back(event); + continue; + } + if event.kind == PtyInputKind::Press + && let Some(reservation_id) = event.reservation_id + { + state.release_reservations.outstanding.remove(&reservation_id); + } + canceled.push(PtyOperationFailure { + surface_id: (event.kind != PtyInputKind::Mutation).then_some(event.surface_id), + kind: (event.kind != PtyInputKind::Mutation).then_some(event.kind), + reservation_id: event.reservation_id, + label: event.label, + error: error.into(), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + }); + } + state.queued_bytes = releases.iter().map(|event| event.bytes.len()).sum(); + state.events = releases; + canceled +} + #[cfg(test)] mod tests { use super::*; @@ -824,6 +849,7 @@ mod tests { state.events.push_back(event(1, 1, PtyInputKind::Motion)); let released_flag = released.clone(); let mut release = event(1, 2, PtyInputKind::Release); + release.reservation_id = Some(7); release.mutation = Some(Box::new(move || { released_flag.store(true, std::sync::atomic::Ordering::Release); Ok(()) @@ -874,6 +900,64 @@ mod tests { assert!(released.load(std::sync::atomic::Ordering::Acquire)); } + #[test] + fn shutdown_closes_enqueue_then_drains_accepted_fifo_work() { + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (unblock_tx, unblock_rx) = std::sync::mpsc::channel(); + let order = Arc::new(Mutex::new(Vec::new())); + let mut dispatcher = PtyInputDispatcher::spawn(|_| {}).unwrap(); + let sender = dispatcher.sender(); + let first_order = order.clone(); + sender.enqueue_session_mutation("first", false, move || { + started_tx.send(()).unwrap(); + unblock_rx.recv().unwrap(); + first_order.lock().unwrap().push(1); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let second_order = order.clone(); + sender.enqueue_session_mutation("second", false, move || { + second_order.lock().unwrap().push(2); + Ok(()) + }); + + let queue = dispatcher.sender.queue.clone(); + let shutdown = std::thread::spawn(move || dispatcher.shutdown(Duration::from_secs(1))); + while !queue.state.lock().unwrap().closed { + std::thread::yield_now(); + } + assert_eq!( + sender.enqueue(event(1, 3, PtyInputKind::Ordered)), + PtyInputEnqueueResult::Saturated + ); + unblock_tx.send(()).unwrap(); + + assert!(shutdown.join().unwrap()); + assert_eq!(*order.lock().unwrap(), vec![1, 2]); + } + + #[test] + fn recovery_prune_drops_release_paired_with_a_canceled_queued_press() { + let mut state = QueueState::default(); + let mut press = event(7, 1, PtyInputKind::Press); + press.reservation_id = Some(11); + let mut paired_release = event(7, 2, PtyInputKind::Release); + paired_release.reservation_id = Some(11); + let mut ambiguous_release = event(7, 3, PtyInputKind::Release); + ambiguous_release.reservation_id = Some(12); + state.events = VecDeque::from([press, paired_release, ambiguous_release]); + state.queued_bytes = 3; + state.release_reservations.outstanding.insert(11, 7); + + let canceled = prune_to_recovery_releases(&mut state, "timed out"); + + assert_eq!(canceled.len(), 2); + assert_eq!(state.events.len(), 1); + assert_eq!(state.events[0].reservation_id, Some(12)); + assert_eq!(state.queued_bytes, 1); + assert!(!state.release_reservations.outstanding.contains_key(&11)); + } + #[test] fn shutdown_timeout_discards_backlog_without_an_in_flight_press() { let queue = Arc::new(SharedQueue::default()); From 111183cfe7fa9c7523232f0d997a54301103f786 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:49:58 -0700 Subject: [PATCH 036/103] Close remaining TUI hot-path gaps --- cmux-tui/crates/cmux-tui-core/src/server.rs | 2 +- cmux-tui/crates/cmux-tui/src/app.rs | 185 +++++++++++++++++--- cmux-tui/crates/cmux-tui/src/pty_input.rs | 127 +++++++++++++- cmux-tui/crates/cmux-tui/src/ui/graphics.rs | 5 - 4 files changed, 280 insertions(+), 39 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index f8fa05c69064..09726fb1be8e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -1594,7 +1594,7 @@ mod tests { command: Some(vec![ "/bin/sh".to_string(), "-c".to_string(), - "printf '\\033]2;server title\\007'".to_string(), + "printf '\\033]2;server title\\007'; exec cat".to_string(), ]), ..SurfaceOptions::default() }, diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 1def2f3f7ad9..a6b7f5d5808d 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -6,7 +6,7 @@ //! snapshots, prefix arming, the current layout, hit map, selection, and //! menu/prompt overlays). -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Write; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; @@ -174,19 +174,24 @@ enum SurfaceResizeDecision { #[derive(Default)] struct SidebarPluginSyncState { - claimed: Option<((u16, u16), u64)>, - applied: Option<((u16, u16), u64)>, + epoch: u64, + claimed: Option<((u16, u16), u64, u64)>, + applied: Option<((u16, u16), u64, u64)>, } struct SidebarPluginSyncClaim { state: Arc>, - desired: ((u16, u16), u64), + desired: ((u16, u16), u64, u64), applied: bool, } impl SidebarPluginSyncClaim { fn mark_applied(&mut self) { let mut state = self.state.lock().unwrap(); + if state.epoch != self.desired.2 { + self.applied = true; + return; + } state.applied = Some(self.desired); if state.claimed == Some(self.desired) { state.claimed = None; @@ -232,6 +237,7 @@ pub struct OrderedSession { surface_resize_claims: Arc>>, config_generation: Arc, sidebar_plugin_sync: Arc>, + exited_surfaces: Arc>>, } impl OrderedSession { @@ -249,6 +255,7 @@ impl OrderedSession { surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), config_generation: Arc::new(AtomicU64::new(0)), sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), + exited_surfaces: Arc::new(Mutex::new(HashSet::new())), } } @@ -274,6 +281,7 @@ impl OrderedSession { } fn forget_surface(&self, id: SurfaceId) { + self.exited_surfaces.lock().unwrap().insert(id); self.inner.forget_surface(id); } @@ -286,16 +294,24 @@ impl OrderedSession { } fn attach_surface(&self, id: SurfaceId, size: Option<(u16, u16)>) { - if self.inner.cached_surface(id).is_some() { + if !self.can_attach_surface(id) { return; } let session = self.inner.clone(); + let exited_surfaces = self.exited_surfaces.clone(); + let remote = self.remote; let pending = self.pending_mutation(); self.operations.enqueue_coalescing_mutation( "attach surface", ("attach surface", id), self.remote, move || { + if exited_surfaces.lock().unwrap().contains(&id) + || (remote && session.remote_tree_is_stale()) + { + pending.settle(SessionMutationOutcome::Success { tree: None }); + return Ok(()); + } if session.surface_sized(id, size).is_none() { pending.settle(SessionMutationOutcome::Failed(format!( "surface {id} is unavailable" @@ -308,6 +324,12 @@ impl OrderedSession { ); } + fn can_attach_surface(&self, id: SurfaceId) -> bool { + self.inner.cached_surface(id).is_none() + && !self.exited_surfaces.lock().unwrap().contains(&id) + && (!self.remote || !self.inner.remote_tree_is_stale()) + } + fn has_pending_mutations(&self) -> bool { self.pending_mutations.load(Ordering::Acquire) > 0 } @@ -556,8 +578,8 @@ impl OrderedSession { let claim = if relaunch { None } else { - let desired = (size, config_generation); let mut state = self.sidebar_plugin_sync.lock().unwrap(); + let desired = (size, config_generation, state.epoch); if state.claimed == Some(desired) || state.applied == Some(desired) { return; } @@ -599,6 +621,16 @@ impl OrderedSession { } } + fn invalidate_sidebar_plugin_sync(&self) { + let mut state = self.sidebar_plugin_sync.lock().unwrap(); + if state.claimed.is_none() && state.applied.is_none() { + return; + } + state.epoch = state.epoch.wrapping_add(1); + state.claimed = None; + state.applied = None; + } + pub fn new_tab(&self, pane: Option, size: Option<(u16, u16)>) -> anyhow::Result<()> { self.enqueue("create tab", move |session| session.new_tab(pane, size)); Ok(()) @@ -1609,8 +1641,13 @@ impl App { } fn replace_tree(&mut self, tree: TreeView) { + self.tree = tree; + self.rebuild_tab_locations(); + } + + fn rebuild_tab_locations(&mut self) { self.tab_locations.clear(); - for (workspace_index, workspace) in tree.workspaces.iter().enumerate() { + for (workspace_index, workspace) in self.tree.workspaces.iter().enumerate() { for (screen_index, screen) in workspace.screens.iter().enumerate() { for (pane_index, pane) in screen.panes.iter().enumerate() { for (tab_index, tab) in pane.tabs.iter().enumerate() { @@ -1622,7 +1659,26 @@ impl App { } } } - self.tree = tree; + } + + fn remove_surface_from_tree(&mut self, surface: SurfaceId) { + for workspace in &mut self.tree.workspaces { + for screen in &mut workspace.screens { + for pane in &mut screen.panes { + let Some(index) = pane.tabs.iter().position(|tab| tab.surface == surface) + else { + continue; + }; + pane.tabs.remove(index); + if pane.active_tab > index { + pane.active_tab -= 1; + } else if pane.active_tab >= pane.tabs.len() { + pane.active_tab = pane.tabs.len().saturating_sub(1); + } + } + } + } + self.rebuild_tab_locations(); } fn apply_session_completions_through(&mut self, authoritative_generation: u64) { @@ -1806,7 +1862,9 @@ impl App { let size = Some((content.width, content.height)); for tab in &pane.tabs { if !self.session.has_surface(tab.surface) { - if self.prepare_pty_input_before_mutation() { + if self.session.can_attach_surface(tab.surface) + && self.prepare_pty_input_before_mutation() + { self.session.attach_surface(tab.surface, size); } continue; @@ -1846,6 +1904,7 @@ impl App { fn sync_sidebar_plugin(&mut self, relaunch: bool) -> bool { if self.config.sidebar.plugin.is_none() || self.sidebar_width < 3 || !self.sidebar_visible { + self.session.invalidate_sidebar_plugin_sync(); self.sidebar_plugin_surface = None; self.sidebar_plugin_error = None; self.sidebar_plugin_retry_after_ms = None; @@ -1865,9 +1924,22 @@ impl App { } fn apply_sidebar_plugin_status(&mut self, status: SidebarPluginSurface, relaunch: bool) { + if !self.sidebar_visible || self.config.sidebar.plugin.is_none() { + self.session.invalidate_sidebar_plugin_sync(); + self.sidebar_plugin_surface = None; + self.sidebar_plugin_error = None; + self.sidebar_plugin_retry_after_ms = None; + self.sidebar_focused = false; + self.sidebar_focus_pending = false; + return; + } + let had_surface = self.sidebar_plugin_surface.is_some(); self.sidebar_plugin_surface = status.surface_id; self.sidebar_plugin_error = status.error; self.sidebar_plugin_retry_after_ms = status.retry_after_ms; + if had_surface && self.sidebar_plugin_surface.is_none() { + self.session.invalidate_sidebar_plugin_sync(); + } if self.sidebar_focus_pending && (self.sidebar_plugin_surface.is_some() || relaunch) { self.sidebar_focus_pending = false; if self.sidebar_plugin_surface.is_some() { @@ -1937,7 +2009,9 @@ impl App { } self.render_states.remove(&id); self.session.forget_surface(id); + self.remove_surface_from_tree(id); if self.sidebar_plugin_surface == Some(id) { + self.session.invalidate_sidebar_plugin_sync(); self.sidebar_plugin_surface = None; self.sidebar_plugin_error = Some("sidebar plugin exited".to_string()); self.sidebar_focused = false; @@ -2319,7 +2393,9 @@ impl App { } SurfaceResizeDecision::NeedsQueue(_) => {} } - } else if self.prepare_pty_input_before_mutation() { + } else if self.session.can_attach_surface(area.surface) + && self.prepare_pty_input_before_mutation() + { self.session .attach_surface(area.surface, Some((area.content.width, area.content.height))); } @@ -2716,6 +2792,7 @@ impl App { Action::ToggleSidebar => { self.sidebar_visible = !self.sidebar_visible; if !self.sidebar_visible { + self.session.invalidate_sidebar_plugin_sync(); self.sidebar_focused = false; } } @@ -3323,7 +3400,7 @@ impl App { MouseButton::Left, mouse.modifiers, ) { - Ok(RenderAction::Draw) + Ok(RenderAction::None) } else { self.handle_left_drag(mouse.column, mouse.row) } @@ -3364,7 +3441,7 @@ impl App { MouseButton::Right, mouse.modifiers, ) { - Ok(RenderAction::Draw) + Ok(RenderAction::None) } else { self.handle_right_drag(mouse.column, mouse.row) } @@ -3394,18 +3471,15 @@ impl App { RenderAction::None }, ), - MouseEventKind::Drag(MouseButton::Middle) => Ok( - if self.forward_pty_mouse_drag( + MouseEventKind::Drag(MouseButton::Middle) => { + self.forward_pty_mouse_drag( mouse.column, mouse.row, MouseButton::Middle, mouse.modifiers, - ) { - RenderAction::Draw - } else { - RenderAction::None - }, - ), + ); + Ok(RenderAction::None) + } MouseEventKind::Up(MouseButton::Middle) => Ok( if self.finish_pty_mouse_drag( mouse.column, @@ -5019,8 +5093,11 @@ mod tests { .unwrap(); assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); - app.handle_mouse(event(MouseEventKind::Drag(MouseButton::Left), KeyModifiers::NONE)) - .unwrap(); + assert_eq!( + app.handle_mouse(event(MouseEventKind::Drag(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(), + RenderAction::None + ); assert!(app.encode_buf.is_empty()); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); @@ -5171,7 +5248,7 @@ mod tests { } #[test] - fn canceled_known_undelivered_motion_rolls_back_mouse_encoder_dedupe() { + fn evicted_known_undelivered_motion_allows_same_cell_retry() { let mux = Mux::new("motion-cancel-rollback-test", SurfaceOptions::default()); let mut app = test_app(Session::Local(mux)); let mut terminal = ghostty_vt::Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); @@ -5197,7 +5274,7 @@ mod tests { kind: Some(PtyInputKind::Motion), reservation_id: None, label: "PTY input", - error: "canceled before delivery".to_string(), + error: "evicted from the bounded PTY queue before delivery".to_string(), lane_failed: false, delivery: PtyOperationDelivery::KnownNotDelivered, })) @@ -5419,6 +5496,10 @@ mod tests { fn sidebar_plugin_sync_is_deduped_after_it_is_applied() { let mux = Mux::new("sidebar-plugin-sync-test", SurfaceOptions::default()); let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.config.sidebar.plugin = Some(cmux_tui_core::SidebarPluginOptions { + command: vec!["unused".to_string()], + cwd: None, + }); for _ in 0..1_000 { app.session.sidebar_plugin((24, 8), false); @@ -5436,7 +5517,61 @@ mod tests { } assert_eq!(updates, 1); assert!(!app.session.has_pending_mutations()); - assert_eq!(app.session.sidebar_plugin_sync.lock().unwrap().applied, Some(((24, 8), 0))); + assert_eq!(app.session.sidebar_plugin_sync.lock().unwrap().applied, Some(((24, 8), 0, 0))); + } + + #[test] + fn hiding_then_showing_sidebar_rehydrates_same_size_plugin_status() { + let mux = Mux::new("sidebar-plugin-hide-show-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.config.sidebar.plugin = Some(cmux_tui_core::SidebarPluginOptions { + command: vec!["unused".to_string()], + cwd: None, + }); + app.sidebar_width = 12; + app.content_area.height = 8; + app.session.sidebar_plugin((11, 9), false); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert!(app.session.sidebar_plugin_sync.lock().unwrap().applied.is_some()); + + app.sidebar_visible = false; + assert!(!app.sync_sidebar_plugin(false)); + assert!(app.session.sidebar_plugin_sync.lock().unwrap().applied.is_none()); + app.sidebar_visible = true; + + assert!(app.sync_sidebar_plugin(false)); + assert!(app.session.has_pending_mutations()); + } + + #[test] + fn surface_exit_removes_stale_topology_without_attach_or_failed_mutation() { + let mux = Mux::new("surface-exit-before-refresh-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let surface = 77; + app.session.remote = true; + app.replace_tree(notify_tree(surface, false)); + app.deferred_input + .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + + assert_eq!( + app.handle(AppEvent::Mux(MuxEvent::SurfaceExited(surface))).unwrap(), + RenderAction::Draw + ); + assert!(!app.tab_locations.contains_key(&surface)); + assert!(app.tree.workspaces[0].screens[0].panes[0].tabs.is_empty()); + + // Simulate the stale cached remote tree being painted before the + // authoritative exit refresh arrives. The exited-surface guard must + // still suppress a synchronous reattach attempt. + app.replace_tree(notify_tree(surface, false)); + app.session.attach_surface(surface, Some((80, 24))); + + assert!(!app.session.can_attach_surface(surface)); + assert!(!app.session.has_pending_mutations()); + assert_eq!(app.deferred_input.len(), 1); + assert!(events.try_recv().is_err()); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 62c40ba2ccce..ab28886388f1 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -286,7 +286,7 @@ impl PtyInputSender { } let reserves_release = event.kind == PtyInputKind::Press; let QueueState { events, queued_bytes, release_reservations, .. } = &mut *state; - let accepted = enqueue_bounded( + let outcome = enqueue_bounded_with_evictions( events, queued_bytes, release_reservations, @@ -294,13 +294,18 @@ impl PtyInputSender { QUEUE_CAPACITY, MAX_QUEUED_BYTES, ); - if accepted { + let result = if outcome.accepted { let reservation_id = reserves_release.then_some(release_reservations.next_id); self.queue.changed.notify_one(); (PtyInputEnqueueResult::Accepted, reservation_id) } else { (PtyInputEnqueueResult::Saturated, None) + }; + drop(state); + for failure in outcome.evicted { + (self.on_failure)(failure); } + result } pub fn cancel_release_reservation(&self, reservation_id: u64) { @@ -369,19 +374,45 @@ impl Drop for PtyInputDispatcher { } } +#[cfg(test)] fn enqueue_bounded( events: &mut VecDeque, queued_bytes: &mut usize, release_reservations: &mut ReleaseReservations, - mut event: PtyInputEvent, + event: PtyInputEvent, capacity: usize, max_bytes: usize, ) -> bool { + enqueue_bounded_with_evictions( + events, + queued_bytes, + release_reservations, + event, + capacity, + max_bytes, + ) + .accepted +} + +struct BoundedEnqueueOutcome { + accepted: bool, + evicted: Vec, +} + +fn enqueue_bounded_with_evictions( + events: &mut VecDeque, + queued_bytes: &mut usize, + release_reservations: &mut ReleaseReservations, + mut event: PtyInputEvent, + capacity: usize, + max_bytes: usize, +) -> BoundedEnqueueOutcome { + let mut evicted = Vec::new(); if let Some(key) = event.coalesce_key { for index in (0..events.len()).rev() { if events[index].coalesce_key == Some(key) { events[index] = event; - return true; + return BoundedEnqueueOutcome { accepted: true, evicted }; } if events[index].coalesce_key.is_none() { break; @@ -398,11 +429,11 @@ fn enqueue_bounded( + event.bytes.len() + release_reservations.len() * RESERVED_RELEASE_BYTES; if projected_bytes > max_bytes { - return false; + return BoundedEnqueueOutcome { accepted: false, evicted }; } *queued_bytes = queued_bytes.saturating_sub(previous_len) + event.bytes.len(); *events.back_mut().unwrap() = event; - return true; + return BoundedEnqueueOutcome { accepted: true, evicted }; } let merge_stream = event.kind == PtyInputKind::Ordered @@ -436,12 +467,21 @@ fn enqueue_bounded( while projected > capacity || projected_bytes > max_bytes { let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) else { - return false; + return BoundedEnqueueOutcome { accepted: false, evicted }; }; let removed = events.remove(index).unwrap(); *queued_bytes = queued_bytes.saturating_sub(removed.bytes.len()); projected -= 1; projected_bytes = projected_bytes.saturating_sub(removed.bytes.len()); + evicted.push(PtyOperationFailure { + surface_id: Some(removed.surface_id), + kind: Some(PtyInputKind::Motion), + reservation_id: removed.reservation_id, + label: removed.label, + error: "evicted from the bounded PTY queue before delivery".to_string(), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + }); } if event.kind == PtyInputKind::Press { @@ -461,7 +501,7 @@ fn enqueue_bounded( *queued_bytes += event.bytes.len(); events.push_back(event); } - true + BoundedEnqueueOutcome { accepted: true, evicted } } fn worker(queue: Arc, on_failure: Arc) { @@ -636,6 +676,77 @@ mod tests { assert_eq!(events[0].bytes.as_slice(), &[2]); } + #[test] + fn bounded_queue_reports_evicted_motion_as_known_not_delivered() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = ReleaseReservations::default(); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + event(7, 1, PtyInputKind::Motion), + 1, + 1024, + )); + + let outcome = enqueue_bounded_with_evictions( + &mut events, + &mut queued_bytes, + &mut releases, + event(8, 2, PtyInputKind::Ordered), + 1, + 1024, + ); + + assert!(outcome.accepted); + assert_eq!(outcome.evicted.len(), 1); + assert_eq!(outcome.evicted[0].surface_id, Some(7)); + assert_eq!(outcome.evicted[0].kind, Some(PtyInputKind::Motion)); + assert_eq!(outcome.evicted[0].delivery, PtyOperationDelivery::KnownNotDelivered); + assert_eq!(events.len(), 1); + assert_eq!(events[0].surface_id, 8); + } + + #[test] + fn sender_emits_failure_when_bounded_queue_evicts_motion() { + let (failure_tx, failure_rx) = std::sync::mpsc::channel(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (unblock_tx, unblock_rx) = std::sync::mpsc::channel(); + let dispatcher = PtyInputDispatcher::spawn(move |failure| { + failure_tx.send(failure).unwrap(); + }) + .unwrap(); + let sender = dispatcher.sender(); + sender.enqueue_session_mutation("blocker", false, move || { + started_tx.send(()).unwrap(); + unblock_rx.recv().unwrap(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!( + sender.enqueue(event(7, 1, PtyInputKind::Motion)), + PtyInputEnqueueResult::Accepted + ); + for _ in 0..QUEUE_CAPACITY - 1 { + assert_eq!( + sender.enqueue(PtyInputEvent::mutation("fill", None, false, || Ok(()))), + PtyInputEnqueueResult::Accepted + ); + } + + assert_eq!( + sender.enqueue(PtyInputEvent::mutation("overflow", None, false, || Ok(()))), + PtyInputEnqueueResult::Accepted + ); + let failure = failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(failure.surface_id, Some(7)); + assert_eq!(failure.kind, Some(PtyInputKind::Motion)); + assert_eq!(failure.delivery, PtyOperationDelivery::KnownNotDelivered); + + unblock_tx.send(()).unwrap(); + } + #[test] fn ordered_bytes_batch_without_crossing_motion_or_surfaces() { let mut events = VecDeque::new(); diff --git a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs index dbfb8fad5ca5..1bc95faf770a 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs @@ -152,11 +152,6 @@ fn query_cell_pixels() -> Option<(u16, u16)> { Some((((width / cols as u32).max(1)) as u16, ((height / rows as u32).max(1)) as u16)) } -#[cfg(not(unix))] -fn ioctl_cell_pixels() -> Option<(u16, u16)> { - None -} - #[cfg(unix)] fn read_stdin_for(timeout: Duration) -> Vec { let start = Instant::now(); From 703b1b627d2719f11317e6d37c93cc7e9d767447 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:32:00 -0700 Subject: [PATCH 037/103] Harden TUI asynchronous input lifecycle --- cmux-tui/bindings/python/e2e.py | 5 +- cmux-tui/bindings/typescript/e2e/e2e.ts | 5 +- cmux-tui/crates/cmux-tui-core/src/browser.rs | 34 +-- cmux-tui/crates/cmux-tui-core/src/surface.rs | 13 +- cmux-tui/crates/cmux-tui/src/app.rs | 270 ++++++++++++++++-- cmux-tui/crates/cmux-tui/src/browser_input.rs | 100 ++++++- cmux-tui/crates/cmux-tui/src/pty_input.rs | 50 ++-- cmux-tui/crates/cmux-tui/src/session/mod.rs | 12 +- 8 files changed, 407 insertions(+), 82 deletions(-) diff --git a/cmux-tui/bindings/python/e2e.py b/cmux-tui/bindings/python/e2e.py index 0765ac019555..437e571487be 100755 --- a/cmux-tui/bindings/python/e2e.py +++ b/cmux-tui/bindings/python/e2e.py @@ -31,9 +31,10 @@ def main() -> int: events = client.subscribe() try: title = f"{marker}_TITLE" - client.send(created.surface, text=f"printf '\\033]2;{title}\\007'; sleep 1\r") - title_changed = next_title_changed(events, created.surface, title, 1.0) + client.send(created.surface, text=f"printf '\\033]2;{title}\\007'; sleep 5\r") + title_changed = next_title_changed(events, created.surface, title, 3.0) assert title_changed.title == title, title_changed + client.send(created.surface, text="\x03") client.resize_surface(created.surface, 100, 31) resized = next_resized(events, created.surface, 1.0) assert (resized.cols, resized.rows) == (100, 31) diff --git a/cmux-tui/bindings/typescript/e2e/e2e.ts b/cmux-tui/bindings/typescript/e2e/e2e.ts index ba77843ab187..5c05e0af3521 100644 --- a/cmux-tui/bindings/typescript/e2e/e2e.ts +++ b/cmux-tui/bindings/typescript/e2e/e2e.ts @@ -25,9 +25,10 @@ async function main(): Promise { await client.renameSurface(created.surface, `${marker}-renamed`); const events = await client.subscribe(); const title = `${marker}-title`; - await client.send(created.surface, { text: `printf '\\033]2;${title}\\007'; sleep 1\r` }); - const titleChanged = await nextTitleChanged(events, created.surface, title, 1000); + await client.send(created.surface, { text: `printf '\\033]2;${title}\\007'; sleep 5\r` }); + const titleChanged = await nextTitleChanged(events, created.surface, title, 3000); assert(titleChanged.title === title, `bad title event ${JSON.stringify(titleChanged)}`); + await client.send(created.surface, { text: "\x03" }); await client.resizeSurface(created.surface, 100, 31); const resized = await nextSurfaceResized(events, created.surface, 1000); assert(resized.cols === 100 && resized.rows === 31, `bad resize event ${JSON.stringify(resized)}`); diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 88b58593aa5a..7de5433873dc 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -643,36 +643,36 @@ impl BrowserSurface { self.resize(cols, rows); } - fn try_resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> { + pub(crate) fn try_resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> { let (cols, rows) = (cols.max(1), rows.max(1)); let cell = *self.cell_pixels.lock().unwrap(); let pixel_w = cols as u32 * cell.0.max(1) as u32; let pixel_h = rows as u32 * cell.1.max(1) as u32; - let (unchanged, capture_w, capture_h) = { - let mut state = self.state.lock().unwrap(); + let (unchanged, capture_w, capture_h, capture_scale, capture_pixels) = { + let state = self.state.lock().unwrap(); let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); let unchanged = state.size == (cols, rows) && state.pane_pixels == (pixel_w, pixel_h) && state.capture_pixels == capture_pixels; - state.size = (cols, rows); - state.pane_pixels = (pixel_w, pixel_h); - state.capture_pixels = capture_pixels; - state.capture_scale = capture_scale; - if !unchanged { - state.live_since = Some(Instant::now()); - state.last_frame_at = None; - state.stall_nudged = false; - } - (unchanged, capture_pixels.0, capture_pixels.1) + (unchanged, capture_pixels.0, capture_pixels.1, capture_scale, capture_pixels) }; if unchanged { return Ok(()); } - let Some(session) = self.live_session()? else { return Ok(()) }; - session.runtime.client.set_device_metrics(&session.session_id, capture_w, capture_h)?; - let _ = session.runtime.client.stop_screencast(&session.session_id); - session.runtime.client.start_screencast(&session.session_id, capture_w, capture_h)?; + if let Some(session) = self.live_session()? { + session.runtime.client.set_device_metrics(&session.session_id, capture_w, capture_h)?; + let _ = session.runtime.client.stop_screencast(&session.session_id); + session.runtime.client.start_screencast(&session.session_id, capture_w, capture_h)?; + } + let mut state = self.state.lock().unwrap(); + state.size = (cols, rows); + state.pane_pixels = (pixel_w, pixel_h); + state.capture_pixels = capture_pixels; + state.capture_scale = capture_scale; + state.live_since = Some(Instant::now()); + state.last_frame_at = None; + state.stall_nudged = false; Ok(()) } diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 3b04266b1f35..a7af6ee4adfe 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -513,12 +513,19 @@ impl Surface { /// use the last configured cell pixel size for CDP device metrics. /// Returns whether the final clamped size actually changed. pub fn resize(&self, cols: u16, rows: u16) -> bool { + self.try_resize(cols, rows).unwrap_or_else(|error| { + eprintln!("cmux-tui: surface resize failed for surface {}: {error}", self.id); + false + }) + } + + pub fn try_resize(&self, cols: u16, rows: u16) -> anyhow::Result { match self { - Surface::Pty(pty) => pty.resize(cols, rows), + Surface::Pty(pty) => Ok(pty.resize(cols, rows)), Surface::Browser(browser) => { let before = browser.size(); - browser.resize(cols, rows); - browser.size() != before + browser.try_resize(cols, rows)?; + Ok(browser.size() != before) } } } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index a6b7f5d5808d..a2ab5b81364e 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -59,7 +59,7 @@ pub enum AppEvent { Input(Event), PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), - RemoteTreeUpdated(Result), + RemoteTreeUpdated { refresh_sequence: u64, result: Result }, SidebarPluginUpdated { status: SidebarPluginSurface, relaunch: bool }, } @@ -110,12 +110,16 @@ pub enum SessionMutationOutcome { IdentityRefreshSucceeded { tree: TreeView, authoritative_generation: u64, + refresh_sequence: u64, }, CommittedTreeStale { error: Option, completion: Option, }, - IdentityRefreshFailed(String), + IdentityRefreshFailed { + error: String, + refresh_sequence: u64, + }, MutationTimedOut(String), Failed(String), Canceled, @@ -161,9 +165,15 @@ impl Drop for RemoteRefreshClaim { } struct SurfaceResizeClaim { - claims: Arc>>, + claims: Arc>>, surface: SurfaceId, + token: u64, +} + +#[derive(Clone, Copy)] +struct SurfaceResizeClaimState { desired: (u16, u16), + token: u64, } enum SurfaceResizeDecision { @@ -215,7 +225,7 @@ impl Drop for SidebarPluginSyncClaim { impl Drop for SurfaceResizeClaim { fn drop(&mut self) { let mut claims = self.claims.lock().unwrap(); - if claims.get(&self.surface) == Some(&self.desired) { + if claims.get(&self.surface).is_some_and(|claim| claim.token == self.token) { claims.remove(&self.surface); } } @@ -234,7 +244,9 @@ pub struct OrderedSession { committed_mutation_generation: Arc, remote_refresh_queued: Arc, remote_background_dirty: Arc, - surface_resize_claims: Arc>>, + remote_refresh_sequence: Arc, + surface_resize_claims: Arc>>, + surface_resize_claim_sequence: Arc, config_generation: Arc, sidebar_plugin_sync: Arc>, exited_surfaces: Arc>>, @@ -252,7 +264,9 @@ impl OrderedSession { committed_mutation_generation: Arc::new(AtomicU64::new(0)), remote_refresh_queued: Arc::new(AtomicBool::new(false)), remote_background_dirty: Arc::new(AtomicBool::new(false)), + remote_refresh_sequence: Arc::new(AtomicU64::new(0)), surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), + surface_resize_claim_sequence: Arc::new(AtomicU64::new(0)), config_generation: Arc::new(AtomicU64::new(0)), sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), exited_surfaces: Arc::new(Mutex::new(HashSet::new())), @@ -281,7 +295,9 @@ impl OrderedSession { } fn forget_surface(&self, id: SurfaceId) { - self.exited_surfaces.lock().unwrap().insert(id); + if self.remote { + self.exited_surfaces.lock().unwrap().insert(id); + } self.inner.forget_surface(id); } @@ -330,6 +346,20 @@ impl OrderedSession { && (!self.remote || !self.inner.remote_tree_is_stale()) } + fn reconcile_exited_surfaces(&self, tree: &TreeView) { + if !self.remote { + return; + } + self.exited_surfaces.lock().unwrap().retain(|surface| { + tree.workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .any(|tab| tab.surface == *surface) + }); + } + fn has_pending_mutations(&self) -> bool { self.pending_mutations.load(Ordering::Acquire) > 0 } @@ -353,25 +383,28 @@ impl OrderedSession { self.inner.invalidate_remote_tree(); let session = self.inner.clone(); let authoritative_generation = self.committed_mutation_generation.load(Ordering::Acquire); + let refresh_sequence = self.remote_refresh_sequence.fetch_add(1, Ordering::AcqRel) + 1; let pending = self.pending_mutation(); let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); let spawn = std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { let result = session.refresh_tree(); - drop(claim); match result { Ok(tree) => { pending.settle(SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation, + refresh_sequence, }); } Err(error) => { - pending.settle(SessionMutationOutcome::IdentityRefreshFailed( - error.to_string(), - )); + pending.settle(SessionMutationOutcome::IdentityRefreshFailed { + error: error.to_string(), + refresh_sequence, + }); } } + drop(claim); }); if let Err(error) = spawn { self.remote_refresh_queued.store(false, Ordering::Release); @@ -398,16 +431,20 @@ impl OrderedSession { } let session = self.inner.clone(); let events = self.events.clone(); + let refresh_sequence = self.remote_refresh_sequence.fetch_add(1, Ordering::AcqRel) + 1; let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); let spawn = std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { let result = session.refresh_tree().map_err(|error| error.to_string()); + let _ = events.send(AppEvent::RemoteTreeUpdated { refresh_sequence, result }); drop(claim); - let _ = events.send(AppEvent::RemoteTreeUpdated(result)); }); if let Err(error) = spawn { self.remote_refresh_queued.store(false, Ordering::Release); - let _ = self.events.send(AppEvent::RemoteTreeUpdated(Err(error.to_string()))); + let _ = self.events.send(AppEvent::RemoteTreeUpdated { + refresh_sequence, + result: Err(error.to_string()), + }); } } @@ -537,20 +574,19 @@ impl OrderedSession { desired: (u16, u16), surface_needs_resize: bool, ) -> SurfaceResizeDecision { - { - let mut claims = self.surface_resize_claims.lock().unwrap(); - if claims.get(&surface_id) == Some(&desired) { - return SurfaceResizeDecision::AlreadyClaimed; - } - if !claims.contains_key(&surface_id) && !surface_needs_resize { - return SurfaceResizeDecision::Noop; - } - claims.insert(surface_id, desired); + let mut claims = self.surface_resize_claims.lock().unwrap(); + if claims.get(&surface_id).is_some_and(|claim| claim.desired == desired) { + return SurfaceResizeDecision::AlreadyClaimed; + } + if !claims.contains_key(&surface_id) && !surface_needs_resize { + return SurfaceResizeDecision::Noop; } + let token = self.surface_resize_claim_sequence.fetch_add(1, Ordering::AcqRel) + 1; + claims.insert(surface_id, SurfaceResizeClaimState { desired, token }); SurfaceResizeDecision::NeedsQueue(SurfaceResizeClaim { claims: self.surface_resize_claims.clone(), surface: surface_id, - desired, + token, }) } @@ -1171,6 +1207,7 @@ pub struct App { deferred_input: VecDeque, routing_refresh_pending: bool, routing_refresh_retries_remaining: u8, + last_applied_refresh_sequence: u64, pending_session_completions: VecDeque, mux_titles: Arc, drag: Option, @@ -1455,6 +1492,7 @@ pub fn run( deferred_input: VecDeque::new(), routing_refresh_pending: false, routing_refresh_retries_remaining: 0, + last_applied_refresh_sequence: 0, pending_session_completions: VecDeque::new(), mux_titles, drag: None, @@ -1703,6 +1741,23 @@ impl App { } } + fn accept_refresh_sequence(&mut self, refresh_sequence: u64) -> bool { + if refresh_sequence <= self.last_applied_refresh_sequence { + return false; + } + self.last_applied_refresh_sequence = refresh_sequence; + true + } + + fn complete_routing_after_stale_identity_result(&mut self) { + if !self.session.has_pending_mutations() + && !self.session.remote_tree_is_stale() + && !self.deferred_input.is_empty() + { + self.routing_refresh_pending = true; + } + } + fn draw_terminal( &mut self, terminal: &mut RatatuiTerminal>, @@ -2096,7 +2151,13 @@ impl App { SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation, + refresh_sequence, } => { + if !self.accept_refresh_sequence(refresh_sequence) { + self.complete_routing_after_stale_identity_result(); + return Ok(RenderAction::None); + } + self.session.reconcile_exited_surfaces(&tree); self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; self.apply_session_completions_through(authoritative_generation); @@ -2115,7 +2176,11 @@ impl App { self.session.invalidate_remote_tree(); self.session.refresh_remote_tree_if_stale(); } - SessionMutationOutcome::IdentityRefreshFailed(error) => { + SessionMutationOutcome::IdentityRefreshFailed { error, refresh_sequence } => { + if !self.accept_refresh_sequence(refresh_sequence) { + self.complete_routing_after_stale_identity_result(); + return Ok(RenderAction::None); + } self.status_message = Some(format!( "session changed, but its layout is still stale: {error}" )); @@ -2138,6 +2203,7 @@ impl App { } SessionMutationOutcome::Failed(error) => { self.deferred_input.clear(); + self.prefix_armed = false; self.pending_session_completions.clear(); self.status_message = Some(format!("session operation failed: {error}")); return Ok(RenderAction::Draw); @@ -2147,6 +2213,7 @@ impl App { return Ok(RenderAction::None); } self.deferred_input.clear(); + self.prefix_armed = false; self.pending_session_completions.clear(); self.status_message = Some("session operation was canceled".to_string()); return Ok(RenderAction::Draw); @@ -2159,9 +2226,13 @@ impl App { self.routing_refresh_pending = true; Ok(RenderAction::Draw) } - AppEvent::RemoteTreeUpdated(result) => { + AppEvent::RemoteTreeUpdated { refresh_sequence, result } => { + if !self.accept_refresh_sequence(refresh_sequence) { + return Ok(RenderAction::None); + } match result { Ok(tree) => { + self.session.reconcile_exited_surfaces(&tree); self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; } @@ -2419,10 +2490,16 @@ impl App { claim: Option, ) { if surface.kind() == SurfaceKind::Browser { + let Some(claim) = claim else { return }; self.browser_input.enqueue(BrowserInputEvent { surface_id, surface, - kind: BrowserInputKind::Resize { cols, rows, reassert }, + kind: BrowserInputKind::Resize { + cols, + rows, + reassert, + _claim: Some(Box::new(claim)), + }, }); } else { let Some(claim) = claim else { return }; @@ -4956,7 +5033,7 @@ mod tests { SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; - use std::collections::{HashMap, VecDeque}; + use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::Ordering; use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; @@ -4974,14 +5051,14 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; - use crate::browser_input::BrowserInputDispatcher; + use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; use crate::config::{ChromeTheme, Config, ScrollbarPosition}; use crate::pty_input::{ PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputKind, PtyOperationDelivery, PtyOperationFailure, }; use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; - use crate::session::{Session, SidebarPluginSurface, TreeView}; + use crate::session::{Session, SidebarPluginSurface, SurfaceHandle, TreeView}; #[test] fn browser_omnibar_reduces_content_rect_for_graphics_and_input() { @@ -5420,6 +5497,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: TreeView::default(), authoritative_generation: 0, + refresh_sequence: 1, }, )) .unwrap(); @@ -5428,7 +5506,7 @@ mod tests { assert!(!app.session.has_pending_mutations()); assert!(matches!( events.recv_timeout(Duration::from_secs(1)).unwrap(), - AppEvent::RemoteTreeUpdated(Ok(_)) + AppEvent::RemoteTreeUpdated { result: Ok(_), .. } )); } @@ -5490,6 +5568,126 @@ mod tests { app.session.surface_resize_decision(7, (80, 24), false), SurfaceResizeDecision::Noop )); + + let old_a = match app.session.surface_resize_decision(9, (80, 24), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("first A must queue"), + }; + let b = match app.session.surface_resize_decision(9, (100, 30), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("B must replace A"), + }; + let new_a = match app.session.surface_resize_decision(9, (80, 24), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("new A must replace B"), + }; + drop(old_a); + drop(b); + assert!(matches!( + app.session.surface_resize_decision(9, (80, 24), true), + SurfaceResizeDecision::AlreadyClaimed + )); + drop(new_a); + } + + #[test] + fn browser_resize_claim_survives_queue_and_input_barrier_until_drop() { + let mux = Mux::new("browser-resize-claim-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let (dispatcher, blocked) = BrowserInputDispatcher::blocked(2); + app.browser_input = dispatcher; + let claim = match app.session.surface_resize_decision(7, (100, 30), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("browser resize must queue"), + }; + app.enqueue_surface_resize( + 7, + SurfaceHandle::RemoteBrowserUnsupported, + 100, + 30, + false, + Some(claim), + ); + app.browser_input.enqueue(BrowserInputEvent { + surface_id: 7, + surface: SurfaceHandle::RemoteBrowserUnsupported, + kind: BrowserInputKind::Mouse { + event_type: "mouseMoved", + x: 1.0, + y: 1.0, + button: Some("none"), + click_count: None, + }, + }); + + assert!(matches!( + app.session.surface_resize_decision(7, (100, 30), true), + SurfaceResizeDecision::AlreadyClaimed + )); + drop(blocked); + assert!(matches!( + app.session.surface_resize_decision(7, (100, 30), true), + SurfaceResizeDecision::NeedsQueue(_) + )); + } + + #[test] + fn refresh_sequences_are_monotonic_across_identity_and_background_paths() { + let mux = Mux::new("refresh-sequence-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let newer = notify_tree(22, false); + let older = notify_tree(11, false); + app.session.pending_mutations.store(1, Ordering::Release); + app.deferred_input + .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + + app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(newer.clone()) }) + .unwrap(); + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: older.clone(), + authoritative_generation: 0, + refresh_sequence: 1, + }, + )) + .unwrap(); + assert_eq!(app.tree.workspaces[0].screens[0].panes[0].tabs[0].surface, 22); + assert!(app.routing_refresh_pending); + assert_eq!(app.deferred_input.len(), 1); + + app.routing_refresh_pending = false; + app.session.pending_mutations.store(1, Ordering::Release); + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: newer, + authoritative_generation: 0, + refresh_sequence: 4, + }, + )) + .unwrap(); + app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 3, result: Ok(older) }).unwrap(); + assert_eq!(app.tree.workspaces[0].screens[0].panes[0].tabs[0].surface, 22); + } + + #[test] + fn exited_surface_tombstones_are_remote_only_and_pruned_authoritatively() { + let mux = Mux::new("surface-tombstone-churn-test", SurfaceOptions::default()); + let app = test_app(Session::Local(mux)); + for surface in 1..=1_000 { + app.session.forget_surface(surface); + } + assert!(app.session.exited_surfaces.lock().unwrap().is_empty()); + + let mut app = app; + app.session.remote = true; + for surface in 1..=1_000 { + app.session.forget_surface(surface); + } + assert_eq!(app.session.exited_surfaces.lock().unwrap().len(), 1_000); + app.session.reconcile_exited_surfaces(¬ify_tree(1_000, false)); + assert_eq!(*app.session.exited_surfaces.lock().unwrap(), HashSet::from([1_000])); + app.session.reconcile_exited_surfaces(&TreeView::default()); + assert!(app.session.exited_surfaces.lock().unwrap().is_empty()); } #[test] @@ -5706,7 +5904,8 @@ mod tests { }); let tree = browser_completion_tree(surface, surface); - app.handle(AppEvent::RemoteTreeUpdated(Ok(tree.clone()))).unwrap(); + app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 1, result: Ok(tree.clone()) }) + .unwrap(); assert_eq!(app.pending_session_completions.len(), 1); assert!(app.omnibar.is_none()); @@ -5723,6 +5922,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: tree.clone(), authoritative_generation: 3, + refresh_sequence: 2, }, )) .unwrap(); @@ -5734,6 +5934,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation: 4, + refresh_sequence: 3, }, )) .unwrap(); @@ -5758,6 +5959,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: browser_completion_tree(created_surface, active_surface), authoritative_generation: 4, + refresh_sequence: 1, }, )) .unwrap(); @@ -5778,6 +5980,12 @@ mod tests { anyhow::bail!("selection rejected") }); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + )))) + .unwrap(); + assert!(app.prefix_armed); app.handle(AppEvent::Input(Event::Key(KeyEvent::new( KeyCode::Char('x'), KeyModifiers::NONE, @@ -5790,6 +5998,7 @@ mod tests { app.handle(settled).unwrap(); assert!(app.deferred_input.is_empty()); + assert!(!app.prefix_armed); assert_eq!( app.status_message.as_deref(), Some("session operation failed: selection rejected") @@ -6314,6 +6523,7 @@ mod tests { deferred_input: VecDeque::new(), routing_refresh_pending: false, routing_refresh_retries_remaining: 0, + last_applied_refresh_sequence: 0, pending_session_completions: VecDeque::new(), mux_titles: Arc::new(MuxTitleIngress::default()), drag: None, diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index c4ed57d8f87f..7e26927b25eb 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -34,14 +34,12 @@ use crate::session::SurfaceHandle; /// blocked worker caps queued work at a few hundred events. const QUEUE_CAPACITY: usize = 512; -#[derive(Clone)] pub struct BrowserInputEvent { pub surface_id: SurfaceId, pub surface: SurfaceHandle, pub kind: BrowserInputKind, } -#[derive(Clone)] pub enum BrowserInputKind { Mouse { event_type: &'static str, @@ -68,6 +66,7 @@ pub enum BrowserInputKind { cols: u16, rows: u16, reassert: bool, + _claim: Option>, }, } @@ -101,6 +100,11 @@ pub struct BrowserInputDispatcher { latest_resizes: Arc>>, } +#[cfg(test)] +pub(crate) struct BlockedBrowserInput { + _rx: Receiver, +} + impl BrowserInputDispatcher { pub fn spawn() -> anyhow::Result { let (tx, rx) = sync_channel(QUEUE_CAPACITY); @@ -114,6 +118,19 @@ impl BrowserInputDispatcher { Ok(BrowserInputDispatcher { tx, order, latest_resizes }) } + #[cfg(test)] + pub(crate) fn blocked(capacity: usize) -> (Self, BlockedBrowserInput) { + let (tx, rx) = sync_channel(capacity); + ( + BrowserInputDispatcher { + tx, + order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), + latest_resizes: Arc::new(Mutex::new(HashMap::new())), + }, + BlockedBrowserInput { _rx: rx }, + ) + } + /// Queue an event without blocking. A full queue retains the latest /// resize per surface and input-delimited run, and drops other input. pub fn enqueue(&self, event: BrowserInputEvent) { @@ -148,7 +165,7 @@ fn worker( let mut batch = batch.into_iter().map(|event| event.event).collect::>(); coalesce_browser_events(&mut batch); for event in batch { - dispatch(&event); + let _ = dispatch(&event); } } } @@ -199,9 +216,9 @@ fn coalesce_browser_events(batch: &mut Vec) { } } -fn dispatch(event: &BrowserInputEvent) { +fn dispatch(event: &BrowserInputEvent) -> anyhow::Result<()> { let surface = &event.surface; - let _ = match &event.kind { + match &event.kind { BrowserInputKind::Mouse { event_type, x, y, button, click_count } => { surface.browser_mouse_event(event_type, *x, *y, *button, *click_count) } @@ -222,19 +239,28 @@ fn dispatch(event: &BrowserInputEvent) { *text, ), BrowserInputKind::InsertText(text) => surface.browser_insert_text(text), - BrowserInputKind::Resize { cols, rows, reassert } => { + BrowserInputKind::Resize { cols, rows, reassert, .. } => { if *reassert { surface.reassert_size(*cols, *rows) } else { surface.resize(*cols, *rows) } } - }; + } } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } fn move_event(surface: SurfaceId, x: f64) -> BrowserInputEvent { BrowserInputEvent { @@ -268,7 +294,24 @@ mod tests { BrowserInputEvent { surface_id: surface, surface: SurfaceHandle::RemoteBrowserUnsupported, - kind: BrowserInputKind::Resize { cols, rows: 24, reassert: false }, + kind: BrowserInputKind::Resize { cols, rows: 24, reassert: false, _claim: None }, + } + } + + fn resize_event_with_probe( + surface: SurfaceId, + cols: u16, + dropped: Arc, + ) -> BrowserInputEvent { + BrowserInputEvent { + surface_id: surface, + surface: SurfaceHandle::RemoteBrowserUnsupported, + kind: BrowserInputKind::Resize { + cols, + rows: 24, + reassert: false, + _claim: Some(Box::new(DropProbe(dropped))), + }, } } @@ -369,6 +412,47 @@ mod tests { assert!(latest_resizes.lock().unwrap().is_empty()); } + #[test] + fn resize_claim_lives_through_queue_fallback_replacement_and_disconnect() { + let (tx, rx) = sync_channel(1); + let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let dispatcher = BrowserInputDispatcher { + tx, + order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), + latest_resizes: latest_resizes.clone(), + }; + let accepted = Arc::new(AtomicBool::new(false)); + dispatcher.enqueue(resize_event_with_probe(1, 80, accepted.clone())); + assert!(!accepted.load(Ordering::Acquire)); + drop(rx.recv().unwrap()); + assert!(accepted.load(Ordering::Acquire)); + + dispatcher.enqueue(click_event(1)); + let replaced = Arc::new(AtomicBool::new(false)); + let retained = Arc::new(AtomicBool::new(false)); + dispatcher.enqueue(resize_event_with_probe(1, 100, replaced.clone())); + dispatcher.enqueue(resize_event_with_probe(1, 120, retained.clone())); + assert!(replaced.load(Ordering::Acquire)); + assert!(!retained.load(Ordering::Acquire)); + latest_resizes.lock().unwrap().clear(); + assert!(retained.load(Ordering::Acquire)); + + drop(rx); + let disconnected = Arc::new(AtomicBool::new(false)); + dispatcher.enqueue(resize_event_with_probe(1, 140, disconnected.clone())); + assert!(disconnected.load(Ordering::Acquire)); + } + + #[test] + fn resize_claim_drops_after_dispatch_failure() { + let dropped = Arc::new(AtomicBool::new(false)); + let event = resize_event_with_probe(1, 80, dropped.clone()); + assert!(dispatch(&event).is_err()); + assert!(!dropped.load(Ordering::Acquire)); + drop(event); + assert!(dropped.load(Ordering::Acquire)); + } + #[test] fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { let mut batch = vec![sequenced(0, click_event(1))]; diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index ab28886388f1..1f9a0f351815 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -538,18 +538,21 @@ fn worker(queue: Arc, on_failure: Arc { - let _ = surface.resize(desired.0, desired.1); + let _ = surface.try_resize(desired.0, desired.1)?; Ok(()) } SurfaceHandle::Remote(surface, session) => { @@ -629,7 +629,9 @@ impl SurfaceHandle { } Ok(()) } - SurfaceHandle::RemoteBrowserUnsupported => Ok(()), + SurfaceHandle::RemoteBrowserUnsupported => { + anyhow::bail!("browser surface is unavailable") + } } } @@ -651,7 +653,7 @@ impl SurfaceHandle { let desired = (cols.max(1), rows.max(1)); match self { SurfaceHandle::Local(surface) => { - let _ = surface.resize(desired.0, desired.1); + let _ = surface.try_resize(desired.0, desired.1)?; Ok(()) } SurfaceHandle::Remote(surface, session) => { @@ -666,7 +668,9 @@ impl SurfaceHandle { surface.set_asserted_size(desired); Ok(()) } - SurfaceHandle::RemoteBrowserUnsupported => Ok(()), + SurfaceHandle::RemoteBrowserUnsupported => { + anyhow::bail!("browser surface is unavailable") + } } } From a03490f2d16e2dd6f2cfe13a46c8434fd0b55093 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:04:22 -0700 Subject: [PATCH 038/103] Fix browser resize failure handling --- cmux-tui/bindings/conformance/fixtures.json | 8 +- cmux-tui/crates/cmux-tui-core/src/browser.rs | 97 ++++++++++++++-- cmux-tui/crates/cmux-tui-core/src/mux.rs | 2 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 17 +-- cmux-tui/crates/cmux-tui-core/tests/pty.rs | 10 +- cmux-tui/crates/cmux-tui/src/app.rs | 51 +++++++-- cmux-tui/crates/cmux-tui/src/browser_input.rs | 108 ++++++++++++++++-- cmux-tui/crates/cmux-tui/src/session/mod.rs | 6 +- 8 files changed, 254 insertions(+), 45 deletions(-) diff --git a/cmux-tui/bindings/conformance/fixtures.json b/cmux-tui/bindings/conformance/fixtures.json index d171b71a07d2..2f4cf8427e45 100644 --- a/cmux-tui/bindings/conformance/fixtures.json +++ b/cmux-tui/bindings/conformance/fixtures.json @@ -210,7 +210,7 @@ }, { "type": "command", - "request": { "id": 3, "cmd": "send", "surface": "$surface0", "text": "printf '\\033]2;conformance title\\007'; sleep 1\r" }, + "request": { "id": 3, "cmd": "send", "surface": "$surface0", "text": "printf '\\033]2;conformance title\\007'; sleep 30\r" }, "expect": { "id": 3, "ok": true }, "match": "partial" }, @@ -220,6 +220,12 @@ "expect": [ { "event": "title-changed", "surface": "$surface0", "title": "conformance title" } ] + }, + { + "type": "command", + "request": { "id": 4, "cmd": "send", "surface": "$surface0", "text": "\u0003" }, + "expect": { "id": 4, "ok": true }, + "match": "partial" } ] }, diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 7de5433873dc..45d053a05aed 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -648,14 +648,21 @@ impl BrowserSurface { let cell = *self.cell_pixels.lock().unwrap(); let pixel_w = cols as u32 * cell.0.max(1) as u32; let pixel_h = rows as u32 * cell.1.max(1) as u32; - let (unchanged, capture_w, capture_h, capture_scale, capture_pixels) = { + let (unchanged, capture_w, capture_h, capture_scale, capture_pixels, frame_generation) = { let state = self.state.lock().unwrap(); let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); let unchanged = state.size == (cols, rows) && state.pane_pixels == (pixel_w, pixel_h) && state.capture_pixels == capture_pixels; - (unchanged, capture_pixels.0, capture_pixels.1, capture_scale, capture_pixels) + ( + unchanged, + capture_pixels.0, + capture_pixels.1, + capture_scale, + capture_pixels, + state.next_frame_seq, + ) }; if unchanged { return Ok(()); @@ -666,16 +673,31 @@ impl BrowserSurface { session.runtime.client.start_screencast(&session.session_id, capture_w, capture_h)?; } let mut state = self.state.lock().unwrap(); - state.size = (cols, rows); - state.pane_pixels = (pixel_w, pixel_h); - state.capture_pixels = capture_pixels; - state.capture_scale = capture_scale; - state.live_since = Some(Instant::now()); - state.last_frame_at = None; - state.stall_nudged = false; + commit_resize_state( + &mut state, + (cols, rows), + (pixel_w, pixel_h), + capture_pixels, + capture_scale, + frame_generation, + Instant::now(), + ); Ok(()) } + pub(crate) fn resize_needed(&self, cols: u16, rows: u16) -> bool { + let (cols, rows) = (cols.max(1), rows.max(1)); + let cell = *self.cell_pixels.lock().unwrap(); + let pixel_w = cols as u32 * cell.0.max(1) as u32; + let pixel_h = rows as u32 * cell.1.max(1) as u32; + let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); + let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); + let state = self.state.lock().unwrap(); + state.size != (cols, rows) + || state.pane_pixels != (pixel_w, pixel_h) + || state.capture_pixels != capture_pixels + } + pub fn attach_frames(&self) -> (BrowserAttachState, BrowserFrameStream) { let (tx, rx) = std::sync::mpsc::sync_channel(1); let slot = Arc::new(Mutex::new(BrowserAttachUpdate::default())); @@ -967,6 +989,26 @@ impl BrowserSurface { } } +fn commit_resize_state( + state: &mut BrowserState, + size: (u16, u16), + pane_pixels: (u32, u32), + capture_pixels: (u32, u32), + capture_scale: f64, + frame_generation: u64, + now: Instant, +) { + state.size = size; + state.pane_pixels = pane_pixels; + state.capture_pixels = capture_pixels; + state.capture_scale = capture_scale; + state.live_since = Some(now); + if state.next_frame_seq == frame_generation { + state.last_frame_at = None; + state.stall_nudged = false; + } +} + fn browser_attach_state_locked( state: &BrowserState, now: Instant, @@ -1134,7 +1176,7 @@ fn percent_encode_query(input: &str) -> String { mod tests { use super::{ BrowserCaptureOptions, BrowserFrame, BrowserSource, BrowserStatus, capture_scale_for, - new_surface, normalize_url, runtime_endpoint, scaled_pixels, + commit_resize_state, new_surface, normalize_url, runtime_endpoint, scaled_pixels, }; use crate::SurfaceOptions; use std::io::{Read, Write}; @@ -1395,6 +1437,41 @@ mod tests { assert!(!super::frames_stalled_locked(&state, Instant::now(), false)); } + #[test] + fn cell_pixel_mismatch_requires_browser_resize() { + let opts = SurfaceOptions::default(); + let surface = new_surface(1, "https://example.test".into(), (10, 5), (8, 16), &opts); + let browser = surface.as_browser().expect("browser surface"); + assert!(!browser.resize_needed(10, 5)); + + *browser.cell_pixels.lock().unwrap() = (9, 16); + assert!(browser.resize_needed(10, 5)); + } + + #[test] + fn resize_commit_preserves_frame_delivered_during_cdp_round_trip() { + let opts = SurfaceOptions::default(); + let surface = new_surface(1, "https://example.test".into(), (10, 5), (8, 16), &opts); + let browser = surface.as_browser().expect("browser surface"); + let frame_generation = browser.state.lock().unwrap().next_frame_seq; + browser.store_frame(test_frame(1)); + let delivered_at = browser.state.lock().unwrap().last_frame_at; + + let mut state = browser.state.lock().unwrap(); + commit_resize_state( + &mut state, + (11, 5), + (88, 80), + (88, 80), + 1.0, + frame_generation, + Instant::now(), + ); + + assert_eq!(state.last_frame_at, delivered_at); + assert!(!state.stall_nudged); + } + #[test] fn attach_frames_are_latest_wins_and_close_detaches() { let opts = SurfaceOptions::default(); diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index f86ac203d068..479e21286753 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -710,7 +710,7 @@ impl Mux { let Some(surface) = self.surface(id) else { anyhow::bail!("unknown surface {id}"); }; - if !surface.resize(cols, rows) { + if !surface.resize(cols, rows)? { return Ok(false); } let (cols, rows) = surface.size(); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index a7af6ee4adfe..9e33740c5516 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -512,14 +512,7 @@ impl Surface { /// Resize this surface. PTYs receive cell dimensions; browsers also /// use the last configured cell pixel size for CDP device metrics. /// Returns whether the final clamped size actually changed. - pub fn resize(&self, cols: u16, rows: u16) -> bool { - self.try_resize(cols, rows).unwrap_or_else(|error| { - eprintln!("cmux-tui: surface resize failed for surface {}: {error}", self.id); - false - }) - } - - pub fn try_resize(&self, cols: u16, rows: u16) -> anyhow::Result { + pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { match self { Surface::Pty(pty) => Ok(pty.resize(cols, rows)), Surface::Browser(browser) => { @@ -530,6 +523,14 @@ impl Surface { } } + pub fn resize_needed(&self, cols: u16, rows: u16) -> bool { + let desired = (cols.max(1), rows.max(1)); + match self { + Surface::Pty(pty) => *pty.size.lock().unwrap() != desired, + Surface::Browser(browser) => browser.resize_needed(desired.0, desired.1), + } + } + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { if let Some(browser) = self.as_browser() { browser.set_cell_pixel_size(width_px, height_px); diff --git a/cmux-tui/crates/cmux-tui-core/tests/pty.rs b/cmux-tui/crates/cmux-tui-core/tests/pty.rs index 96f23b139297..36d2d5e1d5a2 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/pty.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/pty.rs @@ -86,14 +86,14 @@ fn surface_resize_reports_whether_the_size_changed() { let mux = Mux::new(unique_session("test-resize-bool"), shell_opts("sleep 30")); let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); - assert!(!surface.resize(80, 24)); + assert!(!surface.resize(80, 24).unwrap()); assert_eq!(surface.size(), (80, 24)); - assert!(surface.resize(100, 40)); + assert!(surface.resize(100, 40).unwrap()); assert_eq!(surface.size(), (100, 40)); - assert!(!surface.resize(100, 40)); - assert!(surface.resize(0, 0)); + assert!(!surface.resize(100, 40).unwrap()); + assert!(surface.resize(0, 0).unwrap()); assert_eq!(surface.size(), (1, 1)); - assert!(!surface.resize(0, 0)); + assert!(!surface.resize(0, 0).unwrap()); mux.close_surface(surface.id); } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index a2ab5b81364e..702888b290ea 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -34,7 +34,9 @@ use ghostty_vt::{ use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::CrosstermBackend; -use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; +use crate::browser_input::{ + BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind, BrowserResizeFailure, +}; use crate::config::{Action, ChromeTheme, Config, ScrollbarPosition}; use crate::keys; use crate::pty_input::{ @@ -57,6 +59,7 @@ pub enum AppEvent { Mux(MuxEvent), MuxTitlesReady, Input(Event), + BrowserResizeFailed(BrowserResizeFailure), PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), RemoteTreeUpdated { refresh_sequence: u64, result: Result }, @@ -389,6 +392,7 @@ impl OrderedSession { let spawn = std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { let result = session.refresh_tree(); + drop(claim); match result { Ok(tree) => { pending.settle(SessionMutationOutcome::IdentityRefreshSucceeded { @@ -404,7 +408,6 @@ impl OrderedSession { }); } } - drop(claim); }); if let Err(error) = spawn { self.remote_refresh_queued.store(false, Ordering::Release); @@ -436,8 +439,8 @@ impl OrderedSession { let spawn = std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { let result = session.refresh_tree().map_err(|error| error.to_string()); - let _ = events.send(AppEvent::RemoteTreeUpdated { refresh_sequence, result }); drop(claim); + let _ = events.send(AppEvent::RemoteTreeUpdated { refresh_sequence, result }); }); if let Err(error) = spawn { self.remote_refresh_queued.store(false, Ordering::Release); @@ -1364,7 +1367,10 @@ pub fn run( let encoder = KeyEncoder::new()?; let mouse_encoder = MouseEncoder::new()?; let (tx, rx) = channel::(); - let browser_input = BrowserInputDispatcher::spawn()?; + let browser_failure_tx = tx.clone(); + let browser_input = BrowserInputDispatcher::spawn(move |failure| { + let _ = browser_failure_tx.send(AppEvent::BrowserResizeFailed(failure)); + })?; let failure_tx = tx.clone(); let pty_input = PtyInputDispatcher::spawn(move |failure| { let _ = failure_tx.send(AppEvent::PtyOperationFailed(failure)); @@ -1837,6 +1843,7 @@ impl App { return; } self.cell_pixels = next; + self.browser_input.clear_resize_failures(); self.session.set_cell_pixel_size(next.0, next.1); } } @@ -1925,6 +1932,12 @@ impl App { continue; } if let Some(surface) = self.session.surface(tab.surface) { + let desired = (content.width, content.height); + if surface.kind() == SurfaceKind::Browser + && self.browser_input.resize_failed(tab.surface, desired) + { + continue; + } let needs = surface.resize_needed(content.width, content.height, false); if let SurfaceResizeDecision::NeedsQueue(claim) = self.session.surface_resize_decision( @@ -2080,6 +2093,7 @@ impl App { if self.last_browser_hover.is_some_and(|(surface, _, _)| surface == id) { self.last_browser_hover = None; } + self.browser_input.forget_surface(id); Ok(RenderAction::Draw) } AppEvent::Mux(MuxEvent::Status(message)) => { @@ -2105,6 +2119,13 @@ impl App { } } AppEvent::Mux(_) => Ok(RenderAction::Draw), + AppEvent::BrowserResizeFailed(failure) => { + self.status_message = Some(format!( + "browser surface {} resize to {}x{} failed: {}", + failure.surface_id, failure.cols, failure.rows, failure.error + )); + Ok(RenderAction::Draw) + } AppEvent::PtyOperationFailed(failure) => { if failure.label == "relaunch sidebar plugin" { self.sidebar_focus_pending = false; @@ -2401,15 +2422,21 @@ impl App { { let rect = self.sidebar_plugin_rect(); if rect.width > 0 && rect.height > 0 { + let desired = (rect.width, rect.height); let needs_barrier = surface.resize_needed(rect.width, rect.height, true); - if let Some(surface_id) = self.sidebar_plugin_surface { + if let Some(surface_id) = self.sidebar_plugin_surface + && !(surface.kind() == SurfaceKind::Browser + && self.browser_input.resize_failed(surface_id, desired)) + { match self.session.surface_resize_decision( surface_id, (rect.width, rect.height), needs_barrier, ) { SurfaceResizeDecision::Noop => { - let _ = surface.reassert_size(rect.width, rect.height); + if surface.kind() != SurfaceKind::Browser { + let _ = surface.reassert_size(rect.width, rect.height); + } } SurfaceResizeDecision::AlreadyClaimed => {} SurfaceResizeDecision::NeedsQueue(claim) @@ -2439,6 +2466,12 @@ impl App { return; } if let Some(surface) = self.session.surface(area.surface) { + let desired = (area.content.width, area.content.height); + if surface.kind() == SurfaceKind::Browser + && self.browser_input.resize_failed(area.surface, desired) + { + continue; + } let needs_barrier = surface.resize_needed(area.content.width, area.content.height, true); match self.session.surface_resize_decision( @@ -2447,7 +2480,9 @@ impl App { needs_barrier, ) { SurfaceResizeDecision::Noop => { - let _ = surface.reassert_size(area.content.width, area.content.height); + if surface.kind() != SurfaceKind::Browser { + let _ = surface.reassert_size(area.content.width, area.content.height); + } } SurfaceResizeDecision::AlreadyClaimed => {} SurfaceResizeDecision::NeedsQueue(claim) @@ -6518,7 +6553,7 @@ mod tests { cell_pixels: (8, 16), pointer_shape: false, last_browser_hover: None, - browser_input: BrowserInputDispatcher::spawn().unwrap(), + browser_input: BrowserInputDispatcher::spawn(|_| {}).unwrap(), pty_input, deferred_input: VecDeque::new(), routing_refresh_pending: false, diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 7e26927b25eb..d3eab59e74e6 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -17,9 +17,10 @@ //! UI. The latest rejected resize per surface and uninterrupted resize //! run is retained so geometry catches up without crossing later input. //! -//! Results are intentionally discarded: browser input has no caller -//! that can act on a per-event error, and the surface's own status -//! (`BrowserStatus`) is what the UI reports. +//! Ordinary input errors are reported by the surface's own status. Resize +//! failures are retained per surface and reported to the app because retrying a +//! persistently failing CDP geometry update ahead of every input would stall the +//! browser lane. use std::collections::HashMap; use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; @@ -40,6 +41,14 @@ pub struct BrowserInputEvent { pub kind: BrowserInputKind, } +#[derive(Debug, Clone)] +pub struct BrowserResizeFailure { + pub surface_id: SurfaceId, + pub cols: u16, + pub rows: u16, + pub error: String, +} + pub enum BrowserInputKind { Mouse { event_type: &'static str, @@ -92,12 +101,20 @@ impl BrowserInputKind { fn is_resize(&self) -> bool { matches!(self, BrowserInputKind::Resize { .. }) } + + fn resize_dimensions(&self) -> Option<(u16, u16)> { + match self { + BrowserInputKind::Resize { cols, rows, .. } => Some((*cols, *rows)), + _ => None, + } + } } pub struct BrowserInputDispatcher { tx: SyncSender, order: Arc>, latest_resizes: Arc>>, + failed_resizes: Arc>>, } #[cfg(test)] @@ -106,16 +123,21 @@ pub(crate) struct BlockedBrowserInput { } impl BrowserInputDispatcher { - pub fn spawn() -> anyhow::Result { + pub fn spawn( + on_resize_failure: impl Fn(BrowserResizeFailure) + Send + Sync + 'static, + ) -> anyhow::Result { let (tx, rx) = sync_channel(QUEUE_CAPACITY); let order = Arc::new(Mutex::new(BrowserEnqueueOrder::default())); let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let failed_resizes = Arc::new(Mutex::new(HashMap::new())); let worker_order = order.clone(); let worker_resizes = latest_resizes.clone(); - std::thread::Builder::new() - .name("mux-browser-input".into()) - .spawn(move || worker(rx, worker_order, worker_resizes))?; - Ok(BrowserInputDispatcher { tx, order, latest_resizes }) + let worker_failures = failed_resizes.clone(); + let on_resize_failure = Arc::new(on_resize_failure); + std::thread::Builder::new().name("mux-browser-input".into()).spawn(move || { + worker(rx, worker_order, worker_resizes, worker_failures, on_resize_failure); + })?; + Ok(BrowserInputDispatcher { tx, order, latest_resizes, failed_resizes }) } #[cfg(test)] @@ -126,6 +148,7 @@ impl BrowserInputDispatcher { tx, order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: Arc::new(Mutex::new(HashMap::new())), + failed_resizes: Arc::new(Mutex::new(HashMap::new())), }, BlockedBrowserInput { _rx: rx }, ) @@ -135,6 +158,11 @@ impl BrowserInputDispatcher { /// resize per surface and input-delimited run, and drops other input. pub fn enqueue(&self, event: BrowserInputEvent) { let is_resize = event.kind.is_resize(); + if let Some(desired) = event.kind.resize_dimensions() + && self.resize_failed(event.surface_id, desired) + { + return; + } let mut order = self.order.lock().unwrap(); let sequence = order.next_sequence; order.next_sequence = order.next_sequence.saturating_add(1); @@ -152,12 +180,26 @@ impl BrowserInputDispatcher { Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {} } } + + pub fn resize_failed(&self, surface_id: SurfaceId, desired: (u16, u16)) -> bool { + self.failed_resizes.lock().unwrap().get(&surface_id) == Some(&desired) + } + + pub fn forget_surface(&self, surface_id: SurfaceId) { + self.failed_resizes.lock().unwrap().remove(&surface_id); + } + + pub fn clear_resize_failures(&self) { + self.failed_resizes.lock().unwrap().clear(); + } } fn worker( rx: Receiver, order: Arc>, latest_resizes: Arc>>, + failed_resizes: Arc>>, + on_resize_failure: Arc, ) { while let Ok(event) = rx.recv() { let mut batch = vec![event]; @@ -165,7 +207,30 @@ fn worker( let mut batch = batch.into_iter().map(|event| event.event).collect::>(); coalesce_browser_events(&mut batch); for event in batch { - let _ = dispatch(&event); + let desired = event.kind.resize_dimensions(); + if desired.is_some_and(|desired| { + failed_resizes.lock().unwrap().get(&event.surface_id) == Some(&desired) + }) { + continue; + } + let result = dispatch(&event); + let Some((cols, rows)) = desired else { continue }; + match result { + Ok(()) => { + failed_resizes.lock().unwrap().remove(&event.surface_id); + } + Err(error) => { + failed_resizes.lock().unwrap().insert(event.surface_id, (cols, rows)); + let failure = BrowserResizeFailure { + surface_id: event.surface_id, + cols, + rows, + error: error.to_string(), + }; + drop(event); + on_resize_failure(failure); + } + } } } } @@ -389,6 +454,7 @@ mod tests { tx, order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: latest_resizes.clone(), + failed_resizes: Arc::new(Mutex::new(HashMap::new())), }; dispatcher.enqueue(click_event(1)); @@ -420,6 +486,7 @@ mod tests { tx, order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: latest_resizes.clone(), + failed_resizes: Arc::new(Mutex::new(HashMap::new())), }; let accepted = Arc::new(AtomicBool::new(false)); dispatcher.enqueue(resize_event_with_probe(1, 80, accepted.clone())); @@ -453,6 +520,28 @@ mod tests { assert!(dropped.load(Ordering::Acquire)); } + #[test] + fn failed_resize_is_reported_and_same_geometry_is_suppressed() { + let (failure_tx, failure_rx) = std::sync::mpsc::channel(); + let dispatcher = BrowserInputDispatcher::spawn(move |failure| { + failure_tx.send(failure).unwrap(); + }) + .unwrap(); + + dispatcher.enqueue(resize_event(7, 100)); + let failure = failure_rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap(); + assert_eq!((failure.surface_id, failure.cols, failure.rows), (7, 100, 24)); + assert!(dispatcher.resize_failed(7, (100, 24))); + + let dropped = Arc::new(AtomicBool::new(false)); + dispatcher.enqueue(resize_event_with_probe(7, 100, dropped.clone())); + assert!(dropped.load(Ordering::Acquire)); + assert!(failure_rx.try_recv().is_err()); + + dispatcher.forget_surface(7); + assert!(!dispatcher.resize_failed(7, (100, 24))); + } + #[test] fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { let mut batch = vec![sequenced(0, click_event(1))]; @@ -473,6 +562,7 @@ mod tests { tx, order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: latest_resizes.clone(), + failed_resizes: Arc::new(Mutex::new(HashMap::new())), }; dispatcher.enqueue(click_event(1)); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 45e3587e89c8..edb6c54022b1 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -614,7 +614,7 @@ impl SurfaceHandle { let desired = (cols.max(1), rows.max(1)); match self { SurfaceHandle::Local(surface) => { - let _ = surface.try_resize(desired.0, desired.1)?; + let _ = surface.resize(desired.0, desired.1)?; Ok(()) } SurfaceHandle::Remote(surface, session) => { @@ -638,7 +638,7 @@ impl SurfaceHandle { pub fn resize_needed(&self, cols: u16, rows: u16, user_interaction: bool) -> bool { let desired = (cols.max(1), rows.max(1)); match self { - SurfaceHandle::Local(surface) => surface.size() != desired, + SurfaceHandle::Local(surface) => surface.resize_needed(desired.0, desired.1), SurfaceHandle::Remote(surface, _) => resize_action( desired, surface.asserted_size(), @@ -653,7 +653,7 @@ impl SurfaceHandle { let desired = (cols.max(1), rows.max(1)); match self { SurfaceHandle::Local(surface) => { - let _ = surface.try_resize(desired.0, desired.1)?; + let _ = surface.resize(desired.0, desired.1)?; Ok(()) } SurfaceHandle::Remote(surface, session) => { From 28310ad284791b1dd26e88674728049280d02ccd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:56:21 -0700 Subject: [PATCH 039/103] Bound remote surface synchronization --- cmux-tui/bindings/conformance/fixtures.json | 38 ++++ cmux-tui/bindings/conformance/runner.py | 15 +- cmux-tui/crates/cmux-tui-core/src/server.rs | 2 +- cmux-tui/crates/cmux-tui/src/app.rs | 214 ++++++++++++++++-- cmux-tui/crates/cmux-tui/src/pty_input.rs | 9 +- cmux-tui/crates/cmux-tui/src/session/mod.rs | 22 +- .../crates/cmux-tui/src/session/remote.rs | 27 ++- 7 files changed, 287 insertions(+), 40 deletions(-) diff --git a/cmux-tui/bindings/conformance/fixtures.json b/cmux-tui/bindings/conformance/fixtures.json index 2f4cf8427e45..25f894201acf 100644 --- a/cmux-tui/bindings/conformance/fixtures.json +++ b/cmux-tui/bindings/conformance/fixtures.json @@ -229,6 +229,44 @@ } ] }, + { + "name": "attach-resize-carries-replay", + "steps": [ + { + "type": "command", + "request": { "id": 1, "cmd": "new-workspace", "name": "conf-attach-resize", "cols": 80, "rows": 24 }, + "expect": { "id": 1, "ok": true }, + "match": "partial", + "bind": { "surface0": "data.surface" } + }, + { + "type": "stream", + "name": "attach", + "request": { "cmd": "attach-surface", "surface": "$surface0" }, + "expect": { "ok": true }, + "match": "partial" + }, + { + "type": "command", + "request": { "id": 2, "cmd": "resize-surface", "surface": "$surface0", "cols": 100, "rows": 31 }, + "expect": { "id": 2, "ok": true }, + "match": "partial" + }, + { + "type": "expect_events", + "stream": "attach", + "expect": [ + { + "event": "resized", + "surface": "$surface0", + "cols": 100, + "rows": 31, + "replay": { "__match__": "nonempty-string" } + } + ] + } + ] + }, { "name": "send-read-screen-round-trip", "steps": [ diff --git a/cmux-tui/bindings/conformance/runner.py b/cmux-tui/bindings/conformance/runner.py index 25dc3a0dda7b..59972817e0b8 100755 --- a/cmux-tui/bindings/conformance/runner.py +++ b/cmux-tui/bindings/conformance/runner.py @@ -118,7 +118,11 @@ def run_fixture(fixture: Dict[str, Any], defaults: Dict[str, Any], socket_path: variables: Dict[str, Any] = {} streams: Dict[str, Any] = {} timeout_ms = int(fixture.get("timeout_ms", defaults.get("timeout_ms", 5000))) - with CmuxClient(socket_path=socket_path, timeout=max(timeout_ms / 1000.0, 1.0)) as client: + with CmuxClient( + socket_path=socket_path, + timeout=max(timeout_ms / 1000.0, 1.0), + allow_protocol_v6_attach=True, + ) as client: try: for step in fixture.get("steps", []): step_timeout = int(step.get("timeout_ms", timeout_ms)) @@ -143,9 +147,12 @@ def run_step( bind_variables(response, step.get("bind", {}), variables) elif step_type == "stream": request = substitute(step["request"], variables) - if request.get("cmd") != "subscribe": + if request.get("cmd") == "subscribe": + stream = client.subscribe_with_request(request) + elif request.get("cmd") == "attach-surface": + stream = client.attach_surface(int(request["surface"])) + else: raise FixtureFailure(f"unsupported stream command {request.get('cmd')}") - stream = client.subscribe_with_request(request) streams[step["name"]] = stream response = stream.response assert_match(response, substitute(step.get("expect", {}), variables), step.get("match", "partial")) @@ -278,6 +285,8 @@ def assert_match(actual: Any, expected: Any, mode: str) -> None: def partial_match(actual: Any, expected: Any) -> bool: + if expected == {"__match__": "nonempty-string"}: + return isinstance(actual, str) and bool(actual) if isinstance(expected, dict): if not isinstance(actual, dict): return False diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 09726fb1be8e..159ea174012c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -1431,7 +1431,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: "surface": surface_id, "cols": cols, "rows": rows, - "data": base64::engine::general_purpose::STANDARD.encode(replay), + "replay": base64::engine::general_purpose::STANDARD.encode(replay), }), }; if writer.send(&value).is_err() { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 702888b290ea..afe09f251966 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -123,6 +123,11 @@ pub enum SessionMutationOutcome { error: String, refresh_sequence: u64, }, + SurfaceSyncFailed { + surface: SurfaceId, + operation: &'static str, + error: String, + }, MutationTimedOut(String), Failed(String), Canceled, @@ -182,6 +187,7 @@ struct SurfaceResizeClaimState { enum SurfaceResizeDecision { Noop, AlreadyClaimed, + Failed, NeedsQueue(SurfaceResizeClaim), } @@ -250,6 +256,8 @@ pub struct OrderedSession { remote_refresh_sequence: Arc, surface_resize_claims: Arc>>, surface_resize_claim_sequence: Arc, + surface_attach_failures: Arc>>, + surface_resize_failures: Arc>>, config_generation: Arc, sidebar_plugin_sync: Arc>, exited_surfaces: Arc>>, @@ -270,6 +278,8 @@ impl OrderedSession { remote_refresh_sequence: Arc::new(AtomicU64::new(0)), surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), surface_resize_claim_sequence: Arc::new(AtomicU64::new(0)), + surface_attach_failures: Arc::new(Mutex::new(HashSet::new())), + surface_resize_failures: Arc::new(Mutex::new(HashMap::new())), config_generation: Arc::new(AtomicU64::new(0)), sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), exited_surfaces: Arc::new(Mutex::new(HashSet::new())), @@ -301,6 +311,8 @@ impl OrderedSession { if self.remote { self.exited_surfaces.lock().unwrap().insert(id); } + self.surface_attach_failures.lock().unwrap().remove(&id); + self.surface_resize_failures.lock().unwrap().remove(&id); self.inner.forget_surface(id); } @@ -308,6 +320,11 @@ impl OrderedSession { self.inner.invalidate_remote_tree(); } + fn clear_surface_sync_failures(&self) { + self.surface_attach_failures.lock().unwrap().clear(); + self.surface_resize_failures.lock().unwrap().clear(); + } + fn begin_shutdown(&self) { self.inner.begin_shutdown(); } @@ -318,9 +335,11 @@ impl OrderedSession { } let session = self.inner.clone(); let exited_surfaces = self.exited_surfaces.clone(); + let attach_failures = self.surface_attach_failures.clone(); + let enqueue_failures = attach_failures.clone(); let remote = self.remote; let pending = self.pending_mutation(); - self.operations.enqueue_coalescing_mutation( + let enqueue_result = self.operations.enqueue_coalescing_mutation( "attach surface", ("attach surface", id), self.remote, @@ -331,21 +350,40 @@ impl OrderedSession { pending.settle(SessionMutationOutcome::Success { tree: None }); return Ok(()); } - if session.surface_sized(id, size).is_none() { - pending.settle(SessionMutationOutcome::Failed(format!( - "surface {id} is unavailable" - ))); - anyhow::bail!("surface {id} is unavailable"); + match session.try_surface_sized(id, size) { + Ok(Some(_)) => { + attach_failures.lock().unwrap().remove(&id); + pending.settle(SessionMutationOutcome::Success { tree: None }); + } + Ok(None) => { + attach_failures.lock().unwrap().insert(id); + pending.settle(SessionMutationOutcome::SurfaceSyncFailed { + surface: id, + operation: "attach", + error: format!("surface {id} is unavailable"), + }); + } + Err(error) => { + attach_failures.lock().unwrap().insert(id); + pending.settle(SessionMutationOutcome::SurfaceSyncFailed { + surface: id, + operation: "attach", + error: error.to_string(), + }); + } } - pending.settle(SessionMutationOutcome::Success { tree: None }); Ok(()) }, ); + if enqueue_result != PtyInputEnqueueResult::Accepted { + enqueue_failures.lock().unwrap().insert(id); + } } fn can_attach_surface(&self, id: SurfaceId) -> bool { self.inner.cached_surface(id).is_none() && !self.exited_surfaces.lock().unwrap().contains(&id) + && !self.surface_attach_failures.lock().unwrap().contains(&id) && (!self.remote || !self.inner.remote_tree_is_stale()) } @@ -556,19 +594,42 @@ impl OrderedSession { reassert: bool, claim: SurfaceResizeClaim, ) { - self.enqueue_coalescing_session_mutation( + let pending = self.pending_mutation(); + let failures = self.surface_resize_failures.clone(); + let enqueue_failures = failures.clone(); + let committed_mutation_generation = self.committed_mutation_generation.clone(); + let enqueue_result = self.operations.enqueue_coalescing_mutation( "resize PTY surface", ("surface resize", surface_id), - move |_| { + self.remote, + move || { let _claim = claim; - if reassert { - surface.reassert_size(cols, rows)?; + let result = if reassert { + surface.reassert_size(cols, rows) } else { - surface.resize(cols, rows)?; + surface.resize(cols, rows) + }; + match result { + Ok(()) => { + failures.lock().unwrap().remove(&surface_id); + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + pending.settle(SessionMutationOutcome::Success { tree: None }); + } + Err(error) => { + failures.lock().unwrap().insert(surface_id, (cols, rows)); + pending.settle(SessionMutationOutcome::SurfaceSyncFailed { + surface: surface_id, + operation: "resize", + error: error.to_string(), + }); + } } Ok(()) }, ); + if enqueue_result != PtyInputEnqueueResult::Accepted { + enqueue_failures.lock().unwrap().insert(surface_id, (cols, rows)); + } } fn surface_resize_decision( @@ -577,6 +638,12 @@ impl OrderedSession { desired: (u16, u16), surface_needs_resize: bool, ) -> SurfaceResizeDecision { + let mut failures = self.surface_resize_failures.lock().unwrap(); + if failures.get(&surface_id) == Some(&desired) { + return SurfaceResizeDecision::Failed; + } + failures.remove(&surface_id); + drop(failures); let mut claims = self.surface_resize_claims.lock().unwrap(); if claims.get(&surface_id).is_some_and(|claim| claim.desired == desired) { return SurfaceResizeDecision::AlreadyClaimed; @@ -2047,6 +2114,14 @@ impl App { } _ => {} } + if matches!( + &event, + AppEvent::Mux( + MuxEvent::TreeChanged | MuxEvent::LayoutChanged(_) | MuxEvent::SurfaceExited(_) + ) + ) { + self.session.clear_surface_sync_failures(); + } let event = match event { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if (self.session.has_pending_mutations() @@ -2162,6 +2237,7 @@ impl App { authoritative_generation, completion, } => { + self.session.clear_surface_sync_failures(); self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; if let Some(completion) = completion { @@ -2175,9 +2251,11 @@ impl App { refresh_sequence, } => { if !self.accept_refresh_sequence(refresh_sequence) { + self.apply_session_completions_through(authoritative_generation); self.complete_routing_after_stale_identity_result(); return Ok(RenderAction::None); } + self.session.clear_surface_sync_failures(); self.session.reconcile_exited_surfaces(&tree); self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; @@ -2213,6 +2291,11 @@ impl App { self.complete_remote_tree_refresh(refresh_stale); return Ok(RenderAction::Draw); } + SessionMutationOutcome::SurfaceSyncFailed { surface, operation, error } => { + self.status_message = Some(format!( + "surface {surface} {operation} failed; waiting for a lifecycle change: {error}" + )); + } SessionMutationOutcome::MutationTimedOut(error) => { self.status_message = Some(format!( "session operation may have committed; refreshing its layout: {error}" @@ -2255,6 +2338,7 @@ impl App { Ok(tree) => { self.session.reconcile_exited_surfaces(&tree); self.replace_tree(tree); + self.routing_refresh_pending = true; self.routing_refresh_retries_remaining = 0; } Err(error) => { @@ -2438,7 +2522,7 @@ impl App { let _ = surface.reassert_size(rect.width, rect.height); } } - SurfaceResizeDecision::AlreadyClaimed => {} + SurfaceResizeDecision::AlreadyClaimed | SurfaceResizeDecision::Failed => {} SurfaceResizeDecision::NeedsQueue(claim) if self.prepare_pty_input_before_mutation() => { @@ -2484,7 +2568,7 @@ impl App { let _ = surface.reassert_size(area.content.width, area.content.height); } } - SurfaceResizeDecision::AlreadyClaimed => {} + SurfaceResizeDecision::AlreadyClaimed | SurfaceResizeDecision::Failed => {} SurfaceResizeDecision::NeedsQueue(claim) if self.prepare_pty_input_before_mutation() => { @@ -5666,6 +5750,61 @@ mod tests { )); } + #[test] + fn failed_surface_sync_is_bounded_until_lifecycle_recovery() { + let mux = Mux::new("surface-sync-failure-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + + app.session.attach_surface(77, Some((80, 24))); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!( + settled, + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + .. + }) + )); + app.handle(settled).unwrap(); + assert!(!app.session.can_attach_surface(77)); + app.session.attach_surface(77, Some((80, 24))); + assert!(events.try_recv().is_err()); + + let claim = match app.session.surface_resize_decision(88, (100, 30), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("first resize must queue"), + }; + app.session.resize_surface( + 88, + SurfaceHandle::RemoteBrowserUnsupported, + 100, + 30, + false, + claim, + ); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!( + settled, + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 88, + operation: "resize", + .. + }) + )); + app.handle(settled).unwrap(); + assert!(matches!( + app.session.surface_resize_decision(88, (100, 30), true), + SurfaceResizeDecision::Failed + )); + + app.session.clear_surface_sync_failures(); + assert!(app.session.can_attach_surface(77)); + assert!(matches!( + app.session.surface_resize_decision(88, (100, 30), true), + SurfaceResizeDecision::NeedsQueue(_) + )); + } + #[test] fn refresh_sequences_are_monotonic_across_identity_and_background_paths() { let mux = Mux::new("refresh-sequence-test", SurfaceOptions::default()); @@ -5704,6 +5843,53 @@ mod tests { assert_eq!(app.tree.workspaces[0].screens[0].panes[0].tabs[0].surface, 22); } + #[test] + fn stale_identity_refresh_retires_completion_against_newer_tree() { + let mux = Mux::new("stale-identity-completion-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let surface = 41; + let tree = browser_completion_tree(surface, surface); + app.pane_areas.push(browser_completion_area(surface)); + app.pending_session_completions.push_back(SessionCompletion { + mutation_generation: 4, + action: SessionCompletionAction::BrowserTabCreated { surface }, + }); + + app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(tree.clone()) }) + .unwrap(); + app.session.pending_mutations.store(1, Ordering::Release); + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree, + authoritative_generation: 4, + refresh_sequence: 1, + }, + )) + .unwrap(); + + assert!(app.pending_session_completions.is_empty()); + assert_eq!(app.omnibar.as_ref().map(|state| state.surface), Some(surface)); + } + + #[test] + fn background_tree_snapshot_sets_input_routing_barrier() { + let mux = Mux::new("background-routing-barrier-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence: 1, + result: Ok(notify_tree(22, false)), + }) + .unwrap(); + + assert!(app.routing_refresh_pending); + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + assert_eq!(app.deferred_input.len(), 1); + } + #[test] fn exited_surface_tombstones_are_remote_only_and_pruned_authoritatively() { let mux = Mux::new("surface-tombstone-churn-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 1f9a0f351815..43f353e35c96 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -320,7 +320,7 @@ impl PtyInputSender { remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) { - self.enqueue_mutation_with_key(label, None, remote, operation); + let _ = self.enqueue_mutation_with_key(label, None, remote, operation); } pub fn enqueue_coalescing_mutation( @@ -329,8 +329,8 @@ impl PtyInputSender { key: (&'static str, u64), remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, - ) { - self.enqueue_mutation_with_key(label, Some(key), remote, operation); + ) -> PtyInputEnqueueResult { + self.enqueue_mutation_with_key(label, Some(key), remote, operation) } fn enqueue_mutation_with_key( @@ -339,7 +339,7 @@ impl PtyInputSender { key: Option<(&'static str, u64)>, remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, - ) { + ) -> PtyInputEnqueueResult { let result = self.enqueue(PtyInputEvent::mutation(label, key, remote, operation)); if result != PtyInputEnqueueResult::Accepted { (self.on_failure)(PtyOperationFailure { @@ -358,6 +358,7 @@ impl PtyInputSender { delivery: PtyOperationDelivery::KnownNotDelivered, }); } + result } } diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index edb6c54022b1..d76193470a39 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -254,22 +254,26 @@ impl Session { /// Like [`Session::surface`], but passes the render size for remote /// mirrors created on first use (the server surface is resized before /// the attach replay, so the replay arrives at final geometry). - pub fn surface_sized(&self, id: SurfaceId, size: Option<(u16, u16)>) -> Option { + pub fn try_surface_sized( + &self, + id: SurfaceId, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { match self { - Session::Local(mux) => mux.surface(id).map(SurfaceHandle::Local), + Session::Local(mux) => Ok(mux.surface(id).map(SurfaceHandle::Local)), Session::Remote(remote) => { if remote.surface_kind(id) == SurfaceKind::Browser { if remote.supports_browser_attach() { - remote - .ensure_surface(id, size) - .map(|surface| SurfaceHandle::Remote(surface, remote.clone())) + remote.try_ensure_surface(id, size).map(|surface| { + surface.map(|surface| SurfaceHandle::Remote(surface, remote.clone())) + }) } else { - Some(SurfaceHandle::RemoteBrowserUnsupported) + Ok(Some(SurfaceHandle::RemoteBrowserUnsupported)) } } else { - remote - .ensure_surface(id, size) - .map(|surface| SurfaceHandle::Remote(surface, remote.clone())) + remote.try_ensure_surface(id, size).map(|surface| { + surface.map(|surface| SurfaceHandle::Remote(surface, remote.clone())) + }) } } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index cbe743bf79ce..55217671dd3e 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -625,16 +625,16 @@ impl RemoteSession { /// provided, the caller's immediately following `resize` sends the /// server resize after the attach tap is installed, so the resize /// marker and any shell WINCH redraw bytes stay ordered in-stream. - pub fn ensure_surface( + pub fn try_ensure_surface( self: &Arc, id: SurfaceId, size: Option<(u16, u16)>, - ) -> Option> { + ) -> anyhow::Result>> { let kind = { let tree = self.tree.lock().unwrap(); tree.view.surface_kind(id) }; - self.ensure_surface_with_kind(id, kind, size) + self.try_ensure_surface_with_kind(id, kind, size) } pub fn ensure_surface_with_kind( @@ -643,18 +643,27 @@ impl RemoteSession { kind: SurfaceKind, size: Option<(u16, u16)>, ) -> Option> { + self.try_ensure_surface_with_kind(id, kind, size).ok().flatten() + } + + pub fn try_ensure_surface_with_kind( + self: &Arc, + id: SurfaceId, + kind: SurfaceKind, + size: Option<(u16, u16)>, + ) -> anyhow::Result>> { if self.exited_surfaces.lock().unwrap().contains(&id) { - return None; + return Ok(None); } if let Some(surface) = self.surfaces.lock().unwrap().get(&id) { - return Some(surface.clone()); + return Ok(Some(surface.clone())); } let source = { let tree = self.tree.lock().unwrap(); browser_source_from_tree(&tree.view, id) }; let (cols, rows) = size.unwrap_or((80, 24)); - let term = Terminal::new(cols, rows, 10_000, Callbacks::default()).ok()?; + let term = Terminal::new(cols, rows, 10_000, Callbacks::default())?; let surface = Arc::new(RemoteSurface { id, kind, @@ -667,11 +676,11 @@ impl RemoteSession { surface.update_browser_source(source); self.surfaces.lock().unwrap().insert(id, surface.clone()); // The vt-state event that follows fills the mirror. - if self.request(json!({"cmd": "attach-surface", "surface": id})).is_err() { + if let Err(error) = self.request(json!({"cmd": "attach-surface", "surface": id})) { self.surfaces.lock().unwrap().remove(&id); - return None; + return Err(error); } - Some(surface) + Ok(Some(surface)) } pub fn drop_surface(&self, id: SurfaceId) { From b8a4633e96cfb809aae6eced3ffc97c66528fdd4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:29:52 -0700 Subject: [PATCH 040/103] Harden remote recovery and browser teardown --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 3 + cmux-tui/crates/cmux-tui/src/app.rs | 158 ++++++++++++++++-- cmux-tui/crates/cmux-tui/src/browser_input.rs | 94 +++++++++-- cmux-tui/crates/cmux-tui/src/session/mod.rs | 7 + .../crates/cmux-tui/src/session/remote.rs | 80 ++++++++- cmux-tui/docs/protocol.md | 2 +- 6 files changed, 310 insertions(+), 34 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 45d053a05aed..6ea73d95c502 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -133,6 +133,7 @@ struct Routes { pub struct BrowserSurface { pub(crate) meta: SurfaceMeta, session: Mutex>, + resize_transaction: Mutex<()>, state: Mutex, dirty: AtomicBool, dead: AtomicBool, @@ -286,6 +287,7 @@ pub(crate) fn new_surface( Arc::new(Surface::Browser(BrowserSurface { meta: SurfaceMeta { id, name: Mutex::new(None), selection: Mutex::new(None) }, session: Mutex::new(None), + resize_transaction: Mutex::new(()), state: Mutex::new(BrowserState { latest_frame: None, taps: Vec::new(), @@ -644,6 +646,7 @@ impl BrowserSurface { } pub(crate) fn try_resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> { + let _transaction = self.resize_transaction.lock().unwrap(); let (cols, rows) = (cols.max(1), rows.max(1)); let cell = *self.cell_pixels.lock().unwrap(); let pixel_w = cols as u32 * cell.0.max(1) as u32; diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index afe09f251966..2feec3a2496c 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -43,7 +43,10 @@ use crate::pty_input::{ PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind, PtyInputSender, PtyOperationDelivery, PtyOperationFailure, }; -use crate::session::{Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_timeout}; +use crate::session::{ + Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_timeout, + is_remote_transport_failure, +}; use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::GraphicsWriter; use crate::ui::input::{InputEvent, TextInput}; @@ -184,6 +187,37 @@ struct SurfaceResizeClaimState { token: u64, } +#[derive(Clone, Copy)] +struct SurfaceSyncFailureState { + attempts: u8, + retry_after: Option, +} + +#[derive(Clone, Copy)] +struct SurfaceResizeFailure { + desired: (u16, u16), + state: SurfaceSyncFailureState, +} + +fn next_surface_sync_failure( + previous: Option, + transient: bool, +) -> SurfaceSyncFailureState { + if !transient { + return SurfaceSyncFailureState { attempts: 0, retry_after: None }; + } + let attempts = previous.map_or(1, |state| state.attempts.saturating_add(1)).min(6); + let delay_seconds = 1_u64 << u32::from(attempts.saturating_sub(1)); + SurfaceSyncFailureState { + attempts, + retry_after: Some(Instant::now() + Duration::from_secs(delay_seconds.min(30))), + } +} + +fn surface_sync_failure_blocks(state: SurfaceSyncFailureState) -> bool { + state.retry_after.is_none_or(|retry_after| Instant::now() < retry_after) +} + enum SurfaceResizeDecision { Noop, AlreadyClaimed, @@ -256,8 +290,8 @@ pub struct OrderedSession { remote_refresh_sequence: Arc, surface_resize_claims: Arc>>, surface_resize_claim_sequence: Arc, - surface_attach_failures: Arc>>, - surface_resize_failures: Arc>>, + surface_attach_failures: Arc>>, + surface_resize_failures: Arc>>, config_generation: Arc, sidebar_plugin_sync: Arc>, exited_surfaces: Arc>>, @@ -278,7 +312,7 @@ impl OrderedSession { remote_refresh_sequence: Arc::new(AtomicU64::new(0)), surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), surface_resize_claim_sequence: Arc::new(AtomicU64::new(0)), - surface_attach_failures: Arc::new(Mutex::new(HashSet::new())), + surface_attach_failures: Arc::new(Mutex::new(HashMap::new())), surface_resize_failures: Arc::new(Mutex::new(HashMap::new())), config_generation: Arc::new(AtomicU64::new(0)), sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), @@ -354,36 +388,56 @@ impl OrderedSession { Ok(Some(_)) => { attach_failures.lock().unwrap().remove(&id); pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) } Ok(None) => { - attach_failures.lock().unwrap().insert(id); + let mut failures = attach_failures.lock().unwrap(); + let state = next_surface_sync_failure(failures.get(&id).copied(), false); + failures.insert(id, state); + drop(failures); pending.settle(SessionMutationOutcome::SurfaceSyncFailed { surface: id, operation: "attach", error: format!("surface {id} is unavailable"), }); + Ok(()) } Err(error) => { - attach_failures.lock().unwrap().insert(id); + let transient = + is_remote_timeout(&error) || is_remote_transport_failure(&error); + let mut failures = attach_failures.lock().unwrap(); + let state = + next_surface_sync_failure(failures.get(&id).copied(), transient); + failures.insert(id, state); + drop(failures); pending.settle(SessionMutationOutcome::SurfaceSyncFailed { surface: id, operation: "attach", error: error.to_string(), }); + if transient { Err(error) } else { Ok(()) } } } - Ok(()) }, ); if enqueue_result != PtyInputEnqueueResult::Accepted { - enqueue_failures.lock().unwrap().insert(id); + let transient = enqueue_result != PtyInputEnqueueResult::Failed; + let mut failures = enqueue_failures.lock().unwrap(); + let state = next_surface_sync_failure(failures.get(&id).copied(), transient); + failures.insert(id, state); } } fn can_attach_surface(&self, id: SurfaceId) -> bool { self.inner.cached_surface(id).is_none() && !self.exited_surfaces.lock().unwrap().contains(&id) - && !self.surface_attach_failures.lock().unwrap().contains(&id) + && !self + .surface_attach_failures + .lock() + .unwrap() + .get(&id) + .copied() + .is_some_and(surface_sync_failure_blocks) && (!self.remote || !self.inner.remote_tree_is_stale()) } @@ -476,7 +530,7 @@ impl OrderedSession { let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); let spawn = std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { - let result = session.refresh_tree().map_err(|error| error.to_string()); + let result = session.refresh_tree_background().map_err(|error| error.to_string()); drop(claim); let _ = events.send(AppEvent::RemoteTreeUpdated { refresh_sequence, result }); }); @@ -614,21 +668,35 @@ impl OrderedSession { failures.lock().unwrap().remove(&surface_id); committed_mutation_generation.fetch_add(1, Ordering::AcqRel); pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) } Err(error) => { - failures.lock().unwrap().insert(surface_id, (cols, rows)); + let transient = + is_remote_timeout(&error) || is_remote_transport_failure(&error); + let mut failures = failures.lock().unwrap(); + let previous = failures.get(&surface_id).map(|failure| failure.state); + let state = next_surface_sync_failure(previous, transient); + failures.insert( + surface_id, + SurfaceResizeFailure { desired: (cols, rows), state }, + ); + drop(failures); pending.settle(SessionMutationOutcome::SurfaceSyncFailed { surface: surface_id, operation: "resize", error: error.to_string(), }); + if transient { Err(error) } else { Ok(()) } } } - Ok(()) }, ); if enqueue_result != PtyInputEnqueueResult::Accepted { - enqueue_failures.lock().unwrap().insert(surface_id, (cols, rows)); + let transient = enqueue_result != PtyInputEnqueueResult::Failed; + let mut failures = enqueue_failures.lock().unwrap(); + let previous = failures.get(&surface_id).map(|failure| failure.state); + let state = next_surface_sync_failure(previous, transient); + failures.insert(surface_id, SurfaceResizeFailure { desired: (cols, rows), state }); } } @@ -639,10 +707,15 @@ impl OrderedSession { surface_needs_resize: bool, ) -> SurfaceResizeDecision { let mut failures = self.surface_resize_failures.lock().unwrap(); - if failures.get(&surface_id) == Some(&desired) { + if let Some(failure) = failures.get(&surface_id).copied() + && failure.desired == desired + && surface_sync_failure_blocks(failure.state) + { return SurfaceResizeDecision::Failed; } - failures.remove(&surface_id); + if failures.get(&surface_id).is_some_and(|failure| failure.desired != desired) { + failures.remove(&surface_id); + } drop(failures); let mut claims = self.surface_resize_claims.lock().unwrap(); if claims.get(&surface_id).is_some_and(|claim| claim.desired == desired) { @@ -1277,6 +1350,8 @@ pub struct App { deferred_input: VecDeque, routing_refresh_pending: bool, routing_refresh_retries_remaining: u8, + background_refresh_attempts: u8, + background_refresh_retry_at: Option, last_applied_refresh_sequence: u64, pending_session_completions: VecDeque, mux_titles: Arc, @@ -1565,6 +1640,8 @@ pub fn run( deferred_input: VecDeque::new(), routing_refresh_pending: false, routing_refresh_retries_remaining: 0, + background_refresh_attempts: 0, + background_refresh_retry_at: None, last_applied_refresh_sequence: 0, pending_session_completions: VecDeque::new(), mux_titles, @@ -1657,6 +1734,7 @@ impl App { if self.expire_toast() { action = action.merge(RenderAction::Draw); } + self.retry_background_refresh_if_due(); self.render_action(terminal, action)?; if self.routing_refresh_pending { self.routing_refresh_pending = false; @@ -1831,6 +1909,21 @@ impl App { } } + fn schedule_background_refresh_retry(&mut self) { + self.background_refresh_attempts = + self.background_refresh_attempts.saturating_add(1).min(6); + let delay_seconds = 1_u64 << u32::from(self.background_refresh_attempts.saturating_sub(1)); + self.background_refresh_retry_at = + Some(Instant::now() + Duration::from_secs(delay_seconds.min(30))); + } + + fn retry_background_refresh_if_due(&mut self) { + if self.background_refresh_retry_at.is_some_and(|retry_at| Instant::now() >= retry_at) { + self.background_refresh_retry_at = None; + self.session.refresh_remote_tree_background(); + } + } + fn draw_terminal( &mut self, terminal: &mut RatatuiTerminal>, @@ -2259,6 +2352,8 @@ impl App { self.session.reconcile_exited_surfaces(&tree); self.replace_tree(tree); self.routing_refresh_retries_remaining = 0; + self.background_refresh_attempts = 0; + self.background_refresh_retry_at = None; self.apply_session_completions_through(authoritative_generation); self.complete_remote_tree_refresh(true); } @@ -2293,7 +2388,7 @@ impl App { } SessionMutationOutcome::SurfaceSyncFailed { surface, operation, error } => { self.status_message = Some(format!( - "surface {surface} {operation} failed; waiting for a lifecycle change: {error}" + "surface {surface} {operation} failed; retries are rate-limited: {error}" )); } SessionMutationOutcome::MutationTimedOut(error) => { @@ -2334,18 +2429,26 @@ impl App { if !self.accept_refresh_sequence(refresh_sequence) { return Ok(RenderAction::None); } - match result { + let refreshed = match result { Ok(tree) => { self.session.reconcile_exited_surfaces(&tree); self.replace_tree(tree); self.routing_refresh_pending = true; self.routing_refresh_retries_remaining = 0; + self.background_refresh_attempts = 0; + self.background_refresh_retry_at = None; + true } Err(error) => { self.status_message = Some(format!("refresh remote tree failed: {error}")); + self.schedule_background_refresh_retry(); + let _ = self.session.take_background_refresh_dirty(); + false } + }; + if refreshed { + self.complete_remote_tree_refresh(true); } - self.complete_remote_tree_refresh(true); if !self.session.has_pending_mutations() && !self.session.remote_tree_is_stale() && !self.deferred_input.is_empty() @@ -5805,6 +5908,23 @@ mod tests { )); } + #[test] + fn transient_surface_sync_failures_retry_with_bounded_backoff() { + let first = super::next_surface_sync_failure(None, true); + assert_eq!(first.attempts, 1); + assert!(super::surface_sync_failure_blocks(first)); + + let elapsed = super::SurfaceSyncFailureState { + attempts: first.attempts, + retry_after: Some(std::time::Instant::now() - Duration::from_millis(1)), + }; + assert!(!super::surface_sync_failure_blocks(elapsed)); + let capped = + (0..10).fold(elapsed, |state, _| super::next_surface_sync_failure(Some(state), true)); + assert_eq!(capped.attempts, 6); + assert!(capped.retry_after.unwrap() <= std::time::Instant::now() + Duration::from_secs(30)); + } + #[test] fn refresh_sequences_are_monotonic_across_identity_and_background_paths() { let mux = Mux::new("refresh-sequence-test", SurfaceOptions::default()); @@ -6744,6 +6864,8 @@ mod tests { deferred_input: VecDeque::new(), routing_refresh_pending: false, routing_refresh_retries_remaining: 0, + background_refresh_attempts: 0, + background_refresh_retry_at: None, last_applied_refresh_sequence: 0, pending_session_completions: VecDeque::new(), mux_titles: Arc::new(MuxTitleIngress::default()), diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index d3eab59e74e6..020c15909965 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -23,6 +23,7 @@ //! browser lane. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; @@ -82,6 +83,7 @@ pub enum BrowserInputKind { struct SequencedBrowserInputEvent { sequence: u64, event: BrowserInputEvent, + lifetime: Arc, } #[derive(Default)] @@ -115,6 +117,7 @@ pub struct BrowserInputDispatcher { order: Arc>, latest_resizes: Arc>>, failed_resizes: Arc>>, + surface_lifetimes: Arc>>>, } #[cfg(test)] @@ -130,6 +133,7 @@ impl BrowserInputDispatcher { let order = Arc::new(Mutex::new(BrowserEnqueueOrder::default())); let latest_resizes = Arc::new(Mutex::new(HashMap::new())); let failed_resizes = Arc::new(Mutex::new(HashMap::new())); + let surface_lifetimes = Arc::new(Mutex::new(HashMap::new())); let worker_order = order.clone(); let worker_resizes = latest_resizes.clone(); let worker_failures = failed_resizes.clone(); @@ -137,7 +141,7 @@ impl BrowserInputDispatcher { std::thread::Builder::new().name("mux-browser-input".into()).spawn(move || { worker(rx, worker_order, worker_resizes, worker_failures, on_resize_failure); })?; - Ok(BrowserInputDispatcher { tx, order, latest_resizes, failed_resizes }) + Ok(BrowserInputDispatcher { tx, order, latest_resizes, failed_resizes, surface_lifetimes }) } #[cfg(test)] @@ -149,6 +153,7 @@ impl BrowserInputDispatcher { order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: Arc::new(Mutex::new(HashMap::new())), failed_resizes: Arc::new(Mutex::new(HashMap::new())), + surface_lifetimes: Arc::new(Mutex::new(HashMap::new())), }, BlockedBrowserInput { _rx: rx }, ) @@ -163,10 +168,17 @@ impl BrowserInputDispatcher { { return; } + let lifetime = self + .surface_lifetimes + .lock() + .unwrap() + .entry(event.surface_id) + .or_insert_with(|| Arc::new(AtomicBool::new(false))) + .clone(); let mut order = self.order.lock().unwrap(); let sequence = order.next_sequence; order.next_sequence = order.next_sequence.saturating_add(1); - let event = SequencedBrowserInputEvent { sequence, event }; + let event = SequencedBrowserInputEvent { sequence, event, lifetime }; match self.tx.try_send(event) { Ok(()) if !is_resize => { order.barrier_epoch = order.barrier_epoch.saturating_add(1); @@ -186,7 +198,13 @@ impl BrowserInputDispatcher { } pub fn forget_surface(&self, surface_id: SurfaceId) { + // Surface-exit handling removes the ID from app topology before this + // call, so no later app input can create a fresh lifetime for it. + if let Some(lifetime) = self.surface_lifetimes.lock().unwrap().remove(&surface_id) { + lifetime.store(true, Ordering::Release); + } self.failed_resizes.lock().unwrap().remove(&surface_id); + self.latest_resizes.lock().unwrap().retain(|(surface, _), _| *surface != surface_id); } pub fn clear_resize_failures(&self) { @@ -204,25 +222,30 @@ fn worker( while let Ok(event) = rx.recv() { let mut batch = vec![event]; finish_ordered_batch(&rx, &order, &latest_resizes, &mut batch); - let mut batch = batch.into_iter().map(|event| event.event).collect::>(); - coalesce_browser_events(&mut batch); + coalesce_sequenced_browser_events(&mut batch); for event in batch { - let desired = event.kind.resize_dimensions(); + if event.lifetime.load(Ordering::Acquire) { + continue; + } + let desired = event.event.kind.resize_dimensions(); if desired.is_some_and(|desired| { - failed_resizes.lock().unwrap().get(&event.surface_id) == Some(&desired) + failed_resizes.lock().unwrap().get(&event.event.surface_id) == Some(&desired) }) { continue; } - let result = dispatch(&event); + let result = dispatch(&event.event); let Some((cols, rows)) = desired else { continue }; + if event.lifetime.load(Ordering::Acquire) { + continue; + } match result { Ok(()) => { - failed_resizes.lock().unwrap().remove(&event.surface_id); + failed_resizes.lock().unwrap().remove(&event.event.surface_id); } Err(error) => { - failed_resizes.lock().unwrap().insert(event.surface_id, (cols, rows)); + failed_resizes.lock().unwrap().insert(event.event.surface_id, (cols, rows)); let failure = BrowserResizeFailure { - surface_id: event.surface_id, + surface_id: event.event.surface_id, cols, rows, error: error.to_string(), @@ -265,6 +288,7 @@ fn merge_latest_resizes( /// Drop a mouse move when the next event is also a mouse move on the /// same surface: only the final position of a consecutive run is /// forwarded. Clicks, keys, and wheel events keep their order. +#[cfg(test)] fn coalesce_browser_events(batch: &mut Vec) { let mut index = 0; while index + 1 < batch.len() { @@ -281,6 +305,21 @@ fn coalesce_browser_events(batch: &mut Vec) { } } +fn coalesce_sequenced_browser_events(batch: &mut Vec) { + let mut index = 0; + while index + 1 < batch.len() { + let current = &batch[index].event; + let next = &batch[index + 1].event; + let same_coalescing_kind = (current.kind.is_mouse_move() && next.kind.is_mouse_move()) + || (current.kind.is_resize() && next.kind.is_resize()); + if same_coalescing_kind && current.surface_id == next.surface_id { + batch.remove(index); + } else { + index += 1; + } + } +} + fn dispatch(event: &BrowserInputEvent) -> anyhow::Result<()> { let surface = &event.surface; match &event.kind { @@ -381,7 +420,7 @@ mod tests { } fn sequenced(sequence: u64, event: BrowserInputEvent) -> SequencedBrowserInputEvent { - SequencedBrowserInputEvent { sequence, event } + SequencedBrowserInputEvent { sequence, event, lifetime: Arc::new(AtomicBool::new(false)) } } fn positions(batch: &[BrowserInputEvent]) -> Vec<(&'static str, SurfaceId)> { @@ -455,6 +494,7 @@ mod tests { order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: latest_resizes.clone(), failed_resizes: Arc::new(Mutex::new(HashMap::new())), + surface_lifetimes: Arc::new(Mutex::new(HashMap::new())), }; dispatcher.enqueue(click_event(1)); @@ -487,6 +527,7 @@ mod tests { order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: latest_resizes.clone(), failed_resizes: Arc::new(Mutex::new(HashMap::new())), + surface_lifetimes: Arc::new(Mutex::new(HashMap::new())), }; let accepted = Arc::new(AtomicBool::new(false)); dispatcher.enqueue(resize_event_with_probe(1, 80, accepted.clone())); @@ -542,6 +583,36 @@ mod tests { assert!(!dispatcher.resize_failed(7, (100, 24))); } + #[test] + fn forgetting_surface_cancels_queued_resize_and_clears_fallback() { + let (tx, rx) = sync_channel(1); + let latest_resizes = Arc::new(Mutex::new(HashMap::new())); + let dispatcher = BrowserInputDispatcher { + tx, + order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), + latest_resizes: latest_resizes.clone(), + failed_resizes: Arc::new(Mutex::new(HashMap::new())), + surface_lifetimes: Arc::new(Mutex::new(HashMap::new())), + }; + let queued = Arc::new(AtomicBool::new(false)); + let fallback = Arc::new(AtomicBool::new(false)); + dispatcher.enqueue(resize_event_with_probe(7, 80, queued.clone())); + dispatcher.enqueue(click_event(8)); + dispatcher.enqueue(resize_event_with_probe(7, 100, fallback.clone())); + dispatcher.failed_resizes.lock().unwrap().insert(7, (80, 24)); + + dispatcher.forget_surface(7); + + assert!(!queued.load(Ordering::Acquire)); + assert!(fallback.load(Ordering::Acquire)); + assert!(latest_resizes.lock().unwrap().is_empty()); + assert!(!dispatcher.resize_failed(7, (80, 24))); + let queued_event = rx.recv().unwrap(); + assert!(queued_event.lifetime.load(Ordering::Acquire)); + drop(queued_event); + assert!(queued.load(Ordering::Acquire)); + } + #[test] fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { let mut batch = vec![sequenced(0, click_event(1))]; @@ -563,6 +634,7 @@ mod tests { order: Arc::new(Mutex::new(BrowserEnqueueOrder::default())), latest_resizes: latest_resizes.clone(), failed_resizes: Arc::new(Mutex::new(HashMap::new())), + surface_lifetimes: Arc::new(Mutex::new(HashMap::new())), }; dispatcher.enqueue(click_event(1)); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index d76193470a39..18a2d2ed0060 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -133,6 +133,13 @@ impl Session { } } + pub fn refresh_tree_background(&self) -> anyhow::Result { + match self { + Session::Local(_) => Ok(self.tree()), + Session::Remote(remote) => remote.refresh_tree_background(), + } + } + /// Make sure the session has at least one workspace to show. `size` /// is the expected content size of the first pane, when known. pub fn ensure_initial(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 55217671dd3e..b1a8045c06be 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -421,7 +421,8 @@ impl RemoteSession { let cols = value.get("cols").and_then(|v| v.as_u64()).unwrap_or(80) as u16; let rows = value.get("rows").and_then(|v| v.as_u64()).unwrap_or(24) as u16; let replay = value - .get("data") + .get("replay") + .or_else(|| value.get("data")) .and_then(|v| v.as_str()) .and_then(|data| base64::engine::general_purpose::STANDARD.decode(data).ok()); self.log_frame( @@ -697,14 +698,26 @@ impl RemoteSession { } pub fn refresh_tree(&self) -> anyhow::Result { + self.refresh_tree_inner(true) + } + + pub fn refresh_tree_background(&self) -> anyhow::Result { + self.refresh_tree_inner(false) + } + + fn refresh_tree_inner(&self, identity_refresh: bool) -> anyhow::Result { let _refresh = self.tree_refresh.lock().unwrap(); - self.tree_stale.store(false, Ordering::Release); + if identity_refresh { + self.tree_stale.store(false, Ordering::Release); + } let refresh_generation = self.tree.lock().unwrap().title_generation(); let data = match self.request(json!({"cmd": "list-workspaces"})) { Ok(data) => data, Err(e) => { - // Retry next frame rather than caching a bad tree. - self.tree_stale.store(true, Ordering::Release); + if identity_refresh { + // Retry identity refreshes rather than caching a bad tree. + self.tree_stale.store(true, Ordering::Release); + } return Err(e); } }; @@ -893,6 +906,30 @@ mod tests { assert!(release["id"].as_u64().unwrap() > first["id"].as_u64().unwrap()); } + #[cfg(unix)] + #[test] + fn background_refresh_failure_does_not_mark_identity_stale() { + let (client, server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + session.tree_stale.store(false, Ordering::Release); + let refreshing = session.clone(); + let refresh = std::thread::spawn(move || refreshing.refresh_tree_background()); + + let mut peer = BufReader::new(server); + let mut line = String::new(); + peer.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + writeln!( + peer.get_mut(), + "{}", + json!({"id": request["id"], "ok": false, "error": "temporary"}) + ) + .unwrap(); + + assert!(refresh.join().unwrap().is_err()); + assert!(!session.tree_is_stale()); + } + #[test] fn indexed_title_update_changes_only_the_addressed_surface() { let mut cache = RemoteTreeCache::default(); @@ -1059,6 +1096,41 @@ mod tests { assert_eq!(mirror.scrollback_rows(), scrollback_rows); } + #[cfg(unix)] + #[test] + fn resized_event_decodes_protocol_replay_field() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let surface = Arc::new(RemoteSurface { + id: 7, + kind: SurfaceKind::Pty, + term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), + dirty: AtomicBool::new(false), + server_size: Mutex::new((12, 4)), + asserted_size: Mutex::new(None), + browser: Mutex::new(RemoteBrowserState::default()), + }); + session.surfaces.lock().unwrap().insert(7, surface.clone()); + + let mut authoritative = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + for index in 0..8 { + authoritative.vt_write(format!("authoritative-{index}\r\n").as_bytes()); + } + authoritative.resize(8, 4, 8, 16).unwrap(); + let expected = authoritative.plain_text().unwrap(); + let replay = authoritative.vt_replay().unwrap(); + session.handle_line(json!({ + "event": "resized", + "surface": 7, + "cols": 8, + "rows": 4, + "replay": base64::engine::general_purpose::STANDARD.encode(replay), + })); + + assert_eq!(*surface.server_size.lock().unwrap(), (8, 4)); + assert_eq!(surface.term.lock().unwrap().plain_text().unwrap(), expected); + } + #[test] fn ordered_resize_replay_recovers_from_stale_initial_replay() { let mut server = Terminal::new(12, 3, 100, Callbacks::default()).unwrap(); diff --git a/cmux-tui/docs/protocol.md b/cmux-tui/docs/protocol.md index 39124aaee9d1..b3b1100027cc 100644 --- a/cmux-tui/docs/protocol.md +++ b/cmux-tui/docs/protocol.md @@ -121,7 +121,7 @@ Then it sends ordered stream frames: ```json {"event":"output","surface":4,"data":""} -{"event":"resized","surface":4,"cols":132,"rows":43,"data":""} +{"event":"resized","surface":4,"cols":132,"rows":43,"replay":""} ``` The `resized` attach frame carries the new cell size and a fresh VT replay captured at that size. It is delivered in the same attach stream as output frames, so a client can reset its local terminal, apply the replay, and continue consuming later output in order. From e5d00060e77fc90ba79af374158191eeb5b6ae92 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:48:01 -0700 Subject: [PATCH 041/103] Retry browser resizes with bounded backoff --- cmux-tui/crates/cmux-tui/src/browser_input.rs | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 020c15909965..81b5b6a73cec 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -26,6 +26,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use cmux_tui_core::SurfaceId; @@ -93,6 +94,33 @@ struct BrowserEnqueueOrder { barrier_epoch: u64, } +#[derive(Clone, Copy)] +struct FailedBrowserResize { + desired: (u16, u16), + attempts: u8, + retry_after: Instant, +} + +fn next_failed_browser_resize( + previous: Option, + desired: (u16, u16), +) -> FailedBrowserResize { + let attempts = previous + .filter(|failure| failure.desired == desired) + .map_or(1, |failure| failure.attempts.saturating_add(1)) + .min(6); + let delay_seconds = 1_u64 << u32::from(attempts.saturating_sub(1)); + FailedBrowserResize { + desired, + attempts, + retry_after: Instant::now() + Duration::from_secs(delay_seconds.min(30)), + } +} + +fn failed_browser_resize_blocks(failure: FailedBrowserResize, desired: (u16, u16)) -> bool { + failure.desired == desired && Instant::now() < failure.retry_after +} + impl BrowserInputKind { /// Mouse moves carry only a position; when several are queued for /// the same surface, only the newest matters. @@ -116,7 +144,7 @@ pub struct BrowserInputDispatcher { tx: SyncSender, order: Arc>, latest_resizes: Arc>>, - failed_resizes: Arc>>, + failed_resizes: Arc>>, surface_lifetimes: Arc>>>, } @@ -194,7 +222,12 @@ impl BrowserInputDispatcher { } pub fn resize_failed(&self, surface_id: SurfaceId, desired: (u16, u16)) -> bool { - self.failed_resizes.lock().unwrap().get(&surface_id) == Some(&desired) + self.failed_resizes + .lock() + .unwrap() + .get(&surface_id) + .copied() + .is_some_and(|failure| failed_browser_resize_blocks(failure, desired)) } pub fn forget_surface(&self, surface_id: SurfaceId) { @@ -216,7 +249,7 @@ fn worker( rx: Receiver, order: Arc>, latest_resizes: Arc>>, - failed_resizes: Arc>>, + failed_resizes: Arc>>, on_resize_failure: Arc, ) { while let Ok(event) = rx.recv() { @@ -229,7 +262,12 @@ fn worker( } let desired = event.event.kind.resize_dimensions(); if desired.is_some_and(|desired| { - failed_resizes.lock().unwrap().get(&event.event.surface_id) == Some(&desired) + failed_resizes + .lock() + .unwrap() + .get(&event.event.surface_id) + .copied() + .is_some_and(|failure| failed_browser_resize_blocks(failure, desired)) }) { continue; } @@ -243,7 +281,14 @@ fn worker( failed_resizes.lock().unwrap().remove(&event.event.surface_id); } Err(error) => { - failed_resizes.lock().unwrap().insert(event.event.surface_id, (cols, rows)); + let desired = (cols, rows); + let mut failures = failed_resizes.lock().unwrap(); + let failure = next_failed_browser_resize( + failures.get(&event.event.surface_id).copied(), + desired, + ); + failures.insert(event.event.surface_id, failure); + drop(failures); let failure = BrowserResizeFailure { surface_id: event.event.surface_id, cols, @@ -570,7 +615,7 @@ mod tests { .unwrap(); dispatcher.enqueue(resize_event(7, 100)); - let failure = failure_rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap(); + let failure = failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!((failure.surface_id, failure.cols, failure.rows), (7, 100, 24)); assert!(dispatcher.resize_failed(7, (100, 24))); @@ -579,6 +624,12 @@ mod tests { assert!(dropped.load(Ordering::Acquire)); assert!(failure_rx.try_recv().is_err()); + dispatcher.failed_resizes.lock().unwrap().get_mut(&7).unwrap().retry_after = + Instant::now() - Duration::from_millis(1); + dispatcher.enqueue(resize_event(7, 100)); + failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(dispatcher.failed_resizes.lock().unwrap().get(&7).unwrap().attempts, 2); + dispatcher.forget_surface(7); assert!(!dispatcher.resize_failed(7, (100, 24))); } @@ -599,7 +650,14 @@ mod tests { dispatcher.enqueue(resize_event_with_probe(7, 80, queued.clone())); dispatcher.enqueue(click_event(8)); dispatcher.enqueue(resize_event_with_probe(7, 100, fallback.clone())); - dispatcher.failed_resizes.lock().unwrap().insert(7, (80, 24)); + dispatcher.failed_resizes.lock().unwrap().insert( + 7, + FailedBrowserResize { + desired: (80, 24), + attempts: 1, + retry_after: Instant::now() + Duration::from_secs(1), + }, + ); dispatcher.forget_surface(7); From a72aa9cce4a144b0d3df41be6c3509f0d44e8509 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:15:15 -0700 Subject: [PATCH 042/103] Preserve reattach input and lifecycle ownership --- cmux-tui/bindings/python/cmux/client.py | 2 + .../bindings/python/tests/test_protocol.py | 17 ++ cmux-tui/crates/cmux-tui/src/app.rs | 210 ++++++++++++++++-- cmux-tui/crates/cmux-tui/src/browser_input.rs | 7 + 4 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 cmux-tui/bindings/python/tests/test_protocol.py diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index b974f85a6bff..d6a898f284f0 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -526,6 +526,8 @@ def subscribe_with_request(self, request: Dict[str, Any]) -> EventStream: def attach_surface(self, surface: int) -> AttachStream: protocol = self._protocol if self._protocol is not None else self.identify().protocol + if protocol > 7: + raise ProtocolError(f"unsupported protocol {protocol}; maximum supported is 7") if protocol > 5 and not self.allow_protocol_v6_attach: raise ProtocolError("protocol v6 attach streams require resized replay handling") return AttachStream(self, {"cmd": "attach-surface", "surface": surface}) diff --git a/cmux-tui/bindings/python/tests/test_protocol.py b/cmux-tui/bindings/python/tests/test_protocol.py new file mode 100644 index 000000000000..418619ab2acc --- /dev/null +++ b/cmux-tui/bindings/python/tests/test_protocol.py @@ -0,0 +1,17 @@ +import unittest + +from cmux import CmuxClient, ProtocolError + + +class ProtocolTests(unittest.TestCase): + def test_attach_rejects_protocols_newer_than_seven_even_with_opt_in(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._protocol = 8 + client.allow_protocol_v6_attach = True + + with self.assertRaisesRegex(ProtocolError, "maximum supported is 7"): + client.attach_surface(1) + + +if __name__ == "__main__": + unittest.main() diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 2feec3a2496c..9170438aa399 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -181,6 +181,17 @@ struct SurfaceResizeClaim { token: u64, } +struct SurfaceAttachClaim { + claims: Arc>>, + surface: SurfaceId, +} + +impl Drop for SurfaceAttachClaim { + fn drop(&mut self) { + self.claims.lock().unwrap().remove(&self.surface); + } +} + #[derive(Clone, Copy)] struct SurfaceResizeClaimState { desired: (u16, u16), @@ -191,6 +202,7 @@ struct SurfaceResizeClaimState { struct SurfaceSyncFailureState { attempts: u8, retry_after: Option, + sticky_until_reconnect: bool, } #[derive(Clone, Copy)] @@ -202,15 +214,17 @@ struct SurfaceResizeFailure { fn next_surface_sync_failure( previous: Option, transient: bool, + sticky_until_reconnect: bool, ) -> SurfaceSyncFailureState { if !transient { - return SurfaceSyncFailureState { attempts: 0, retry_after: None }; + return SurfaceSyncFailureState { attempts: 0, retry_after: None, sticky_until_reconnect }; } let attempts = previous.map_or(1, |state| state.attempts.saturating_add(1)).min(6); let delay_seconds = 1_u64 << u32::from(attempts.saturating_sub(1)); SurfaceSyncFailureState { attempts, retry_after: Some(Instant::now() + Duration::from_secs(delay_seconds.min(30))), + sticky_until_reconnect, } } @@ -290,6 +304,7 @@ pub struct OrderedSession { remote_refresh_sequence: Arc, surface_resize_claims: Arc>>, surface_resize_claim_sequence: Arc, + surface_attach_claims: Arc>>, surface_attach_failures: Arc>>, surface_resize_failures: Arc>>, config_generation: Arc, @@ -312,6 +327,7 @@ impl OrderedSession { remote_refresh_sequence: Arc::new(AtomicU64::new(0)), surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), surface_resize_claim_sequence: Arc::new(AtomicU64::new(0)), + surface_attach_claims: Arc::new(Mutex::new(HashSet::new())), surface_attach_failures: Arc::new(Mutex::new(HashMap::new())), surface_resize_failures: Arc::new(Mutex::new(HashMap::new())), config_generation: Arc::new(AtomicU64::new(0)), @@ -346,6 +362,7 @@ impl OrderedSession { self.exited_surfaces.lock().unwrap().insert(id); } self.surface_attach_failures.lock().unwrap().remove(&id); + self.surface_attach_claims.lock().unwrap().remove(&id); self.surface_resize_failures.lock().unwrap().remove(&id); self.inner.forget_surface(id); } @@ -355,7 +372,10 @@ impl OrderedSession { } fn clear_surface_sync_failures(&self) { - self.surface_attach_failures.lock().unwrap().clear(); + self.surface_attach_failures + .lock() + .unwrap() + .retain(|_, failure| failure.sticky_until_reconnect); self.surface_resize_failures.lock().unwrap().clear(); } @@ -367,6 +387,8 @@ impl OrderedSession { if !self.can_attach_surface(id) { return; } + self.surface_attach_claims.lock().unwrap().insert(id); + let claim = SurfaceAttachClaim { claims: self.surface_attach_claims.clone(), surface: id }; let session = self.inner.clone(); let exited_surfaces = self.exited_surfaces.clone(); let attach_failures = self.surface_attach_failures.clone(); @@ -378,6 +400,7 @@ impl OrderedSession { ("attach surface", id), self.remote, move || { + let _claim = claim; if exited_surfaces.lock().unwrap().contains(&id) || (remote && session.remote_tree_is_stale()) { @@ -392,7 +415,8 @@ impl OrderedSession { } Ok(None) => { let mut failures = attach_failures.lock().unwrap(); - let state = next_surface_sync_failure(failures.get(&id).copied(), false); + let state = + next_surface_sync_failure(failures.get(&id).copied(), false, false); failures.insert(id, state); drop(failures); pending.settle(SessionMutationOutcome::SurfaceSyncFailed { @@ -403,11 +427,14 @@ impl OrderedSession { Ok(()) } Err(error) => { - let transient = - is_remote_timeout(&error) || is_remote_transport_failure(&error); + let timed_out = is_remote_timeout(&error); + let transport_failed = is_remote_transport_failure(&error); let mut failures = attach_failures.lock().unwrap(); - let state = - next_surface_sync_failure(failures.get(&id).copied(), transient); + let state = next_surface_sync_failure( + failures.get(&id).copied(), + transport_failed, + timed_out, + ); failures.insert(id, state); drop(failures); pending.settle(SessionMutationOutcome::SurfaceSyncFailed { @@ -415,7 +442,7 @@ impl OrderedSession { operation: "attach", error: error.to_string(), }); - if transient { Err(error) } else { Ok(()) } + if timed_out || transport_failed { Err(error) } else { Ok(()) } } } }, @@ -423,7 +450,7 @@ impl OrderedSession { if enqueue_result != PtyInputEnqueueResult::Accepted { let transient = enqueue_result != PtyInputEnqueueResult::Failed; let mut failures = enqueue_failures.lock().unwrap(); - let state = next_surface_sync_failure(failures.get(&id).copied(), transient); + let state = next_surface_sync_failure(failures.get(&id).copied(), transient, false); failures.insert(id, state); } } @@ -438,6 +465,7 @@ impl OrderedSession { .get(&id) .copied() .is_some_and(surface_sync_failure_blocks) + && !self.surface_attach_claims.lock().unwrap().contains(&id) && (!self.remote || !self.inner.remote_tree_is_stale()) } @@ -675,7 +703,7 @@ impl OrderedSession { is_remote_timeout(&error) || is_remote_transport_failure(&error); let mut failures = failures.lock().unwrap(); let previous = failures.get(&surface_id).map(|failure| failure.state); - let state = next_surface_sync_failure(previous, transient); + let state = next_surface_sync_failure(previous, transient, false); failures.insert( surface_id, SurfaceResizeFailure { desired: (cols, rows), state }, @@ -695,7 +723,7 @@ impl OrderedSession { let transient = enqueue_result != PtyInputEnqueueResult::Failed; let mut failures = enqueue_failures.lock().unwrap(); let previous = failures.get(&surface_id).map(|failure| failure.state); - let state = next_surface_sync_failure(previous, transient); + let state = next_surface_sync_failure(previous, transient, false); failures.insert(surface_id, SurfaceResizeFailure { desired: (cols, rows), state }); } } @@ -1734,6 +1762,7 @@ impl App { if self.expire_toast() { action = action.merge(RenderAction::Draw); } + self.retry_deferred_surface_attach(); self.retry_background_refresh_if_due(); self.render_action(terminal, action)?; if self.routing_refresh_pending { @@ -1830,6 +1859,29 @@ impl App { } fn replace_tree(&mut self, tree: TreeView) { + let live_browsers = tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .filter(|tab| tab.kind == SurfaceKind::Browser) + .map(|tab| tab.surface) + .collect::>(); + let removed_browsers = self + .tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .filter(|tab| tab.kind == SurfaceKind::Browser) + .map(|tab| tab.surface) + .filter(|surface| !live_browsers.contains(surface)) + .collect::>(); + for surface in removed_browsers { + self.browser_input.forget_surface(surface); + } self.tree = tree; self.rebuild_tab_locations(); } @@ -2216,6 +2268,14 @@ impl App { self.session.clear_surface_sync_failures(); } let event = match event { + AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) + if self.missing_input_surface(&input).is_some() + && !self.input_can_update_pending_mutation(&input) => + { + let surface = self.missing_input_surface(&input).unwrap(); + self.queue_surface_attach(surface); + return Ok(self.defer_input(input)); + } AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if (self.session.has_pending_mutations() || self.session.remote_tree_is_stale() @@ -2597,6 +2657,39 @@ impl App { Some((id, self.session.surface(id)?)) } + fn missing_input_surface(&self, input: &Event) -> Option { + let surface = match input { + Event::Key(_) | Event::Paste(_) => self.active_surface()?, + Event::Mouse(mouse) => { + let area = self.pane_area_at(mouse.column, mouse.row)?; + area.content.contains(mouse.column, mouse.row).then_some(area.surface)? + } + _ => return None, + }; + (!self.session.has_surface(surface)).then_some(surface) + } + + fn queue_surface_attach(&mut self, surface: SurfaceId) { + if !self.session.can_attach_surface(surface) { + return; + } + let size = self + .pane_areas + .iter() + .find(|area| area.surface == surface) + .map(|area| (area.content.width, area.content.height)) + .filter(|(cols, rows)| *cols > 0 && *rows > 0); + self.session.attach_surface(surface, size); + } + + fn retry_deferred_surface_attach(&mut self) { + let surface = + self.deferred_input.iter().find_map(|input| self.missing_input_surface(input)); + if let Some(surface) = surface { + self.queue_surface_attach(surface); + } + } + fn active_screen_id(&self) -> Option { self.tree.active_screen().map(|screen| screen.id) } @@ -3603,7 +3696,7 @@ impl App { fn omnibar_hit_at(&self, x: u16, y: u16) -> Option<(PaneId, OmnibarHit)> { self.pane_areas.iter().find_map(|area| { let rect = area.omnibar?; - if self.surface_kind(area.surface) != SurfaceKind::Browser { + if self.surface_kind(area.surface) != Some(SurfaceKind::Browser) { return None; } let editing = self @@ -3824,7 +3917,7 @@ impl App { return PtyMousePressResult::NotOwned; }; if !area.content.contains(x, y) - || self.surface_kind(area.surface) != SurfaceKind::Pty + || self.surface_kind(area.surface) != Some(SurfaceKind::Pty) || !self.pty_mouse_tracking(area.surface) { return PtyMousePressResult::NotOwned; @@ -4047,7 +4140,7 @@ impl App { } let Some(area) = self.pane_area_at(x, y).copied() else { return false }; if !area.content.contains(x, y) - || self.surface_kind(area.surface) != SurfaceKind::Pty + || self.surface_kind(area.surface) != Some(SurfaceKind::Pty) || !self.pty_mouse_tracking(area.surface) { return false; @@ -4322,7 +4415,7 @@ impl App { .iter() .find(|area| { area.content.contains(x, y) - && self.surface_kind(area.surface) == SurfaceKind::Browser + && self.surface_kind(area.surface) == Some(SurfaceKind::Browser) }) .copied() { @@ -4527,7 +4620,7 @@ impl App { if let Some(area) = self.pane_area_at(x, y).copied() { if area.content.contains(x, y) { - if self.surface_kind(area.surface) == SurfaceKind::Browser { + if self.surface_kind(area.surface) == Some(SurfaceKind::Browser) { if self.active_pane() != Some(area.pane) { self.focus_pane_after_input(area.pane); } @@ -4963,7 +5056,7 @@ impl App { } if let Some(area) = self.pane_area_at(x, y) { let mut items = Vec::new(); - if self.surface_kind(area.surface) == SurfaceKind::Browser { + if self.surface_kind(area.surface) == Some(SurfaceKind::Browser) { items.extend([ MenuAction::BrowserBack(area.pane), MenuAction::BrowserForward(area.pane), @@ -5097,8 +5190,16 @@ impl App { Ok(RenderAction::None) } - fn surface_kind(&self, surface: SurfaceId) -> SurfaceKind { - self.session.surface(surface).map(|surface| surface.kind()).unwrap_or(SurfaceKind::Pty) + fn surface_kind(&self, surface: SurfaceId) -> Option { + self.tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .find(|tab| tab.surface == surface) + .map(|tab| tab.kind) + .or_else(|| self.session.surface(surface).map(|surface| surface.kind())) } fn browser_source(&self, surface: SurfaceId) -> Option { @@ -5908,19 +6009,84 @@ mod tests { )); } + #[test] + fn ambiguous_attach_timeout_survives_lifecycle_clear_until_reconnect() { + let mux = Mux::new("ambiguous-attach-timeout-test", SurfaceOptions::default()); + let app = test_app(Session::Local(mux)); + app.session + .surface_attach_failures + .lock() + .unwrap() + .insert(77, super::next_surface_sync_failure(None, false, true)); + + app.session.clear_surface_sync_failures(); + + assert!(app.session.surface_attach_failures.lock().unwrap().contains_key(&77)); + assert!(!app.session.can_attach_surface(77)); + } + + #[test] + fn first_input_for_missing_mirror_is_deferred_through_attach() { + let mux = Mux::new("missing-mirror-input-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let surface = 77; + app.replace_tree(notify_tree(surface, false)); + app.pane_areas.push(browser_completion_area(surface)); + + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + + assert_eq!(app.deferred_input.len(), 1); + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!( + settled, + AppEvent::SessionMutationSettled(super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + .. + }) + )); + app.handle(settled).unwrap(); + assert_eq!(app.deferred_input.len(), 1); + } + + #[test] + fn replacing_tree_retires_removed_browser_input_state() { + let mux = Mux::new("browser-input-topology-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let (dispatcher, _blocked) = BrowserInputDispatcher::blocked(1); + app.browser_input = dispatcher; + let surface = 41; + app.replace_tree(browser_completion_tree(surface, surface)); + app.browser_input.enqueue(BrowserInputEvent { + surface_id: surface, + surface: SurfaceHandle::RemoteBrowserUnsupported, + kind: BrowserInputKind::InsertText("x".to_string()), + }); + assert!(app.browser_input.tracks_surface(surface)); + + app.replace_tree(TreeView::default()); + + assert!(!app.browser_input.tracks_surface(surface)); + } + #[test] fn transient_surface_sync_failures_retry_with_bounded_backoff() { - let first = super::next_surface_sync_failure(None, true); + let first = super::next_surface_sync_failure(None, true, false); assert_eq!(first.attempts, 1); assert!(super::surface_sync_failure_blocks(first)); let elapsed = super::SurfaceSyncFailureState { attempts: first.attempts, retry_after: Some(std::time::Instant::now() - Duration::from_millis(1)), + sticky_until_reconnect: false, }; assert!(!super::surface_sync_failure_blocks(elapsed)); - let capped = - (0..10).fold(elapsed, |state, _| super::next_surface_sync_failure(Some(state), true)); + let capped = (0..10) + .fold(elapsed, |state, _| super::next_surface_sync_failure(Some(state), true, false)); assert_eq!(capped.attempts, 6); assert!(capped.retry_after.unwrap() <= std::time::Instant::now() + Duration::from_secs(30)); } diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 81b5b6a73cec..4154fe31546c 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -243,6 +243,13 @@ impl BrowserInputDispatcher { pub fn clear_resize_failures(&self) { self.failed_resizes.lock().unwrap().clear(); } + + #[cfg(test)] + pub(crate) fn tracks_surface(&self, surface_id: SurfaceId) -> bool { + self.surface_lifetimes.lock().unwrap().contains_key(&surface_id) + || self.failed_resizes.lock().unwrap().contains_key(&surface_id) + || self.latest_resizes.lock().unwrap().keys().any(|(surface, _)| *surface == surface_id) + } } fn worker( From ff3deb4add3b336ffc6ef9fb80f4a3e3f3c98ed0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:41:44 -0700 Subject: [PATCH 043/103] Close TUI recovery review gaps --- cmux-tui/bindings/go/events.go | 5 ++ cmux-tui/bindings/go/events_test.go | 16 ++++++ .../bindings/java/src/com/cmux/CmuxEvent.java | 2 +- .../java/tests/com/cmux/JsonTest.java | 6 +++ cmux-tui/bindings/rust/src/lib.rs | 17 ++++++ cmux-tui/bindings/typescript/src/client.ts | 11 +++- cmux-tui/crates/cmux-tui/src/app.rs | 19 +++++-- cmux-tui/crates/cmux-tui/src/browser_input.rs | 8 +++ .../crates/cmux-tui/src/session/remote.rs | 54 ++++++++++++++++++- 9 files changed, 129 insertions(+), 9 deletions(-) diff --git a/cmux-tui/bindings/go/events.go b/cmux-tui/bindings/go/events.go index 01c13e7db73e..deaef73a09ca 100644 --- a/cmux-tui/bindings/go/events.go +++ b/cmux-tui/bindings/go/events.go @@ -42,6 +42,11 @@ func parseEvent(raw map[string]any) Event { return event } case "resized": + if _, ok := raw["replay"]; !ok { + if data, ok := raw["data"].(string); ok { + raw["replay"] = data + } + } var event ResizedEvent if mustDecode(&event) { return event diff --git a/cmux-tui/bindings/go/events_test.go b/cmux-tui/bindings/go/events_test.go index 2a420c0eb105..278329a34524 100644 --- a/cmux-tui/bindings/go/events_test.go +++ b/cmux-tui/bindings/go/events_test.go @@ -24,3 +24,19 @@ func TestParseTitleChangedIncludesAuthoritativeTitle(t *testing.T) { t.Fatalf("legacy event = %#v", legacy) } } + +func TestParseResizedAcceptsProtocolV6DataField(t *testing.T) { + event, ok := parseEvent(map[string]any{ + "event": "resized", + "surface": float64(7), + "cols": float64(80), + "rows": float64(24), + "data": "cmVwbGF5", + }).(ResizedEvent) + if !ok { + t.Fatalf("event type = %T, want ResizedEvent", event) + } + if event.Replay != "cmVwbGF5" { + t.Fatalf("replay = %q, want protocol v6 data", event.Replay) + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java index 6c4a6cfdca60..0f04d82f8c6f 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java @@ -32,7 +32,7 @@ static CmuxEvent from(Map raw) { CmuxClient.asLong(raw.get("surface")), (int) CmuxClient.asLong(raw.get("cols")), (int) CmuxClient.asLong(raw.get("rows")), - CmuxClient.asString(raw.get("replay")) + CmuxClient.asString(raw.containsKey("replay") ? raw.get("replay") : raw.get("data")) ); default -> new UnknownEvent(event, raw); }; diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index 714cfafdce3a..e8fc411fc5ec 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -44,6 +44,12 @@ public static void main(String[] args) { (Map) Json.parse("{\"event\":\"title-changed\",\"surface\":7}") ); assertEquals(null, legacyTitle.title(), "legacy title event title"); + ResizedEvent legacyResize = (ResizedEvent) CmuxEvent.from( + (Map) Json.parse( + "{\"event\":\"resized\",\"surface\":7,\"cols\":80,\"rows\":24,\"data\":\"cmVwbGF5\"}" + ) + ); + assertEquals("cmVwbGF5", legacyResize.replay(), "protocol v6 resize replay"); } private static void assertReject(String input) { diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index f058cdab0582..31d1896ad973 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -210,6 +210,7 @@ pub struct ResizedEvent { pub surface: u64, pub cols: u16, pub rows: u16, + #[serde(alias = "data")] pub replay: String, } @@ -752,4 +753,20 @@ mod tests { Event::TitleChanged(TitleChangedEvent { surface: 7, title: None }) )); } + + #[test] + fn resized_decodes_protocol_v6_data_field() { + let event = parse_event(serde_json::json!({ + "event": "resized", + "surface": 7, + "cols": 80, + "rows": 24, + "data": "cmVwbGF5", + })); + + assert!(matches!( + event, + Event::Resized(ResizedEvent { surface: 7, replay, .. }) if replay == "cmVwbGF5" + )); + } } diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index f2f8af832b13..82bf2791763e 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -27,6 +27,13 @@ import { CmuxCommandError, CmuxConnectionError, CmuxProtocolError, CmuxTimeoutEr type ResponseEnvelope = { id?: unknown; ok: true; data: unknown } | { id?: unknown; ok: false; error: string }; type EventObject = { event: string; [key: string]: unknown }; +function normalizeEvent(value: JsonObject): T { + if (value.event === "resized" && typeof value.replay !== "string" && typeof value.data === "string") { + value.replay = value.data; + } + return value as T; +} + export function defaultSocketPath(session = "main"): string { const base = process.env.TMPDIR || os.tmpdir(); return path.join(base, `cmux-tui-${process.getuid?.() ?? 0}`, `${session}.sock`); @@ -175,7 +182,7 @@ export class CmuxStream implements AsyncIterable { for (;;) { const value = await conn.recv(timeoutMs); if (typeof value.event === "string") { - buffered.push(value as T); + buffered.push(normalizeEvent(value)); continue; } if (value.id !== requestId) continue; @@ -191,7 +198,7 @@ export class CmuxStream implements AsyncIterable { for (;;) { const value = await this.conn.recv(timeoutMs); if (typeof value.event !== "string") continue; - const event = value as T; + const event = normalizeEvent(value); if (event.event === "detached") this.close(); return event; } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 9170438aa399..da0f665f7a13 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1763,6 +1763,9 @@ impl App { action = action.merge(RenderAction::Draw); } self.retry_deferred_surface_attach(); + if self.browser_input.resize_retry_due() { + self.reassert_visible_surface_sizes(); + } self.retry_background_refresh_if_due(); self.render_action(terminal, action)?; if self.routing_refresh_pending { @@ -1798,7 +1801,14 @@ impl App { fn replay_deferred_input(&mut self) -> anyhow::Result { let mut action = RenderAction::None; - while !self.session.has_pending_mutations() && !self.session.remote_tree_is_stale() { + // A replayed event can discover that its destination mirror is still + // unavailable and append itself again. Only process the queue snapshot + // that existed at entry so a blocked attach cannot spin this frame. + let replay_count = self.deferred_input.len(); + for _ in 0..replay_count { + if self.session.has_pending_mutations() || self.session.remote_tree_is_stale() { + break; + } let Some(input) = self.deferred_input.pop_front() else { break }; action = action.merge(self.handle(AppEvent::Input(input))?); } @@ -2247,9 +2257,6 @@ impl App { | AppEvent::Input(Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) => { self.session.refresh_remote_tree_if_stale(); } - AppEvent::Mux(MuxEvent::Notification(_)) => { - self.session.refresh_remote_tree_background(); - } AppEvent::Mux(MuxEvent::TreeChanged) => { if self.session.remote_tree_is_stale() { self.session.refresh_remote_tree_if_stale(); @@ -6051,6 +6058,10 @@ mod tests { )); app.handle(settled).unwrap(); assert_eq!(app.deferred_input.len(), 1); + + app.routing_refresh_pending = false; + app.replay_deferred_input().unwrap(); + assert_eq!(app.deferred_input.len(), 1); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 4154fe31546c..8c872b2edac0 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -230,6 +230,13 @@ impl BrowserInputDispatcher { .is_some_and(|failure| failed_browser_resize_blocks(failure, desired)) } + /// The app event loop uses this deadline as a scheduled retry wakeup, so + /// a failed resize does not depend on unrelated user input to run again. + pub fn resize_retry_due(&self) -> bool { + let now = Instant::now(); + self.failed_resizes.lock().unwrap().values().any(|failure| failure.retry_after <= now) + } + pub fn forget_surface(&self, surface_id: SurfaceId) { // Surface-exit handling removes the ID from app topology before this // call, so no later app input can create a fresh lifetime for it. @@ -633,6 +640,7 @@ mod tests { dispatcher.failed_resizes.lock().unwrap().get_mut(&7).unwrap().retry_after = Instant::now() - Duration::from_millis(1); + assert!(dispatcher.resize_retry_due()); dispatcher.enqueue(resize_event(7, 100)); failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!(dispatcher.failed_resizes.lock().unwrap().get(&7).unwrap().attempts, 2); diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index b1a8045c06be..abd146276e5d 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -13,7 +13,8 @@ use std::time::{Duration, Instant}; use base64::Engine; use cmux_tui_core::{ BrowserFrame, BrowserSource, BrowserStatus, DefaultColors, MuxEvent, MuxEventBroadcaster, - MuxEventReceiver, Rgb, SurfaceId, SurfaceKind, platform::transport, + MuxEventReceiver, NotificationEvent, NotificationLevel, Rgb, SurfaceId, SurfaceKind, + platform::transport, }; use ghostty_vt::{Callbacks, RenderState, Terminal}; use serde_json::{Value, json}; @@ -507,7 +508,25 @@ impl RemoteSession { } } Some("notification") => { - self.emit(MuxEvent::TreeChanged); + let Some(notification) = value.get("notification").and_then(Value::as_u64) else { + return; + }; + let level = match value.get("level").and_then(Value::as_str) { + Some("warning") => NotificationLevel::Warning, + Some("error") => NotificationLevel::Error, + _ => NotificationLevel::Info, + }; + self.emit(MuxEvent::Notification(NotificationEvent { + notification, + title: value + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + body: value.get("body").and_then(Value::as_str).unwrap_or_default().to_string(), + level, + surface: surface_id(), + })); } Some("status") => { if let Some(message) = value.get("message").and_then(|v| v.as_str()) { @@ -1131,6 +1150,37 @@ mod tests { assert_eq!(surface.term.lock().unwrap().plain_text().unwrap(), expected); } + #[cfg(unix)] + #[test] + fn notification_event_preserves_payload_without_invalidating_tree() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + session.tree_stale.store(false, Ordering::Release); + + session.handle_line(json!({ + "event": "notification", + "notification": 42, + "title": "Build", + "body": "finished", + "level": "warning", + "surface": 7, + })); + + assert!(!session.tree_is_stale()); + assert!(events.try_iter().any(|event| { + matches!( + event, + MuxEvent::Notification(notification) + if notification.notification == 42 + && notification.title == "Build" + && notification.body == "finished" + && notification.level == NotificationLevel::Warning + && notification.surface == Some(7) + ) + })); + } + #[test] fn ordered_resize_replay_recovers_from_stale_initial_replay() { let mut server = Terminal::new(12, 3, 100, Callbacks::default()).unwrap(); From feef345d938576e436919b01d8cf610819508838 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:18:33 -0700 Subject: [PATCH 044/103] Preserve deferred TUI interaction intent --- cmux-tui/crates/cmux-tui/src/app.rs | 253 ++++++++++++++---- .../crates/cmux-tui/src/session/remote.rs | 48 +++- 2 files changed, 243 insertions(+), 58 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index da0f665f7a13..5aa18171e9dc 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -804,9 +804,10 @@ impl OrderedSession { let operation = move || { let mut claim = claim; let status = session.sidebar_plugin(size, relaunch); + let synchronized = status.surface_id.is_some() && status.error.is_none(); let _ = events.send(AppEvent::SidebarPluginUpdated { status, relaunch }); committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - if let Some(claim) = &mut claim { + if synchronized && let Some(claim) = &mut claim { claim.mark_applied(); } pending.settle(SessionMutationOutcome::Success { tree: None }); @@ -1327,6 +1328,12 @@ enum PtyMouseReleaseCapture { Failed, } +#[derive(Clone)] +struct DeferredInput { + event: Event, + destination: Option, +} + pub struct App { pub session: OrderedSession, pub config: Config, @@ -1348,6 +1355,7 @@ pub struct App { pub sidebar_plugin_surface: Option, pub sidebar_plugin_error: Option, pub sidebar_plugin_retry_after_ms: Option, + sidebar_plugin_retry_at: Option, sidebar_width_override: Option, /// Pane region of the current frame (screen minus sidebar/status). pub content_area: Rect, @@ -1375,7 +1383,7 @@ pub struct App { /// never run on the event-loop thread (see `browser_input`). browser_input: BrowserInputDispatcher, pty_input: PtyInputDispatcher, - deferred_input: VecDeque, + deferred_input: VecDeque, routing_refresh_pending: bool, routing_refresh_retries_remaining: u8, background_refresh_attempts: u8, @@ -1648,6 +1656,7 @@ pub fn run( sidebar_plugin_surface: None, sidebar_plugin_error: None, sidebar_plugin_retry_after_ms: None, + sidebar_plugin_retry_at: None, sidebar_width_override: None, content_area: Rect::default(), hits: Vec::new(), @@ -1766,6 +1775,7 @@ impl App { if self.browser_input.resize_retry_due() { self.reassert_visible_surface_sizes(); } + self.retry_sidebar_plugin_if_due(); self.retry_background_refresh_if_due(); self.render_action(terminal, action)?; if self.routing_refresh_pending { @@ -1810,7 +1820,14 @@ impl App { break; } let Some(input) = self.deferred_input.pop_front() else { break }; - action = action.merge(self.handle(AppEvent::Input(input))?); + if self.input_destination(&input.event) != input.destination { + self.status_message = Some( + "Deferred input was discarded because its destination changed".to_string(), + ); + action = action.merge(RenderAction::Draw); + continue; + } + action = action.merge(self.handle(AppEvent::Input(input.event))?); } Ok(action) } @@ -2198,6 +2215,7 @@ impl App { self.sidebar_plugin_surface = None; self.sidebar_plugin_error = None; self.sidebar_plugin_retry_after_ms = None; + self.sidebar_plugin_retry_at = None; self.sidebar_focused = false; self.sidebar_focus_pending = false; return false; @@ -2213,12 +2231,22 @@ impl App { true } + fn retry_sidebar_plugin_if_due(&mut self) { + if self.sidebar_plugin_retry_at.is_none_or(|retry_at| Instant::now() < retry_at) { + return; + } + self.sidebar_plugin_retry_at = None; + self.sidebar_plugin_retry_after_ms = None; + let _ = self.sync_sidebar_plugin(false); + } + fn apply_sidebar_plugin_status(&mut self, status: SidebarPluginSurface, relaunch: bool) { if !self.sidebar_visible || self.config.sidebar.plugin.is_none() { self.session.invalidate_sidebar_plugin_sync(); self.sidebar_plugin_surface = None; self.sidebar_plugin_error = None; self.sidebar_plugin_retry_after_ms = None; + self.sidebar_plugin_retry_at = None; self.sidebar_focused = false; self.sidebar_focus_pending = false; return; @@ -2227,6 +2255,8 @@ impl App { self.sidebar_plugin_surface = status.surface_id; self.sidebar_plugin_error = status.error; self.sidebar_plugin_retry_after_ms = status.retry_after_ms; + self.sidebar_plugin_retry_at = + status.retry_after_ms.map(|delay_ms| Instant::now() + Duration::from_millis(delay_ms)); if had_surface && self.sidebar_plugin_surface.is_none() { self.session.invalidate_sidebar_plugin_sync(); } @@ -2612,23 +2642,34 @@ impl App { } fn defer_input(&mut self, input: Event) -> RenderAction { + let destination = self.input_destination(&input); let replace_motion = match (&input, self.deferred_input.back()) { ( Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), - Some(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. })), - ) => true, + Some(DeferredInput { + event: Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), + destination: previous_destination, + }), + ) => *previous_destination == destination, ( Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(button), .. }), - Some(Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(previous), .. })), - ) => button == previous, + Some(DeferredInput { + event: Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(previous), .. }), + destination: previous_destination, + }), + ) => button == previous && *previous_destination == destination, _ => false, }; if replace_motion { - *self.deferred_input.back_mut().unwrap() = input; + *self.deferred_input.back_mut().unwrap() = DeferredInput { event: input, destination }; return RenderAction::None; } let input_bytes = deferred_input_bytes(&input); - let mut queued_bytes = self.deferred_input.iter().map(deferred_input_bytes).sum::(); + let mut queued_bytes = self + .deferred_input + .iter() + .map(|input| deferred_input_bytes(&input.event)) + .sum::(); let prioritize_release = matches!(&input, Event::Mouse(MouseEvent { kind: MouseEventKind::Up(_), .. })); while self.deferred_input.len() >= DEFERRED_INPUT_CAPACITY @@ -2641,12 +2682,27 @@ impl App { return RenderAction::Draw; } let Some(removed) = self.deferred_input.pop_front() else { break }; - queued_bytes = queued_bytes.saturating_sub(deferred_input_bytes(&removed)); + queued_bytes = queued_bytes.saturating_sub(deferred_input_bytes(&removed.event)); } - self.deferred_input.push_back(input); + self.deferred_input.push_back(DeferredInput { event: input, destination }); RenderAction::None } + fn input_destination(&self, input: &Event) -> Option { + match input { + Event::Key(_) | Event::Paste(_) + if self.prompt.is_none() && self.omnibar.is_none() && !self.sidebar_focused => + { + self.active_surface() + } + Event::Mouse(mouse) => self + .pane_area_at(mouse.column, mouse.row) + .filter(|area| area.content.contains(mouse.column, mouse.row)) + .map(|area| area.surface), + _ => None, + } + } + fn active_pane(&self) -> Option { self.tree.active_screen().map(|screen| screen.active_pane) } @@ -2691,7 +2747,7 @@ impl App { fn retry_deferred_surface_attach(&mut self) { let surface = - self.deferred_input.iter().find_map(|input| self.missing_input_surface(input)); + self.deferred_input.iter().find_map(|input| self.missing_input_surface(&input.event)); if let Some(surface) = surface { self.queue_surface_attach(surface); } @@ -4022,15 +4078,22 @@ impl App { let (surface, reservation_id, fallback, content, button) = (*surface, *reservation_id, release_bytes.clone(), *content, *button); let content = self.current_pty_content(surface).unwrap_or(content); - let release_bytes = - match self.capture_pty_mouse_release(surface, content, x, y, button, modifiers) { - PtyMouseReleaseCapture::Bytes(bytes) => bytes, - PtyMouseReleaseCapture::NotReported | PtyMouseReleaseCapture::Failed => fallback, - }; - let delivered = self.enqueue_pty_release(surface, reservation_id, release_bytes); + let release = self.capture_pty_mouse_release(surface, content, x, y, button, modifiers); self.drag = None; - if !delivered { - self.pty_input.cancel_release_reservation(reservation_id); + match release { + PtyMouseReleaseCapture::Bytes(bytes) => { + if !self.enqueue_pty_release(surface, reservation_id, bytes) { + self.pty_input.cancel_release_reservation(reservation_id); + } + } + PtyMouseReleaseCapture::Failed => { + if !self.enqueue_pty_release(surface, reservation_id, fallback) { + self.pty_input.cancel_release_reservation(reservation_id); + } + } + PtyMouseReleaseCapture::NotReported => { + self.pty_input.cancel_release_reservation(reservation_id); + } } true } @@ -4120,16 +4183,23 @@ impl App { *modifiers, ); let content = self.current_pty_content(surface).unwrap_or(content); - let release_bytes = match self - .capture_pty_mouse_release(surface, content, position.0, position.1, button, modifiers) - { - PtyMouseReleaseCapture::Bytes(bytes) => bytes, - PtyMouseReleaseCapture::NotReported | PtyMouseReleaseCapture::Failed => fallback, - }; - let delivered = self.enqueue_pty_release(surface, reservation_id, release_bytes); + let release = self + .capture_pty_mouse_release(surface, content, position.0, position.1, button, modifiers); self.drag = None; - if !delivered { - self.pty_input.cancel_release_reservation(reservation_id); + match release { + PtyMouseReleaseCapture::Bytes(bytes) => { + if !self.enqueue_pty_release(surface, reservation_id, bytes) { + self.pty_input.cancel_release_reservation(reservation_id); + } + } + PtyMouseReleaseCapture::Failed => { + if !self.enqueue_pty_release(surface, reservation_id, fallback) { + self.pty_input.cancel_release_reservation(reservation_id); + } + } + PtyMouseReleaseCapture::NotReported => { + self.pty_input.cancel_release_reservation(reservation_id); + } } } @@ -5359,15 +5429,15 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, AppEvent, Drag, MuxTitleIngress, OrderedSession, PaneArea, RenderAction, - SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, + App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, + RenderAction, SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::Ordering; use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; - use std::time::Duration; + use std::time::{Duration, Instant}; use cmux_tui_core::{ BrowserStatus, Mux, MuxEvent, Node, Rect, SplitDir, SurfaceId, SurfaceKind, SurfaceOptions, @@ -5471,8 +5541,9 @@ mod tests { assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Left, .. }))); surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002l\x1b[?1006l")); + app.encode_buf.clear(); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); + assert!(app.encode_buf.is_empty()); assert!(app.drag.is_none()); surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); @@ -5815,6 +5886,35 @@ mod tests { assert!(!app.session.has_pending_mutations()); } + #[test] + fn deferred_input_is_discarded_when_its_destination_changes() { + let mux = Mux::new("deferred-destination-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, None).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + app.session.pending_mutations.store(1, Ordering::Release); + + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + assert_eq!( + app.deferred_input.front().and_then(|input| input.destination), + Some(surface.id) + ); + + app.session.pending_mutations.store(0, Ordering::Release); + app.replace_tree(notify_tree(surface.id + 1, false)); + app.replay_deferred_input().unwrap(); + + assert!(app.deferred_input.is_empty()); + assert_eq!( + app.status_message.as_deref(), + Some("Deferred input was discarded because its destination changed") + ); + } + #[test] fn identity_refresh_completion_consumes_coalesced_background_refresh() { let mux = Mux::new("identity-refresh-dirty-test", SurfaceOptions::default()); @@ -6092,14 +6192,14 @@ mod tests { let elapsed = super::SurfaceSyncFailureState { attempts: first.attempts, - retry_after: Some(std::time::Instant::now() - Duration::from_millis(1)), + retry_after: Some(Instant::now() - Duration::from_millis(1)), sticky_until_reconnect: false, }; assert!(!super::surface_sync_failure_blocks(elapsed)); let capped = (0..10) .fold(elapsed, |state, _| super::next_surface_sync_failure(Some(state), true, false)); assert_eq!(capped.attempts, 6); - assert!(capped.retry_after.unwrap() <= std::time::Instant::now() + Duration::from_secs(30)); + assert!(capped.retry_after.unwrap() <= Instant::now() + Duration::from_secs(30)); } #[test] @@ -6109,8 +6209,10 @@ mod tests { let newer = notify_tree(22, false); let older = notify_tree(11, false); app.session.pending_mutations.store(1, Ordering::Release); - app.deferred_input - .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + app.deferred_input.push_back(DeferredInput { + event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + destination: None, + }); app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(newer.clone()) }) .unwrap(); @@ -6211,11 +6313,13 @@ mod tests { #[test] fn sidebar_plugin_sync_is_deduped_after_it_is_applied() { let mux = Mux::new("sidebar-plugin-sync-test", SurfaceOptions::default()); - let (mut app, events) = test_app_with_events(Session::Local(mux)); - app.config.sidebar.plugin = Some(cmux_tui_core::SidebarPluginOptions { - command: vec!["unused".to_string()], + let plugin = cmux_tui_core::SidebarPluginOptions { + command: vec!["/bin/cat".to_string()], cwd: None, - }); + }; + mux.configure_sidebar_plugin(Some(plugin.clone())); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.config.sidebar.plugin = Some(plugin); for _ in 0..1_000 { app.session.sidebar_plugin((24, 8), false); @@ -6236,14 +6340,46 @@ mod tests { assert_eq!(app.session.sidebar_plugin_sync.lock().unwrap().applied, Some(((24, 8), 0, 0))); } + #[test] + fn failed_sidebar_plugin_status_schedules_passive_retry() { + let mux = Mux::new("sidebar-plugin-retry-test", SurfaceOptions::default()); + let plugin = cmux_tui_core::SidebarPluginOptions { + command: vec!["/definitely/missing/cmux-sidebar-plugin".to_string()], + cwd: None, + }; + mux.configure_sidebar_plugin(Some(plugin.clone())); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.config.sidebar.plugin = Some(plugin); + app.sidebar_width = 12; + app.content_area.height = 8; + + app.session.sidebar_plugin((11, 9), false); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + + assert!(app.sidebar_plugin_error.is_some()); + assert!(app.sidebar_plugin_retry_at.is_some()); + assert!(app.session.sidebar_plugin_sync.lock().unwrap().applied.is_none()); + + app.sidebar_plugin_retry_at = Some(Instant::now() - Duration::from_millis(1)); + app.retry_sidebar_plugin_if_due(); + + assert!(app.sidebar_plugin_retry_at.is_none()); + assert!(app.session.has_pending_mutations()); + assert!(app.session.sidebar_plugin_sync.lock().unwrap().applied.is_none()); + } + #[test] fn hiding_then_showing_sidebar_rehydrates_same_size_plugin_status() { let mux = Mux::new("sidebar-plugin-hide-show-test", SurfaceOptions::default()); - let (mut app, events) = test_app_with_events(Session::Local(mux)); - app.config.sidebar.plugin = Some(cmux_tui_core::SidebarPluginOptions { - command: vec!["unused".to_string()], + let plugin = cmux_tui_core::SidebarPluginOptions { + command: vec!["/bin/cat".to_string()], cwd: None, - }); + }; + mux.configure_sidebar_plugin(Some(plugin.clone())); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.config.sidebar.plugin = Some(plugin); app.sidebar_width = 12; app.content_area.height = 8; app.session.sidebar_plugin((11, 9), false); @@ -6268,8 +6404,10 @@ mod tests { let surface = 77; app.session.remote = true; app.replace_tree(notify_tree(surface, false)); - app.deferred_input - .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + app.deferred_input.push_back(DeferredInput { + event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + destination: Some(surface), + }); assert_eq!( app.handle(AppEvent::Mux(MuxEvent::SurfaceExited(surface))).unwrap(), @@ -6365,8 +6503,10 @@ mod tests { let mux = Mux::new("mutation-timeout-test", SurfaceOptions::default()); let (mut app, events) = test_app_with_events(Session::Local(mux)); app.session.remote = true; - app.deferred_input - .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + app.deferred_input.push_back(DeferredInput { + event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + destination: None, + }); app.session .enqueue("timed out mutation", |_| Err(crate::session::test_remote_timeout_error())); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); @@ -6388,8 +6528,10 @@ mod tests { let mux = Mux::new("coalesced-timeout-test", SurfaceOptions::default()); let (mut app, events) = test_app_with_events(Session::Local(mux)); app.session.remote = true; - app.deferred_input - .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + app.deferred_input.push_back(DeferredInput { + event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + destination: None, + }); for (label, key) in [ ("resize PTY surface", ("surface resize", 7)), @@ -6631,7 +6773,7 @@ mod tests { assert_eq!(app.deferred_input.len(), 1); assert!(matches!( app.deferred_input.front(), - Some(Event::Mouse(MouseEvent { column: 9, .. })) + Some(DeferredInput { event: Event::Mouse(MouseEvent { column: 9, .. }), .. }) )); release_tx.send(()).unwrap(); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); @@ -6722,8 +6864,10 @@ mod tests { let mux = Mux::new("committed-stale-refresh-test", SurfaceOptions::default()); let mut app = test_app(Session::Local(mux)); app.session.pending_mutations.store(1, Ordering::Release); - app.deferred_input - .push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + app.deferred_input.push_back(DeferredInput { + event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + destination: None, + }); app.handle(AppEvent::SessionMutationSettled( super::SessionMutationOutcome::CommittedTreeStale { @@ -7021,6 +7165,7 @@ mod tests { sidebar_plugin_surface: None, sidebar_plugin_error: None, sidebar_plugin_retry_after_ms: None, + sidebar_plugin_retry_at: None, sidebar_width_override: None, content_area: Rect::default(), hits: Vec::new(), diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index abd146276e5d..e56fb63c0cdb 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -362,6 +362,10 @@ impl RemoteSession { self.subscribers.emit(event); } + fn invalidate_tree_once(&self) -> bool { + !self.tree_stale.swap(true, Ordering::AcqRel) + } + pub fn subscribe(&self) -> MuxEventReceiver { self.subscribers.subscribe() } @@ -491,14 +495,14 @@ impl RemoteSession { if let Some(id) = surface_id() { if let Some(title) = value.get("title").and_then(Value::as_str) { let updated = self.tree.lock().unwrap().update_title(id, title.to_string()); - if !updated { - self.tree_stale.store(true, Ordering::Release); + if !updated && self.invalidate_tree_once() { self.emit(MuxEvent::TreeChanged); } self.emit(MuxEvent::TitleChanged { surface: id, title: title.to_string() }); } else { - self.tree_stale.store(true, Ordering::Release); - self.emit(MuxEvent::TreeChanged); + if self.invalidate_tree_once() { + self.emit(MuxEvent::TreeChanged); + } } } } @@ -949,6 +953,42 @@ mod tests { assert!(!session.tree_is_stale()); } + #[cfg(unix)] + #[test] + fn unknown_surface_title_churn_emits_one_tree_invalidation_per_stale_transition() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + session.tree_stale.store(false, Ordering::Release); + + for index in 0..1_000 { + session.handle_line(json!({ + "event": "title-changed", + "surface": 77, + "title": format!("unknown-{index}"), + })); + } + + let received = events.try_iter().collect::>(); + assert_eq!( + received.iter().filter(|event| matches!(event, MuxEvent::TreeChanged)).count(), + 1 + ); + assert!( + received + .iter() + .any(|event| matches!(event, MuxEvent::TitleChanged { surface: 77, .. })) + ); + + assert!(session.take_tree_stale()); + session.handle_line(json!({ + "event": "title-changed", + "surface": 77, + "title": "after-refresh", + })); + assert!(events.try_iter().any(|event| matches!(event, MuxEvent::TreeChanged))); + } + #[test] fn indexed_title_update_changes_only_the_addressed_surface() { let mut cache = RemoteTreeCache::default(); From 1cdd21ba9f2c9a9696119c75d949f293f855e12c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:38:15 -0700 Subject: [PATCH 045/103] Bound TUI routing and subscriber recovery --- .../crates/cmux-tui-core/src/event_bus.rs | 135 +++++++++++++++--- cmux-tui/crates/cmux-tui/src/app.rs | 124 +++++++++++++--- 2 files changed, 220 insertions(+), 39 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs index e906e01da4d8..4bad1a1876e8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -1,4 +1,4 @@ -//! Per-subscriber mux event delivery with bounded latest-title state. +//! Per-subscriber mux event delivery with bounded coalesced state. use std::collections::{BTreeMap, HashMap, VecDeque}; use std::sync::mpsc::{RecvError, RecvTimeoutError, TryRecvError}; @@ -7,6 +7,9 @@ use std::time::{Duration, Instant}; use crate::{MuxEvent, SurfaceId}; +// A subscriber may drain accepted events after crossing this limit, then observes a disconnect. +const MAX_PENDING_EVENTS: usize = 4_096; + #[derive(Default)] pub struct MuxEventBroadcaster { subscribers: Mutex>>, @@ -28,6 +31,8 @@ struct MuxEventMailboxState { events: VecDeque<(u128, MuxEvent)>, title_sequences: HashMap, titles: BTreeMap, + surface_output_sequences: HashMap, + surface_outputs: BTreeMap, closed: bool, } @@ -42,8 +47,7 @@ impl MuxEventBroadcaster { let mut subscribers = self.subscribers.lock().unwrap(); subscribers.retain(|subscriber| { let Some(mailbox) = subscriber.upgrade() else { return false }; - mailbox.push(event.clone()); - true + mailbox.push(event.clone()) }); } } @@ -59,29 +63,57 @@ impl Drop for MuxEventBroadcaster { } impl MuxEventMailbox { - fn push(&self, event: MuxEvent) { + fn push(&self, event: MuxEvent) -> bool { let mut state = self.state.lock().unwrap(); if state.closed { - return; + return false; } let sequence = state.next_sequence; state.next_sequence = state.next_sequence.saturating_add(1); match event { MuxEvent::TitleChanged { surface, title } => { - if let Some(previous) = state.title_sequences.insert(surface, sequence) { + if let Some(previous) = state.title_sequences.get(&surface).copied() { state.titles.remove(&previous); + } else if !state.reserve_pending_slot() { + self.changed.notify_all(); + return false; } + state.title_sequences.insert(surface, sequence); state.titles.insert(sequence, (surface, title)); } + MuxEvent::SurfaceOutput(surface) => { + if let Some(previous) = state.surface_output_sequences.get(&surface).copied() { + state.surface_outputs.remove(&previous); + } else if !state.reserve_pending_slot() { + self.changed.notify_all(); + return false; + } + state.surface_output_sequences.insert(surface, sequence); + state.surface_outputs.insert(sequence, surface); + } MuxEvent::SurfaceExited(surface) => { if let Some(previous) = state.title_sequences.remove(&surface) { state.titles.remove(&previous); } + if let Some(previous) = state.surface_output_sequences.remove(&surface) { + state.surface_outputs.remove(&previous); + } + if !state.reserve_pending_slot() { + self.changed.notify_all(); + return false; + } state.events.push_back((sequence, MuxEvent::SurfaceExited(surface))); } - event => state.events.push_back((sequence, event)), + event => { + if !state.reserve_pending_slot() { + self.changed.notify_all(); + return false; + } + state.events.push_back((sequence, event)); + } } self.changed.notify_one(); + true } fn close(&self) { @@ -91,20 +123,34 @@ impl MuxEventMailbox { } impl MuxEventMailboxState { + fn reserve_pending_slot(&mut self) -> bool { + if self.events.len() + self.titles.len() + self.surface_outputs.len() < MAX_PENDING_EVENTS { + true + } else { + self.closed = true; + false + } + } + fn pop(&mut self) -> Option { let event_sequence = self.events.front().map(|(sequence, _)| *sequence); let title_sequence = self.titles.first_key_value().map(|(sequence, _)| *sequence); - match (event_sequence, title_sequence) { - (Some(event), Some(title)) if event <= title => { - self.events.pop_front().map(|(_, event)| event) - } - (Some(_), Some(_)) | (None, Some(_)) => { - let (_, (surface, title)) = self.titles.pop_first()?; - self.title_sequences.remove(&surface); - Some(MuxEvent::TitleChanged { surface, title }) - } - (Some(_), None) => self.events.pop_front().map(|(_, event)| event), - (None, None) => None, + let surface_output_sequence = + self.surface_outputs.first_key_value().map(|(sequence, _)| *sequence); + let next_sequence = [event_sequence, title_sequence, surface_output_sequence] + .into_iter() + .flatten() + .min()?; + if event_sequence == Some(next_sequence) { + self.events.pop_front().map(|(_, event)| event) + } else if title_sequence == Some(next_sequence) { + let (_, (surface, title)) = self.titles.pop_first()?; + self.title_sequences.remove(&surface); + Some(MuxEvent::TitleChanged { surface, title }) + } else { + let (_, surface) = self.surface_outputs.pop_first()?; + self.surface_output_sequences.remove(&surface); + Some(MuxEvent::SurfaceOutput(surface)) } } } @@ -227,4 +273,57 @@ mod tests { assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(4))); assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); } + + #[test] + fn surface_output_churn_keeps_one_latest_position_per_surface() { + let broadcaster = MuxEventBroadcaster::default(); + let events = broadcaster.subscribe(); + + broadcaster.emit(MuxEvent::SurfaceOutput(1)); + broadcaster.emit(MuxEvent::Bell(2)); + for _ in 0..10_000 { + broadcaster.emit(MuxEvent::SurfaceOutput(1)); + broadcaster.emit(MuxEvent::SurfaceOutput(3)); + } + broadcaster.emit(MuxEvent::SurfaceExited(4)); + + assert!(matches!(events.recv().unwrap(), MuxEvent::Bell(2))); + assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceOutput(1))); + assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceOutput(3))); + assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(4))); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + } + + #[test] + fn surface_exit_discards_its_pending_output() { + let broadcaster = MuxEventBroadcaster::default(); + let events = broadcaster.subscribe(); + + broadcaster.emit(MuxEvent::SurfaceOutput(4)); + broadcaster.emit(MuxEvent::SurfaceExited(4)); + + assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(4))); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + } + + #[test] + fn slow_subscriber_disconnects_at_the_hard_bound() { + let broadcaster = MuxEventBroadcaster::default(); + let fast = broadcaster.subscribe(); + let slow = broadcaster.subscribe(); + + for surface in 0..=MAX_PENDING_EVENTS { + broadcaster.emit(MuxEvent::Bell(surface as SurfaceId)); + assert!( + matches!(fast.recv().unwrap(), MuxEvent::Bell(id) if id == surface as SurfaceId) + ); + } + + let slow_events = slow.try_iter().collect::>(); + assert_eq!(slow_events.len(), MAX_PENDING_EVENTS); + assert!(matches!(slow.try_recv(), Err(TryRecvError::Disconnected))); + + broadcaster.emit(MuxEvent::Bell(9_999)); + assert!(matches!(fast.recv().unwrap(), MuxEvent::Bell(9_999))); + } } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5aa18171e9dc..5548002c0d37 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -299,6 +299,7 @@ pub struct OrderedSession { remote: bool, pending_mutations: Arc, committed_mutation_generation: Arc, + routing_mutation_generation: Arc, remote_refresh_queued: Arc, remote_background_dirty: Arc, remote_refresh_sequence: Arc, @@ -322,6 +323,7 @@ impl OrderedSession { remote, pending_mutations: Arc::new(AtomicUsize::new(0)), committed_mutation_generation: Arc::new(AtomicU64::new(0)), + routing_mutation_generation: Arc::new(AtomicU64::new(0)), remote_refresh_queued: Arc::new(AtomicBool::new(false)), remote_background_dirty: Arc::new(AtomicBool::new(false)), remote_refresh_sequence: Arc::new(AtomicU64::new(0)), @@ -487,6 +489,10 @@ impl OrderedSession { self.pending_mutations.load(Ordering::Acquire) > 0 } + fn routing_mutation_generation(&self) -> u64 { + self.routing_mutation_generation.load(Ordering::Acquire) + } + fn settle_pending_mutation(&self) { let result = self.pending_mutations.fetch_update(Ordering::AcqRel, Ordering::Acquire, |pending| { @@ -584,7 +590,18 @@ impl OrderedSession { label: &'static str, operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, ) { - self.enqueue_with_completion(label, move |session| { + self.enqueue_with_completion(label, false, move |session| { + operation(session)?; + Ok(None) + }); + } + + fn enqueue_routing( + &self, + label: &'static str, + operation: impl FnOnce(Session) -> anyhow::Result<()> + Send + 'static, + ) { + self.enqueue_with_completion(label, true, move |session| { operation(session)?; Ok(None) }); @@ -593,6 +610,7 @@ impl OrderedSession { fn enqueue_with_completion( &self, label: &'static str, + routing: bool, operation: impl FnOnce(Session) -> anyhow::Result> + Send + 'static, @@ -601,6 +619,7 @@ impl OrderedSession { let pending = self.pending_mutation(); let remote = self.remote; let committed_mutation_generation = self.committed_mutation_generation.clone(); + let routing_mutation_generation = self.routing_mutation_generation.clone(); self.operations.enqueue_session_mutation(label, self.remote, move || { let completion = match operation(session.clone()) { Ok(completion) => completion, @@ -616,6 +635,9 @@ impl OrderedSession { }; let mutation_generation = committed_mutation_generation.fetch_add(1, Ordering::AcqRel) + 1; + if routing { + routing_mutation_generation.fetch_add(1, Ordering::AcqRel); + } let completion = completion.map(|action| SessionCompletion { mutation_generation, action }); session.invalidate_remote_tree(); @@ -840,7 +862,7 @@ impl OrderedSession { } pub fn new_tab(&self, pane: Option, size: Option<(u16, u16)>) -> anyhow::Result<()> { - self.enqueue("create tab", move |session| session.new_tab(pane, size)); + self.enqueue_routing("create tab", move |session| session.new_tab(pane, size)); Ok(()) } @@ -850,7 +872,7 @@ impl OrderedSession { pane: Option, size: Option<(u16, u16)>, ) -> anyhow::Result<()> { - self.enqueue_with_completion("create browser tab", move |session| { + self.enqueue_with_completion("create browser tab", true, move |session| { let surface = session.new_browser_tab(url, pane, size)?; Ok(Some(SessionCompletionAction::BrowserTabCreated { surface })) }); @@ -864,17 +886,17 @@ impl OrderedSession { } pub fn new_workspace(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { - self.enqueue("create workspace", move |session| session.new_workspace(size)); + self.enqueue_routing("create workspace", move |session| session.new_workspace(size)); Ok(()) } pub fn new_screen(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { - self.enqueue("create screen", move |session| session.new_screen(size)); + self.enqueue_routing("create screen", move |session| session.new_screen(size)); Ok(()) } pub fn close_screen(&self, screen: cmux_tui_core::ScreenId) { - self.enqueue("close screen", move |session| session.close_screen(screen)); + self.enqueue_routing("close screen", move |session| session.close_screen(screen)); } pub fn rename_screen(&self, screen: cmux_tui_core::ScreenId, name: String) { @@ -882,7 +904,7 @@ impl OrderedSession { } pub fn select_screen(&self, index: Option, delta: Option) { - self.enqueue("select screen", move |session| session.select_screen(index, delta)); + self.enqueue_routing("select screen", move |session| session.select_screen(index, delta)); } pub fn zoom_pane(&self, pane: Option) { @@ -895,7 +917,7 @@ impl OrderedSession { dir: SplitDir, size: Option<(u16, u16)>, ) -> anyhow::Result<()> { - self.enqueue("split pane", move |session| session.split(pane, dir, size)); + self.enqueue_routing("split pane", move |session| session.split(pane, dir, size)); Ok(()) } @@ -921,11 +943,11 @@ impl OrderedSession { } pub fn close_surface(&self, surface: SurfaceId) { - self.enqueue("close tab", move |session| session.close_surface(surface)); + self.enqueue_routing("close tab", move |session| session.close_surface(surface)); } pub fn close_pane(&self, pane: PaneId) { - self.enqueue("close pane", move |session| session.close_pane(pane)); + self.enqueue_routing("close pane", move |session| session.close_pane(pane)); } pub fn swap_pane(&self, pane: PaneId, target: PaneId) { @@ -933,7 +955,7 @@ impl OrderedSession { } pub fn close_workspace(&self, workspace: WorkspaceId) { - self.enqueue("close workspace", move |session| session.close_workspace(workspace)); + self.enqueue_routing("close workspace", move |session| session.close_workspace(workspace)); } pub fn rename_surface(&self, surface: SurfaceId, name: String) { @@ -945,19 +967,21 @@ impl OrderedSession { } pub fn focus_pane(&self, pane: PaneId) { - self.enqueue("focus pane", move |session| session.focus_pane(pane)); + self.enqueue_routing("focus pane", move |session| session.focus_pane(pane)); } pub fn select_tab(&self, pane: Option, index: Option, delta: Option) { - self.enqueue("select tab", move |session| session.select_tab(pane, index, delta)); + self.enqueue_routing("select tab", move |session| session.select_tab(pane, index, delta)); } pub fn select_workspace(&self, index: Option, delta: Option) { - self.enqueue("select workspace", move |session| session.select_workspace(index, delta)); + self.enqueue_routing("select workspace", move |session| { + session.select_workspace(index, delta) + }); } pub fn move_tab(&self, surface: SurfaceId, pane: PaneId, index: usize) { - self.enqueue("move tab", move |session| session.move_tab(surface, pane, index)); + self.enqueue_routing("move tab", move |session| session.move_tab(surface, pane, index)); } pub fn move_workspace(&self, workspace: WorkspaceId, index: usize) { @@ -1332,6 +1356,7 @@ enum PtyMouseReleaseCapture { struct DeferredInput { event: Event, destination: Option, + routing_generation: u64, } pub struct App { @@ -1820,7 +1845,9 @@ impl App { break; } let Some(input) = self.deferred_input.pop_front() else { break }; - if self.input_destination(&input.event) != input.destination { + let routing_advanced = + self.session.routing_mutation_generation() > input.routing_generation; + if !routing_advanced && self.input_destination(&input.event) != input.destination { self.status_message = Some( "Deferred input was discarded because its destination changed".to_string(), ); @@ -2224,6 +2251,9 @@ impl App { if rect.width == 0 || rect.height == 0 { return false; } + if !relaunch && self.sidebar_plugin_retry_at.is_some() { + return false; + } if relaunch && !self.prepare_pty_input_before_mutation() { return false; } @@ -2237,7 +2267,9 @@ impl App { } self.sidebar_plugin_retry_at = None; self.sidebar_plugin_retry_after_ms = None; - let _ = self.sync_sidebar_plugin(false); + if !self.sync_sidebar_plugin(true) { + self.sidebar_plugin_retry_at = Some(Instant::now() + Duration::from_millis(250)); + } } fn apply_sidebar_plugin_status(&mut self, status: SidebarPluginSurface, relaunch: bool) { @@ -2643,25 +2675,33 @@ impl App { fn defer_input(&mut self, input: Event) -> RenderAction { let destination = self.input_destination(&input); + let routing_generation = self.session.routing_mutation_generation(); let replace_motion = match (&input, self.deferred_input.back()) { ( Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), Some(DeferredInput { event: Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), destination: previous_destination, + routing_generation: previous_generation, }), - ) => *previous_destination == destination, + ) => *previous_destination == destination && *previous_generation == routing_generation, ( Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(button), .. }), Some(DeferredInput { event: Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(previous), .. }), destination: previous_destination, + routing_generation: previous_generation, }), - ) => button == previous && *previous_destination == destination, + ) => { + button == previous + && *previous_destination == destination + && *previous_generation == routing_generation + } _ => false, }; if replace_motion { - *self.deferred_input.back_mut().unwrap() = DeferredInput { event: input, destination }; + *self.deferred_input.back_mut().unwrap() = + DeferredInput { event: input, destination, routing_generation }; return RenderAction::None; } let input_bytes = deferred_input_bytes(&input); @@ -2684,7 +2724,11 @@ impl App { let Some(removed) = self.deferred_input.pop_front() else { break }; queued_bytes = queued_bytes.saturating_sub(deferred_input_bytes(&removed.event)); } - self.deferred_input.push_back(DeferredInput { event: input, destination }); + self.deferred_input.push_back(DeferredInput { + event: input, + destination, + routing_generation, + }); RenderAction::None } @@ -5915,6 +5959,37 @@ mod tests { ); } + #[test] + fn deferred_input_follows_a_committed_routing_mutation() { + let mux = Mux::new("deferred-routing-generation-test", SurfaceOptions::default()); + let first = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, None).unwrap(); + mux.select_tab(Some(pane), Some(0), None); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.replace_tree(app.session.tree()); + + app.session.select_tab(Some(pane), Some(1), None); + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + assert_eq!(app.deferred_input.front().and_then(|input| input.destination), Some(first.id)); + + let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); + app.handle(settled).unwrap(); + app.routing_refresh_pending = false; + app.replay_deferred_input().unwrap(); + + assert_eq!(app.active_surface(), Some(second.id)); + assert!(app.deferred_input.is_empty()); + assert_ne!( + app.status_message.as_deref(), + Some("Deferred input was discarded because its destination changed") + ); + } + #[test] fn identity_refresh_completion_consumes_coalesced_background_refresh() { let mux = Mux::new("identity-refresh-dirty-test", SurfaceOptions::default()); @@ -6212,6 +6287,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, + routing_generation: 0, }); app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(newer.clone()) }) @@ -6361,6 +6437,8 @@ mod tests { assert!(app.sidebar_plugin_error.is_some()); assert!(app.sidebar_plugin_retry_at.is_some()); assert!(app.session.sidebar_plugin_sync.lock().unwrap().applied.is_none()); + assert!(!app.sync_sidebar_plugin(false)); + assert!(!app.session.has_pending_mutations()); app.sidebar_plugin_retry_at = Some(Instant::now() - Duration::from_millis(1)); app.retry_sidebar_plugin_if_due(); @@ -6407,6 +6485,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: Some(surface), + routing_generation: 0, }); assert_eq!( @@ -6506,6 +6585,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, + routing_generation: 0, }); app.session .enqueue("timed out mutation", |_| Err(crate::session::test_remote_timeout_error())); @@ -6531,6 +6611,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, + routing_generation: 0, }); for (label, key) in [ @@ -6867,6 +6948,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, + routing_generation: 0, }); app.handle(AppEvent::SessionMutationSettled( From b783f161fcf495fcde399647cd9d07bad556f2c8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:06:02 -0700 Subject: [PATCH 046/103] Recover bounded TUI event streams --- .../crates/cmux-tui-core/src/event_bus.rs | 7 +++ cmux-tui/crates/cmux-tui-core/src/server.rs | 18 ++++++++ cmux-tui/crates/cmux-tui/src/app.rs | 28 ++++++++++- cmux-tui/crates/cmux-tui/src/browser_input.rs | 24 +++++++++- .../crates/cmux-tui/src/session/remote.rs | 46 +++++++++++++++++++ 5 files changed, 121 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs index 4bad1a1876e8..132914067450 100644 --- a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -34,6 +34,7 @@ struct MuxEventMailboxState { surface_output_sequences: HashMap, surface_outputs: BTreeMap, closed: bool, + overflowed: bool, } impl MuxEventBroadcaster { @@ -128,6 +129,7 @@ impl MuxEventMailboxState { true } else { self.closed = true; + self.overflowed = true; false } } @@ -156,6 +158,10 @@ impl MuxEventMailboxState { } impl MuxEventReceiver { + pub fn overflowed(&self) -> bool { + self.mailbox.state.lock().unwrap().overflowed + } + pub fn recv(&self) -> Result { let mut state = self.mailbox.state.lock().unwrap(); loop { @@ -321,6 +327,7 @@ mod tests { let slow_events = slow.try_iter().collect::>(); assert_eq!(slow_events.len(), MAX_PENDING_EVENTS); + assert!(slow.overflowed()); assert!(matches!(slow.try_recv(), Err(TryRecvError::Disconnected))); broadcaster.emit(MuxEvent::Bell(9_999)); diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 159ea174012c..46faa5e5fa9d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -868,6 +868,14 @@ fn spawn_attach_notification_stream( break; } } + if events.overflowed() { + let _ = writer.send(&json!({ + "event": "overflow", + "scope": "surface", + "surface": surface_id, + "error": "surface event subscriber fell behind; reattach the surface", + })); + } }) .map(|_| ()) } @@ -1373,6 +1381,9 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: break; } } + if events.overflowed() { + let _ = writer.send(&subscription_overflow_json()); + } })?; Ok(json!({})) } @@ -1485,6 +1496,13 @@ fn subscribed_event_json(event: &MuxEvent) -> Value { } } +fn subscription_overflow_json() -> Value { + json!({ + "event": "overflow", + "error": "subscriber fell behind; resubscribe to continue receiving events", + }) +} + /// Remove the socket file (call on clean shutdown). pub fn cleanup(path: &Path) { let _ = std::fs::remove_file(path); diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5548002c0d37..cca3ee8c9d33 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1798,7 +1798,14 @@ impl App { } self.retry_deferred_surface_attach(); if self.browser_input.resize_retry_due() { - self.reassert_visible_surface_sizes(); + let mut visible_surfaces = + self.pane_areas.iter().map(|area| area.surface).collect::>(); + if let Some(surface) = self.sidebar_plugin_surface { + visible_surfaces.insert(surface); + } + if self.browser_input.visible_resize_retry_due(&visible_surfaces) { + self.reassert_visible_surface_sizes(); + } } self.retry_sidebar_plugin_if_due(); self.retry_background_refresh_if_due(); @@ -2265,6 +2272,10 @@ impl App { if self.sidebar_plugin_retry_at.is_none_or(|retry_at| Instant::now() < retry_at) { return; } + if matches!(self.drag, Some(Drag::PtyMouse { .. })) { + self.sidebar_plugin_retry_at = Some(Instant::now() + Duration::from_millis(250)); + return; + } self.sidebar_plugin_retry_at = None; self.sidebar_plugin_retry_after_ms = None; if !self.sync_sidebar_plugin(true) { @@ -6440,6 +6451,21 @@ mod tests { assert!(!app.sync_sidebar_plugin(false)); assert!(!app.session.has_pending_mutations()); + app.sidebar_plugin_retry_at = Some(Instant::now() - Duration::from_millis(1)); + app.drag = Some(Drag::PtyMouse { + surface: 42, + reservation_id: 7, + release_bytes: PtyInputBytes::from_slice(b"release"), + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Left, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + app.retry_sidebar_plugin_if_due(); + assert!(matches!(app.drag, Some(Drag::PtyMouse { reservation_id: 7, .. }))); + assert!(!app.session.has_pending_mutations()); + + app.drag = None; app.sidebar_plugin_retry_at = Some(Instant::now() - Duration::from_millis(1)); app.retry_sidebar_plugin_if_due(); diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 8c872b2edac0..98682a129fb8 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -22,7 +22,7 @@ //! persistently failing CDP geometry update ahead of every input would stall the //! browser lane. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; @@ -237,6 +237,17 @@ impl BrowserInputDispatcher { self.failed_resizes.lock().unwrap().values().any(|failure| failure.retry_after <= now) } + /// Expired failures for hidden surfaces are retired. A later layout pass + /// will enqueue the current geometry if that surface becomes visible again. + pub fn visible_resize_retry_due(&self, visible_surfaces: &HashSet) -> bool { + let now = Instant::now(); + let mut failures = self.failed_resizes.lock().unwrap(); + failures.retain(|surface, failure| { + failure.retry_after > now || visible_surfaces.contains(surface) + }); + failures.values().any(|failure| failure.retry_after <= now) + } + pub fn forget_surface(&self, surface_id: SurfaceId) { // Surface-exit handling removes the ID from app topology before this // call, so no later app input can create a fresh lifetime for it. @@ -641,6 +652,17 @@ mod tests { dispatcher.failed_resizes.lock().unwrap().get_mut(&7).unwrap().retry_after = Instant::now() - Duration::from_millis(1); assert!(dispatcher.resize_retry_due()); + assert!(!dispatcher.visible_resize_retry_due(&HashSet::new())); + assert!(!dispatcher.resize_retry_due()); + dispatcher.failed_resizes.lock().unwrap().insert( + 7, + FailedBrowserResize { + desired: (100, 24), + attempts: 1, + retry_after: Instant::now() - Duration::from_millis(1), + }, + ); + assert!(dispatcher.visible_resize_retry_due(&HashSet::from([7]))); dispatcher.enqueue(resize_event(7, 100)); failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!(dispatcher.failed_resizes.lock().unwrap().get(&7).unwrap().attempts, 2); diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index e56fb63c0cdb..9984e65b4bc8 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -532,6 +532,29 @@ impl RemoteSession { surface: surface_id(), })); } + Some("overflow") => { + if value.get("scope").and_then(Value::as_str) == Some("surface") { + let surface = surface_id().map_or_else(String::new, |id| format!(" {id}")); + self.emit(MuxEvent::Status(format!( + "surface{surface} event stream overflowed; global events remain active" + ))); + return; + } + self.tree_stale.store(true, Ordering::Release); + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let mut line = serde_json::to_vec(&json!({"cmd": "subscribe", "id": id})) + .expect("subscribe recovery command is serializable"); + line.push(b'\n'); + let recovery = self.writer.lock().unwrap().write_all(&line); + let message = match recovery { + Ok(()) => "event subscription overflowed; resubscribing".to_string(), + Err(error) => { + format!("event subscription overflowed; resubscribe failed: {error}") + } + }; + self.emit(MuxEvent::Status(message)); + self.emit(MuxEvent::TreeChanged); + } Some("status") => { if let Some(message) = value.get("message").and_then(|v| v.as_str()) { self.emit(MuxEvent::Status(message.to_string())); @@ -1221,6 +1244,29 @@ mod tests { })); } + #[cfg(unix)] + #[test] + fn subscription_overflow_resubscribes_and_invalidates_tree() { + let (client, server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + session.tree_stale.store(false, Ordering::Release); + + session.handle_line(json!({ + "event": "overflow", + "error": "subscriber fell behind", + })); + + let mut line = String::new(); + BufReader::new(server).read_line(&mut line).unwrap(); + let command: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(command.get("cmd").and_then(Value::as_str), Some("subscribe")); + assert!(session.tree_is_stale()); + let received = events.try_iter().collect::>(); + assert!(received.iter().any(|event| matches!(event, MuxEvent::Status(_)))); + assert!(received.iter().any(|event| matches!(event, MuxEvent::TreeChanged))); + } + #[test] fn ordered_resize_replay_recovers_from_stale_initial_replay() { let mut server = Terminal::new(12, 3, 100, Callbacks::default()).unwrap(); From efe04eb0b1b747d6bda660dd8aa020c4f8aa39b1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:40:35 -0700 Subject: [PATCH 047/103] Complete TUI overflow recovery contract --- cmux-tui/bindings/conformance/fixtures.json | 11 + cmux-tui/bindings/conformance/runner.py | 4 + cmux-tui/bindings/go/events.go | 5 + cmux-tui/bindings/go/events_test.go | 12 + cmux-tui/bindings/go/types.go | 8 + .../bindings/java/src/com/cmux/CmuxEvent.java | 7 +- .../java/src/com/cmux/OverflowEvent.java | 8 + .../java/tests/com/cmux/JsonTest.java | 8 + cmux-tui/bindings/python/cmux/client.py | 4 + cmux-tui/bindings/python/tests/test_events.py | 15 + cmux-tui/bindings/rust/src/lib.rs | 27 ++ cmux-tui/bindings/typescript/src/types.ts | 10 + cmux-tui/crates/cmux-tui/src/app.rs | 303 ++++++++++++++---- cmux-tui/spec/events.md | 30 +- 14 files changed, 385 insertions(+), 67 deletions(-) create mode 100644 cmux-tui/bindings/java/src/com/cmux/OverflowEvent.java diff --git a/cmux-tui/bindings/conformance/fixtures.json b/cmux-tui/bindings/conformance/fixtures.json index 25f894201acf..e9ae4a76dc21 100644 --- a/cmux-tui/bindings/conformance/fixtures.json +++ b/cmux-tui/bindings/conformance/fixtures.json @@ -292,6 +292,17 @@ } ] }, + { + "name": "overflow-event-schema", + "steps": [ + { + "type": "parse_event", + "event": { "event": "overflow", "error": "subscriber fell behind", "scope": "surface", "surface": 7 }, + "expect": { "event": "overflow", "error": "subscriber fell behind", "scope": "surface", "surface": 7 }, + "match": "partial" + } + ] + }, { "name": "implemented-verbs-json", "requires": { diff --git a/cmux-tui/bindings/conformance/runner.py b/cmux-tui/bindings/conformance/runner.py index 59972817e0b8..7d7b928b9473 100755 --- a/cmux-tui/bindings/conformance/runner.py +++ b/cmux-tui/bindings/conformance/runner.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(PYTHON_BINDING)) from cmux import CommandError, CmuxClient, TimeoutError as CmuxTimeoutError # noqa: E402 +from cmux.client import _parse_event # noqa: E402 class FixtureFailure(Exception): @@ -163,6 +164,9 @@ def run_step( elif step_type == "wait_contains": request = substitute(step["request"], variables) wait_contains(client, request, step["path"], step["contains"], timeout_ms / 1000.0) + elif step_type == "parse_event": + event = dataclasses.asdict(_parse_event(substitute(step["event"], variables))) + assert_match(event, substitute(step.get("expect", {}), variables), step.get("match", "partial")) else: raise FixtureFailure(f"unknown step type {step_type}") diff --git a/cmux-tui/bindings/go/events.go b/cmux-tui/bindings/go/events.go index deaef73a09ca..cc376b4beba1 100644 --- a/cmux-tui/bindings/go/events.go +++ b/cmux-tui/bindings/go/events.go @@ -16,6 +16,11 @@ func parseEvent(raw map[string]any) Event { return TreeChangedEvent{} case "empty": return EmptyEvent{} + case "overflow": + var event OverflowEvent + if mustDecode(&event) { + return event + } case "surface-output", "surface-exited", "bell": var event SurfaceEvent if mustDecode(&event) { diff --git a/cmux-tui/bindings/go/events_test.go b/cmux-tui/bindings/go/events_test.go index 278329a34524..af3036b05346 100644 --- a/cmux-tui/bindings/go/events_test.go +++ b/cmux-tui/bindings/go/events_test.go @@ -40,3 +40,15 @@ func TestParseResizedAcceptsProtocolV6DataField(t *testing.T) { t.Fatalf("replay = %q, want protocol v6 data", event.Replay) } } + +func TestParseOverflowExposesRecoveryFields(t *testing.T) { + event, ok := parseEvent(map[string]any{ + "event": "overflow", + "error": "subscriber fell behind", + "scope": "surface", + "surface": float64(7), + }).(OverflowEvent) + if !ok || event.Scope == nil || *event.Scope != "surface" || event.Surface == nil || *event.Surface != 7 { + t.Fatalf("overflow event = %#v", event) + } +} diff --git a/cmux-tui/bindings/go/types.go b/cmux-tui/bindings/go/types.go index f789ba555b11..493a231c655c 100644 --- a/cmux-tui/bindings/go/types.go +++ b/cmux-tui/bindings/go/types.go @@ -124,6 +124,14 @@ type EmptyEvent struct{} func (EmptyEvent) EventName() string { return "empty" } +type OverflowEvent struct { + Error string `json:"error"` + Scope *string `json:"scope"` + Surface *uint64 `json:"surface"` +} + +func (OverflowEvent) EventName() string { return "overflow" } + type SurfaceEvent struct { Event string `json:"event"` Surface uint64 `json:"surface"` diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java index 0f04d82f8c6f..850e62e3955e 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java @@ -2,7 +2,7 @@ import java.util.Map; -public sealed interface CmuxEvent permits TreeChangedEvent, EmptyEvent, SurfaceEvent, TitleChangedEvent, SurfaceResizedEvent, VtStateEvent, OutputEvent, ResizedEvent, UnknownEvent { +public sealed interface CmuxEvent permits TreeChangedEvent, EmptyEvent, SurfaceEvent, TitleChangedEvent, SurfaceResizedEvent, VtStateEvent, OutputEvent, ResizedEvent, OverflowEvent, UnknownEvent { String event(); static CmuxEvent from(Map raw) { @@ -10,6 +10,11 @@ static CmuxEvent from(Map raw) { return switch (event) { case "tree-changed" -> new TreeChangedEvent(); case "empty" -> new EmptyEvent(); + case "overflow" -> new OverflowEvent( + CmuxClient.asString(raw.get("error")), + raw.get("scope") instanceof String scope ? scope : null, + raw.get("surface") instanceof Number ? CmuxClient.asLong(raw.get("surface")) : null + ); case "surface-output", "surface-exited", "bell", "detached" -> new SurfaceEvent(event, CmuxClient.asLong(raw.get("surface"))); case "title-changed" -> new TitleChangedEvent( diff --git a/cmux-tui/bindings/java/src/com/cmux/OverflowEvent.java b/cmux-tui/bindings/java/src/com/cmux/OverflowEvent.java new file mode 100644 index 000000000000..412bb13d3570 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/OverflowEvent.java @@ -0,0 +1,8 @@ +package com.cmux; + +public record OverflowEvent(String error, String scope, Long surface) implements CmuxEvent { + @Override + public String event() { + return "overflow"; + } +} diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index e8fc411fc5ec..712268f69d0c 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -50,6 +50,14 @@ public static void main(String[] args) { ) ); assertEquals("cmVwbGF5", legacyResize.replay(), "protocol v6 resize replay"); + OverflowEvent overflow = (OverflowEvent) CmuxEvent.from( + (Map) Json.parse( + "{\"event\":\"overflow\",\"error\":\"subscriber fell behind\",\"scope\":\"surface\",\"surface\":7}" + ) + ); + assertEquals("subscriber fell behind", overflow.error(), "overflow error"); + assertEquals("surface", overflow.scope(), "overflow scope"); + assertEquals(7L, overflow.surface(), "overflow surface"); } private static void assertReject(String input) { diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index d6a898f284f0..24fdca4fba96 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -152,6 +152,8 @@ class Event: offset: Optional[int] = None at_bottom: Optional[bool] = None title: Optional[str] = None + scope: Optional[str] = None + error: Optional[str] = None @property def bytes_data(self) -> Optional[bytes]: @@ -618,4 +620,6 @@ def _parse_event(value: Dict[str, Any]) -> Event: offset=value.get("offset"), at_bottom=value.get("at_bottom"), title=value.get("title"), + scope=value.get("scope"), + error=value.get("error"), ) diff --git a/cmux-tui/bindings/python/tests/test_events.py b/cmux-tui/bindings/python/tests/test_events.py index a2297c3431bd..b04bd0a4fbb3 100644 --- a/cmux-tui/bindings/python/tests/test_events.py +++ b/cmux-tui/bindings/python/tests/test_events.py @@ -26,6 +26,21 @@ def test_legacy_title_changed_keeps_title_optional(self) -> None: self.assertEqual(event.surface, 7) self.assertIsNone(event.title) + def test_overflow_exposes_recovery_fields(self) -> None: + event = _parse_event( + { + "event": "overflow", + "error": "subscriber fell behind", + "scope": "surface", + "surface": 7, + } + ) + + self.assertEqual(event.event, "overflow") + self.assertEqual(event.error, "subscriber fell behind") + self.assertEqual(event.scope, "surface") + self.assertEqual(event.surface, 7) + if __name__ == "__main__": unittest.main() diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index 31d1896ad973..509d32ab0492 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -214,6 +214,13 @@ pub struct ResizedEvent { pub replay: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct OverflowEvent { + pub error: String, + pub scope: Option, + pub surface: Option, +} + #[non_exhaustive] #[derive(Debug, Clone)] pub enum Event { @@ -229,6 +236,7 @@ pub enum Event { Output(OutputEvent), Resized(ResizedEvent), Detached(SurfaceEvent), + Overflow(OverflowEvent), Unknown(Value), } @@ -703,6 +711,7 @@ fn parse_event(value: Value) -> Event { "output" => parse_typed(value).map_or_else(Event::Unknown, Event::Output), "resized" => parse_typed(value).map_or_else(Event::Unknown, Event::Resized), "detached" => parse_typed(value).map_or_else(Event::Unknown, Event::Detached), + "overflow" => parse_typed(value).map_or_else(Event::Unknown, Event::Overflow), _ => Event::Unknown(value), } } @@ -769,4 +778,22 @@ mod tests { Event::Resized(ResizedEvent { surface: 7, replay, .. }) if replay == "cmVwbGF5" )); } + + #[test] + fn overflow_decodes_recovery_fields() { + let event = parse_event(serde_json::json!({ + "event": "overflow", + "error": "subscriber fell behind", + "scope": "surface", + "surface": 7, + })); + + assert!(matches!( + event, + Event::Overflow(OverflowEvent { error, scope, surface }) + if error == "subscriber fell behind" + && scope.as_deref() == Some("surface") + && surface == Some(7) + )); + } } diff --git a/cmux-tui/bindings/typescript/src/types.ts b/cmux-tui/bindings/typescript/src/types.ts index 60ca8d6c0237..699021f18f79 100644 --- a/cmux-tui/bindings/typescript/src/types.ts +++ b/cmux-tui/bindings/typescript/src/types.ts @@ -61,6 +61,7 @@ export type SubscribeEvent = | { event: "title-changed"; surface: number; title?: string } | { event: "bell"; surface: number } | { event: "empty" } + | OverflowEvent | UnknownEvent; export type AttachEvent = @@ -68,8 +69,17 @@ export type AttachEvent = | { event: "output"; surface: number; data: string } | { event: "resized"; surface: number; cols: number; rows: number; replay: string } | { event: "detached"; surface: number } + | OverflowEvent | UnknownEvent; +export interface OverflowEvent { + event: "overflow"; + error: string; + scope?: "surface"; + surface?: number; + [key: string]: unknown; +} + export interface UnknownEvent { event: string; [key: string]: unknown; diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index cca3ee8c9d33..4f6b4428a720 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Write; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -57,10 +57,12 @@ const DEFERRED_INPUT_FIXED_BYTES: usize = 64; const BRACKETED_PASTE_MARKER_BYTES: usize = 12; const MAX_DEFERRED_INPUT_BYTES: usize = 4 * 1024 * 1024; const ROUTING_REFRESH_RETRIES: u8 = 1; +const APP_EVENT_CAPACITY: usize = 4_096; pub enum AppEvent { Mux(MuxEvent), MuxTitlesReady, + MuxSubscriptionRecovered(Result), Input(Event), BrowserResizeFailed(BrowserResizeFailure), PtyOperationFailed(PtyOperationFailure), @@ -74,6 +76,46 @@ struct MuxTitleIngress { state: Mutex, } +fn forward_mux_events( + event_source: Session, + mut session_events: cmux_tui_core::MuxEventReceiver, + tx: SyncSender, + mux_titles: Arc, +) { + loop { + while let Ok(event) = session_events.recv() { + match event { + MuxEvent::TitleChanged { surface, title } => { + if mux_titles.push(surface, title) && tx.send(AppEvent::MuxTitlesReady).is_err() + { + return; + } + continue; + } + MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), + _ => {} + } + let terminal = matches!(event, MuxEvent::Empty); + if tx.send(AppEvent::Mux(event)).is_err() { + return; + } + if terminal { + return; + } + } + if !session_events.overflowed() { + return; + } + // Subscribe before fetching the authoritative tree so events emitted + // during recovery are retained by the new mailbox. + session_events = event_source.events(); + let recovered = event_source.refresh_tree().map_err(|error| error.to_string()); + if tx.send(AppEvent::MuxSubscriptionRecovered(recovered)).is_err() { + return; + } + } +} + #[derive(Default)] struct MuxTitleIngressState { wake_queued: bool, @@ -130,6 +172,7 @@ pub enum SessionMutationOutcome { surface: SurfaceId, operation: &'static str, error: String, + reconnect_required: bool, }, MutationTimedOut(String), Failed(String), @@ -146,7 +189,7 @@ enum SessionCompletionAction { } struct PendingSessionMutation { - events: Sender, + events: SyncSender, settled: bool, } @@ -295,11 +338,12 @@ impl Drop for SurfaceResizeClaim { pub struct OrderedSession { inner: Session, operations: PtyInputSender, - events: Sender, + events: SyncSender, remote: bool, pending_mutations: Arc, committed_mutation_generation: Arc, - routing_mutation_generation: Arc, + routing_mutation_started: Arc, + routing_mutation_committed: Arc, remote_refresh_queued: Arc, remote_background_dirty: Arc, remote_refresh_sequence: Arc, @@ -314,7 +358,7 @@ pub struct OrderedSession { } impl OrderedSession { - fn new(inner: Session, operations: PtyInputSender, events: Sender) -> Self { + fn new(inner: Session, operations: PtyInputSender, events: SyncSender) -> Self { let remote = matches!(inner, Session::Remote(_)); Self { inner, @@ -323,7 +367,8 @@ impl OrderedSession { remote, pending_mutations: Arc::new(AtomicUsize::new(0)), committed_mutation_generation: Arc::new(AtomicU64::new(0)), - routing_mutation_generation: Arc::new(AtomicU64::new(0)), + routing_mutation_started: Arc::new(AtomicU64::new(0)), + routing_mutation_committed: Arc::new(AtomicU64::new(0)), remote_refresh_queued: Arc::new(AtomicBool::new(false)), remote_background_dirty: Arc::new(AtomicBool::new(false)), remote_refresh_sequence: Arc::new(AtomicU64::new(0)), @@ -343,10 +388,6 @@ impl OrderedSession { PendingSessionMutation { events: self.events.clone(), settled: false } } - fn events(&self) -> cmux_tui_core::MuxEventReceiver { - self.inner.events() - } - fn tree(&self) -> TreeView { self.inner.tree() } @@ -425,6 +466,7 @@ impl OrderedSession { surface: id, operation: "attach", error: format!("surface {id} is unavailable"), + reconnect_required: false, }); Ok(()) } @@ -443,6 +485,7 @@ impl OrderedSession { surface: id, operation: "attach", error: error.to_string(), + reconnect_required: timed_out, }); if timed_out || transport_failed { Err(error) } else { Ok(()) } } @@ -489,8 +532,12 @@ impl OrderedSession { self.pending_mutations.load(Ordering::Acquire) > 0 } - fn routing_mutation_generation(&self) -> u64 { - self.routing_mutation_generation.load(Ordering::Acquire) + fn routing_mutation_started(&self) -> u64 { + self.routing_mutation_started.load(Ordering::Acquire) + } + + fn routing_mutation_committed(&self) -> u64 { + self.routing_mutation_committed.load(Ordering::Acquire) } fn settle_pending_mutation(&self) { @@ -619,7 +666,9 @@ impl OrderedSession { let pending = self.pending_mutation(); let remote = self.remote; let committed_mutation_generation = self.committed_mutation_generation.clone(); - let routing_mutation_generation = self.routing_mutation_generation.clone(); + let routing_token = + routing.then(|| self.routing_mutation_started.fetch_add(1, Ordering::AcqRel) + 1); + let routing_mutation_committed = self.routing_mutation_committed.clone(); self.operations.enqueue_session_mutation(label, self.remote, move || { let completion = match operation(session.clone()) { Ok(completion) => completion, @@ -635,8 +684,8 @@ impl OrderedSession { }; let mutation_generation = committed_mutation_generation.fetch_add(1, Ordering::AcqRel) + 1; - if routing { - routing_mutation_generation.fetch_add(1, Ordering::AcqRel); + if let Some(routing_token) = routing_token { + routing_mutation_committed.fetch_max(routing_token, Ordering::AcqRel); } let completion = completion.map(|action| SessionCompletion { mutation_generation, action }); @@ -735,6 +784,7 @@ impl OrderedSession { surface: surface_id, operation: "resize", error: error.to_string(), + reconnect_required: false, }); if transient { Err(error) } else { Ok(()) } } @@ -1356,7 +1406,7 @@ enum PtyMouseReleaseCapture { struct DeferredInput { event: Event, destination: Option, - routing_generation: u64, + routing_intent: Option, } pub struct App { @@ -1414,6 +1464,7 @@ pub struct App { background_refresh_attempts: u8, background_refresh_retry_at: Option, last_applied_refresh_sequence: u64, + applied_routing_generation: u64, pending_session_completions: VecDeque, mux_titles: Arc, drag: Option, @@ -1569,7 +1620,7 @@ pub fn run( session.ensure_initial(initial_size)?; let encoder = KeyEncoder::new()?; let mouse_encoder = MouseEncoder::new()?; - let (tx, rx) = channel::(); + let (tx, rx) = sync_channel::(APP_EVENT_CAPACITY); let browser_failure_tx = tx.clone(); let browser_input = BrowserInputDispatcher::spawn(move |failure| { let _ = browser_failure_tx.send(AppEvent::BrowserResizeFailed(failure)); @@ -1583,29 +1634,12 @@ pub fn run( let mux_titles = Arc::new(MuxTitleIngress::default()); // Session events → app channel. - let session_events = session.events(); + let event_source = session.inner.clone(); + let session_events = event_source.events(); std::thread::Builder::new().name("mux-events".into()).spawn({ let tx = tx.clone(); let mux_titles = mux_titles.clone(); - move || { - while let Ok(event) = session_events.recv() { - match event { - MuxEvent::TitleChanged { surface, title } => { - if mux_titles.push(surface, title) - && tx.send(AppEvent::MuxTitlesReady).is_err() - { - break; - } - continue; - } - MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), - _ => {} - } - if tx.send(AppEvent::Mux(event)).is_err() { - break; - } - } - } + move || forward_mux_events(event_source, session_events, tx, mux_titles) })?; // Crossterm input → app channel. @@ -1705,6 +1739,7 @@ pub fn run( background_refresh_attempts: 0, background_refresh_retry_at: None, last_applied_refresh_sequence: 0, + applied_routing_generation: 0, pending_session_completions: VecDeque::new(), mux_titles, drag: None, @@ -1852,9 +1887,11 @@ impl App { break; } let Some(input) = self.deferred_input.pop_front() else { break }; - let routing_advanced = - self.session.routing_mutation_generation() > input.routing_generation; - if !routing_advanced && self.input_destination(&input.event) != input.destination { + let follows_pending_route = input.routing_intent.is_some_and(|intent| { + self.session.routing_mutation_committed() >= intent + && self.session.routing_mutation_started() == intent + }); + if !follows_pending_route && self.input_destination(&input.event) != input.destination { self.status_message = Some( "Deferred input was discarded because its destination changed".to_string(), ); @@ -1944,6 +1981,7 @@ impl App { self.browser_input.forget_surface(surface); } self.tree = tree; + self.applied_routing_generation = self.session.routing_mutation_committed(); self.rebuild_tab_locations(); } @@ -2370,6 +2408,21 @@ impl App { AppEvent::MuxTitlesReady => { Ok(if self.apply_mux_titles() { RenderAction::Paint } else { RenderAction::None }) } + AppEvent::MuxSubscriptionRecovered(result) => { + match result { + Ok(tree) => { + self.replace_tree(tree); + self.status_message = Some( + "Mux event backlog overflowed; subscription recovered".to_string(), + ); + } + Err(error) => { + self.status_message = + Some(format!("Mux event backlog overflowed; recovery failed: {error}")); + } + } + Ok(RenderAction::Draw) + } AppEvent::SidebarPluginUpdated { status, relaunch } => { self.apply_sidebar_plugin_status(status, relaunch); Ok(RenderAction::Draw) @@ -2453,7 +2506,16 @@ impl App { if failed_active_press || failure.lane_failed { self.drag = None; } - self.status_message = Some(format!("{} failed: {}", failure.label, failure.error)); + self.status_message = Some(if failure.label == "attach surface" + && failure.delivery == PtyOperationDelivery::Ambiguous + { + format!( + "surface attach outcome is unknown; detach and reconnect before sending more input: {}", + failure.error + ) + } else { + format!("{} failed: {}", failure.label, failure.error) + }); Ok(RenderAction::Draw) } AppEvent::SessionMutationSettled(outcome) => { @@ -2526,10 +2588,22 @@ impl App { self.complete_remote_tree_refresh(refresh_stale); return Ok(RenderAction::Draw); } - SessionMutationOutcome::SurfaceSyncFailed { surface, operation, error } => { - self.status_message = Some(format!( - "surface {surface} {operation} failed; retries are rate-limited: {error}" - )); + SessionMutationOutcome::SurfaceSyncFailed { + surface, + operation, + error, + reconnect_required, + } => { + if reconnect_required { + self.deferred_input.retain(|input| input.destination != Some(surface)); + self.status_message = Some(format!( + "surface {surface} {operation} outcome is unknown; detach and reconnect before sending more input: {error}" + )); + } else { + self.status_message = Some(format!( + "surface {surface} {operation} failed; retries are rate-limited: {error}" + )); + } } SessionMutationOutcome::MutationTimedOut(error) => { self.status_message = Some(format!( @@ -2686,33 +2760,35 @@ impl App { fn defer_input(&mut self, input: Event) -> RenderAction { let destination = self.input_destination(&input); - let routing_generation = self.session.routing_mutation_generation(); + let routing_started = self.session.routing_mutation_started(); + let routing_intent = + (routing_started > self.applied_routing_generation).then_some(routing_started); let replace_motion = match (&input, self.deferred_input.back()) { ( Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), Some(DeferredInput { event: Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), destination: previous_destination, - routing_generation: previous_generation, + routing_intent: previous_intent, }), - ) => *previous_destination == destination && *previous_generation == routing_generation, + ) => *previous_destination == destination && *previous_intent == routing_intent, ( Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(button), .. }), Some(DeferredInput { event: Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(previous), .. }), destination: previous_destination, - routing_generation: previous_generation, + routing_intent: previous_intent, }), ) => { button == previous && *previous_destination == destination - && *previous_generation == routing_generation + && *previous_intent == routing_intent } _ => false, }; if replace_motion { *self.deferred_input.back_mut().unwrap() = - DeferredInput { event: input, destination, routing_generation }; + DeferredInput { event: input, destination, routing_intent }; return RenderAction::None; } let input_bytes = deferred_input_bytes(&input); @@ -2735,11 +2811,7 @@ impl App { let Some(removed) = self.deferred_input.pop_front() else { break }; queued_bytes = queued_bytes.saturating_sub(deferred_input_bytes(&removed.event)); } - self.deferred_input.push_back(DeferredInput { - event: input, - destination, - routing_generation, - }); + self.deferred_input.push_back(DeferredInput { event: input, destination, routing_intent }); RenderAction::None } @@ -5486,7 +5558,8 @@ mod tests { use super::{ App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, RenderAction, SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, - browser_content_size_for_rect, browser_hover_forward_allowed, pane_parts_for_rect, + browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, + pane_parts_for_rect, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::Ordering; @@ -6001,6 +6074,41 @@ mod tests { ); } + #[test] + fn deferred_input_does_not_follow_a_later_routing_mutation() { + let mux = Mux::new("deferred-later-routing-test", SurfaceOptions::default()); + let first = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, None).unwrap(); + let third = mux.new_tab(Some(pane), None, None).unwrap(); + mux.select_tab(Some(pane), Some(0), None); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.replace_tree(app.session.tree()); + + app.session.select_tab(Some(pane), Some(1), None); + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + assert_eq!(app.deferred_input.front().and_then(|input| input.routing_intent), Some(1)); + app.session.select_tab(Some(pane), Some(2), None); + + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + app.routing_refresh_pending = false; + app.replay_deferred_input().unwrap(); + + assert_eq!(app.active_surface(), Some(third.id)); + assert_ne!(app.active_surface(), Some(second.id)); + assert!(app.deferred_input.is_empty()); + assert_eq!( + app.status_message.as_deref(), + Some("Deferred input was discarded because its destination changed") + ); + } + #[test] fn identity_refresh_completion_consumes_coalesced_background_refresh() { let mux = Mux::new("identity-refresh-dirty-test", SurfaceOptions::default()); @@ -6052,6 +6160,45 @@ mod tests { assert!(app.mux_titles.push(updated_surface, "next".to_string())); } + #[test] + fn mux_forwarder_recovers_after_bounded_mailbox_overflow() { + let mux = Mux::new("mux-forwarder-overflow-test", SurfaceOptions::default()); + let event_source = Session::Local(mux.clone()); + let session_events = event_source.events(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let titles = Arc::new(MuxTitleIngress::default()); + let forwarder = std::thread::spawn(move || { + forward_mux_events(event_source, session_events, tx, titles); + }); + + for surface in 0..5_000 { + mux.emit(MuxEvent::Bell(surface)); + } + + let mut recovered = false; + for _ in 0..5_000 { + let event = rx.recv_timeout(Duration::from_secs(1)).unwrap(); + if matches!(event, AppEvent::MuxSubscriptionRecovered(Ok(_))) { + recovered = true; + break; + } + } + assert!(recovered); + + mux.emit(MuxEvent::Bell(9_999)); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::Mux(MuxEvent::Bell(9_999)) + )); + mux.emit(MuxEvent::Empty); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::Mux(MuxEvent::Empty) + )); + drop(rx); + forwarder.join().unwrap(); + } + #[test] fn resize_claims_skip_identical_work_and_replace_a_b_a_reversions() { let mux = Mux::new("resize-claim-test", SurfaceOptions::default()); @@ -6218,6 +6365,35 @@ mod tests { assert!(!app.session.can_attach_surface(77)); } + #[test] + fn ambiguous_attach_timeout_discards_input_and_requests_reconnect() { + let mux = Mux::new("ambiguous-attach-reconnect-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.deferred_input.push_back(DeferredInput { + event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + destination: Some(77), + routing_intent: None, + }); + app.session.pending_mutations.store(1, Ordering::Release); + + app.handle(AppEvent::SessionMutationSettled( + super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + error: "remote session did not respond".to_string(), + reconnect_required: true, + }, + )) + .unwrap(); + + assert!(app.deferred_input.is_empty()); + assert!( + app.status_message + .as_deref() + .is_some_and(|message| message.contains("detach and reconnect")) + ); + } + #[test] fn first_input_for_missing_mirror_is_deferred_through_attach() { let mux = Mux::new("missing-mirror-input-test", SurfaceOptions::default()); @@ -6298,7 +6474,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, - routing_generation: 0, + routing_intent: None, }); app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(newer.clone()) }) @@ -6511,7 +6687,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: Some(surface), - routing_generation: 0, + routing_intent: None, }); assert_eq!( @@ -6611,7 +6787,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, - routing_generation: 0, + routing_intent: None, }); app.session .enqueue("timed out mutation", |_| Err(crate::session::test_remote_timeout_error())); @@ -6637,7 +6813,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, - routing_generation: 0, + routing_intent: None, }); for (label, key) in [ @@ -6974,7 +7150,7 @@ mod tests { app.deferred_input.push_back(DeferredInput { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, - routing_generation: 0, + routing_intent: None, }); app.handle(AppEvent::SessionMutationSettled( @@ -7251,7 +7427,7 @@ mod tests { fn test_app_with_events(session: Session) -> (App, Receiver) { let pty_input = PtyInputDispatcher::spawn(|_| {}).unwrap(); - let (events, receiver) = std::sync::mpsc::channel(); + let (events, receiver) = std::sync::mpsc::sync_channel(4_096); let session = OrderedSession::new(session, pty_input.sender(), events); let app = App { session, @@ -7297,6 +7473,7 @@ mod tests { background_refresh_attempts: 0, background_refresh_retry_at: None, last_applied_refresh_sequence: 0, + applied_routing_generation: 0, pending_session_completions: VecDeque::new(), mux_titles: Arc::new(MuxTitleIngress::default()), drag: None, diff --git a/cmux-tui/spec/events.md b/cmux-tui/spec/events.md index be24e431bded..1f1e85c32e89 100644 --- a/cmux-tui/spec/events.md +++ b/cmux-tui/spec/events.md @@ -6,9 +6,9 @@ Implemented event lines can appear on two stream types: | Stream | How to start | Event names | | --- | --- | --- | -| Subscribe stream | `subscribe` command | `tree-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-exited`, `title-changed`, `bell`, `notification`, `config-reload-requested`, `window-title-requested`, `empty` | -| Attach stream v5 | `attach-surface` command | `vt-state`, `output`, `detached` | -| Attach stream v6 | `attach-surface` command | `vt-state`, `resized`, `output`, `scroll-changed`, `detached` | +| Subscribe stream | `subscribe` command | `tree-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-exited`, `title-changed`, `bell`, `notification`, `config-reload-requested`, `window-title-requested`, `empty`, `overflow` | +| Attach stream v5 | `attach-surface` command | `vt-state`, `output`, `detached`, `overflow` | +| Attach stream v6 | `attach-surface` command | `vt-state`, `resized`, `output`, `scroll-changed`, `detached`, `overflow` | Events and command responses share one JSON-lines connection. Clients must route lines by checking for `event`. If `event` is absent, the line is a command response and should be matched by `id`. @@ -20,6 +20,8 @@ For a single subscription, ordinary events are delivered in the order the mux br Protocol v7 treats `title-changed` as a latest-state notification. A slow subscriber retains at most one pending title per surface. Repeated pending titles for the same surface coalesce to the newest `title` and take the newest event's position relative to ordinary events. Subscribers are independent, and a pending title is discarded when its surface exits. +Each subscription retains at most 4,096 pending events. If a client falls behind that bound, the server drains the accepted backlog, emits `overflow`, and ends that subscription. A subscribe client must open a new subscription and fetch `list-workspaces` to reconcile state. An attach client must reattach the named surface. + `subscribe` registers the event receiver before the command response is written. A client must not treat the `subscribe` response as an event-stream barrier. `subscribe` does not send an initial tree snapshot. Clients that need the current tree must call `list-workspaces`, then subscribe, then reconcile any events that arrive between the two operations according to application needs. @@ -32,6 +34,28 @@ When a surface exits, the mux removes it from the tree itself. Subscribe streams ## Implemented Subscribe Events +### overflow + +| Field | Value | +| --- | --- | +| event | `overflow` | +| status | implemented | +| since | protocol 7 | + +Payload: + +```text +object{event:"overflow",error:string,scope?:"surface",surface?:Id} +``` + +Meaning: The client stopped draining events before the bounded server backlog filled. Without `scope`, the subscribe stream ended and the client must subscribe again, then fetch `list-workspaces`. With `scope:"surface"`, the attach notification stream ended and the client must reattach `surface`. + +Example: + +```json +{"event":"overflow","error":"subscriber fell behind; resubscribe to continue receiving events"} +``` + ### tree-changed | Field | Value | From 18b7e7c674fc95c49ceddb78001cf998e6973eee Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:12:59 -0700 Subject: [PATCH 048/103] Harden bounded TUI event lifecycle --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 10 +- .../crates/cmux-tui-core/src/event_bus.rs | 64 ++++- cmux-tui/crates/cmux-tui-core/src/mux.rs | 4 +- cmux-tui/crates/cmux-tui-core/src/server.rs | 6 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 5 +- cmux-tui/crates/cmux-tui/src/app.rs | 263 ++++++++++++++---- .../crates/cmux-tui/src/session/remote.rs | 10 +- 7 files changed, 291 insertions(+), 71 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 6ea73d95c502..d963bb4e5afd 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -537,13 +537,19 @@ fn start_surface_thread( if (url_changed || title_changed) && let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged { surface: id, title: browser.title() }); + mux.emit(MuxEvent::TitleChanged { + surface: id, + title: browser.title().into(), + }); } } CdpEvent::Other { method, params, .. } if method == "Page.frameNavigated" => { handle_frame_navigated(browser, params); if let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged { surface: id, title: browser.title() }); + mux.emit(MuxEvent::TitleChanged { + surface: id, + title: browser.title().into(), + }); mux.emit(MuxEvent::SurfaceOutput(id)); } } diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs index 132914067450..ebf2e0afdc67 100644 --- a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -30,7 +30,7 @@ struct MuxEventMailboxState { next_sequence: u128, events: VecDeque<(u128, MuxEvent)>, title_sequences: HashMap, - titles: BTreeMap, + titles: BTreeMap)>, surface_output_sequences: HashMap, surface_outputs: BTreeMap, closed: bool, @@ -105,6 +105,14 @@ impl MuxEventMailbox { } state.events.push_back((sequence, MuxEvent::SurfaceExited(surface))); } + MuxEvent::Empty => { + state.events.clear(); + state.title_sequences.clear(); + state.titles.clear(); + state.surface_output_sequences.clear(); + state.surface_outputs.clear(); + state.events.push_back((sequence, MuxEvent::Empty)); + } event => { if !state.reserve_pending_slot() { self.changed.notify_all(); @@ -227,43 +235,62 @@ mod tests { let fast = broadcaster.subscribe(); let slow = broadcaster.subscribe(); - broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "first".to_string() }); + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "first".into() }); assert!(matches!( fast.recv().unwrap(), - MuxEvent::TitleChanged { surface: 1, title } if title == "first" + MuxEvent::TitleChanged { surface: 1, title } if title.as_ref() == "first" )); for index in 0..10_000 { - broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: format!("one-{index}") }); - broadcaster.emit(MuxEvent::TitleChanged { surface: 2, title: format!("two-{index}") }); + broadcaster + .emit(MuxEvent::TitleChanged { surface: 1, title: format!("one-{index}").into() }); + broadcaster + .emit(MuxEvent::TitleChanged { surface: 2, title: format!("two-{index}").into() }); } for receiver in [&fast, &slow] { assert!(matches!( receiver.recv().unwrap(), - MuxEvent::TitleChanged { surface: 1, title } if title == "one-9999" + MuxEvent::TitleChanged { surface: 1, title } if title.as_ref() == "one-9999" )); assert!(matches!( receiver.recv().unwrap(), - MuxEvent::TitleChanged { surface: 2, title } if title == "two-9999" + MuxEvent::TitleChanged { surface: 2, title } if title.as_ref() == "two-9999" )); assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); } } + #[test] + fn title_fanout_shares_the_payload_allocation() { + let broadcaster = MuxEventBroadcaster::default(); + let first = broadcaster.subscribe(); + let second = broadcaster.subscribe(); + let title = Arc::::from("shared title"); + + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: title.clone() }); + + for events in [first, second] { + let MuxEvent::TitleChanged { title: received, .. } = events.recv().unwrap() else { + panic!("expected title event"); + }; + assert!(Arc::ptr_eq(&title, &received)); + } + } + #[test] fn coalesced_title_keeps_its_latest_position_between_other_events() { let broadcaster = MuxEventBroadcaster::default(); let events = broadcaster.subscribe(); - broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "old".to_string() }); + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "old".into() }); broadcaster.emit(MuxEvent::Bell(2)); - broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "latest".to_string() }); + broadcaster.emit(MuxEvent::TitleChanged { surface: 1, title: "latest".into() }); broadcaster.emit(MuxEvent::SurfaceExited(3)); assert!(matches!(events.recv().unwrap(), MuxEvent::Bell(2))); assert!(matches!( events.recv().unwrap(), - MuxEvent::TitleChanged { surface: 1, title } if title == "latest" + MuxEvent::TitleChanged { surface: 1, title } if title.as_ref() == "latest" )); assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(3))); } @@ -273,7 +300,7 @@ mod tests { let broadcaster = MuxEventBroadcaster::default(); let events = broadcaster.subscribe(); - broadcaster.emit(MuxEvent::TitleChanged { surface: 4, title: "gone".to_string() }); + broadcaster.emit(MuxEvent::TitleChanged { surface: 4, title: "gone".into() }); broadcaster.emit(MuxEvent::SurfaceExited(4)); assert!(matches!(events.recv().unwrap(), MuxEvent::SurfaceExited(4))); @@ -333,4 +360,19 @@ mod tests { broadcaster.emit(MuxEvent::Bell(9_999)); assert!(matches!(fast.recv().unwrap(), MuxEvent::Bell(9_999))); } + + #[test] + fn empty_preempts_a_full_mailbox() { + let broadcaster = MuxEventBroadcaster::default(); + let events = broadcaster.subscribe(); + for surface in 0..MAX_PENDING_EVENTS { + broadcaster.emit(MuxEvent::Bell(surface as SurfaceId)); + } + + broadcaster.emit(MuxEvent::Empty); + + assert!(matches!(events.recv().unwrap(), MuxEvent::Empty)); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + assert!(!events.overflowed()); + } } diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 479e21286753..941551b8cec1 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -29,7 +29,7 @@ pub enum MuxEvent { SurfaceExited(SurfaceId), TitleChanged { surface: SurfaceId, - title: String, + title: Arc, }, Bell(SurfaceId), Notification(NotificationEvent), @@ -429,7 +429,7 @@ impl Mux { browser.mark_failed(err.to_string()); } mux.emit(MuxEvent::Status(format!("browser failed: {err}"))); - mux.emit(MuxEvent::TitleChanged { surface: id, title: surface.title() }); + mux.emit(MuxEvent::TitleChanged { surface: id, title: surface.title().into() }); mux.emit(MuxEvent::SurfaceOutput(id)); } }, diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 46faa5e5fa9d..93fb9a7b70ff 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -1468,7 +1468,7 @@ fn subscribed_event_json(event: &MuxEvent) -> Value { }), MuxEvent::SurfaceExited(id) => json!({"event": "surface-exited", "surface": id}), MuxEvent::TitleChanged { surface, title } => { - json!({"event": "title-changed", "surface": surface, "title": title}) + json!({"event": "title-changed", "surface": surface, "title": title.as_ref()}) } MuxEvent::Bell(id) => json!({"event": "bell", "surface": id}), MuxEvent::Notification(notification) => json!({ @@ -1622,7 +1622,7 @@ mod tests { loop { match events.recv_timeout(Duration::from_secs(1)).unwrap() { MuxEvent::TitleChanged { surface: id, title } - if id == surface.id && title == "server title" => + if id == surface.id && title.as_ref() == "server title" => { break; } @@ -1634,7 +1634,7 @@ mod tests { assert_eq!( subscribed_event_json(&MuxEvent::TitleChanged { surface: surface.id, - title: "server title".to_string(), + title: Arc::::from("server title"), }), json!({ "event": "title-changed", diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 9e33740c5516..cff0e9bc05dc 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -293,7 +293,10 @@ impl Surface { let title = term.title().unwrap_or_default(); *pty.title.lock().unwrap() = title.clone(); if let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged { surface: surface.id, title }); + mux.emit(MuxEvent::TitleChanged { + surface: surface.id, + title: title.into(), + }); } } if let Some(pwd) = term.pwd() { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 4f6b4428a720..f6597296e827 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Write; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -62,13 +62,23 @@ const APP_EVENT_CAPACITY: usize = 4_096; pub enum AppEvent { Mux(MuxEvent), MuxTitlesReady, - MuxSubscriptionRecovered(Result), + MuxSubscriptionRecovered { + routing_generation: u64, + result: Result, + }, Input(Event), BrowserResizeFailed(BrowserResizeFailure), PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), - RemoteTreeUpdated { refresh_sequence: u64, result: Result }, - SidebarPluginUpdated { status: SidebarPluginSurface, relaunch: bool }, + RemoteTreeUpdated { + refresh_sequence: u64, + routing_generation: u64, + result: Result, + }, + SidebarPluginUpdated { + status: SidebarPluginSurface, + relaunch: bool, + }, } #[derive(Default)] @@ -79,6 +89,7 @@ struct MuxTitleIngress { fn forward_mux_events( event_source: Session, mut session_events: cmux_tui_core::MuxEventReceiver, + routing_mutation_committed: Arc, tx: SyncSender, mux_titles: Arc, ) { @@ -109,8 +120,12 @@ fn forward_mux_events( // Subscribe before fetching the authoritative tree so events emitted // during recovery are retained by the new mailbox. session_events = event_source.events(); + let routing_generation = routing_mutation_committed.load(Ordering::Acquire); let recovered = event_source.refresh_tree().map_err(|error| error.to_string()); - if tx.send(AppEvent::MuxSubscriptionRecovered(recovered)).is_err() { + if tx + .send(AppEvent::MuxSubscriptionRecovered { routing_generation, result: recovered }) + .is_err() + { return; } } @@ -119,14 +134,14 @@ fn forward_mux_events( #[derive(Default)] struct MuxTitleIngressState { wake_queued: bool, - titles: HashMap, + titles: HashMap>, } impl MuxTitleIngress { /// Returns true when the app channel needs one wake event. - fn push(&self, surface: SurfaceId, title: String) -> bool { + fn push(&self, surface: SurfaceId, title: impl Into>) -> bool { let mut state = self.state.lock().unwrap(); - state.titles.insert(surface, title); + state.titles.insert(surface, title.into()); if state.wake_queued { false } else { @@ -139,7 +154,7 @@ impl MuxTitleIngress { self.state.lock().unwrap().titles.remove(&surface); } - fn take(&self) -> HashMap { + fn take(&self) -> HashMap> { let mut state = self.state.lock().unwrap(); state.wake_queued = false; std::mem::take(&mut state.titles) @@ -153,11 +168,13 @@ pub enum SessionMutationOutcome { AuthoritativeMutationSucceeded { tree: TreeView, authoritative_generation: u64, + routing_generation: u64, completion: Option, }, IdentityRefreshSucceeded { tree: TreeView, authoritative_generation: u64, + routing_generation: u64, refresh_sequence: u64, }, CommittedTreeStale { @@ -190,6 +207,8 @@ enum SessionCompletionAction { struct PendingSessionMutation { events: SyncSender, + pending_mutations: Arc, + cancellation_pending: Arc, settled: bool, } @@ -203,9 +222,20 @@ impl PendingSessionMutation { impl Drop for PendingSessionMutation { fn drop(&mut self) { if !self.settled { - let _ = self + match self .events - .send(AppEvent::SessionMutationSettled(SessionMutationOutcome::Canceled)); + .try_send(AppEvent::SessionMutationSettled(SessionMutationOutcome::Canceled)) + { + Ok(()) => {} + Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => { + let _ = self.pending_mutations.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |pending| pending.checked_sub(1), + ); + self.cancellation_pending.store(true, Ordering::Release); + } + } } } } @@ -341,6 +371,7 @@ pub struct OrderedSession { events: SyncSender, remote: bool, pending_mutations: Arc, + cancellation_pending: Arc, committed_mutation_generation: Arc, routing_mutation_started: Arc, routing_mutation_committed: Arc, @@ -366,6 +397,7 @@ impl OrderedSession { events, remote, pending_mutations: Arc::new(AtomicUsize::new(0)), + cancellation_pending: Arc::new(AtomicBool::new(false)), committed_mutation_generation: Arc::new(AtomicU64::new(0)), routing_mutation_started: Arc::new(AtomicU64::new(0)), routing_mutation_committed: Arc::new(AtomicU64::new(0)), @@ -385,7 +417,12 @@ impl OrderedSession { fn pending_mutation(&self) -> PendingSessionMutation { self.pending_mutations.fetch_add(1, Ordering::AcqRel); - PendingSessionMutation { events: self.events.clone(), settled: false } + PendingSessionMutation { + events: self.events.clone(), + pending_mutations: self.pending_mutations.clone(), + cancellation_pending: self.cancellation_pending.clone(), + settled: false, + } } fn tree(&self) -> TreeView { @@ -548,6 +585,14 @@ impl OrderedSession { debug_assert!(result.is_ok(), "session mutation completion without a pending operation"); } + fn take_cancellation_pending(&self) -> bool { + self.cancellation_pending.swap(false, Ordering::AcqRel) + } + + fn defer_cancellation(&self) { + self.cancellation_pending.store(true, Ordering::Release); + } + fn refresh_remote_tree_if_stale(&self) { if !self.inner.take_remote_tree_stale() { return; @@ -559,6 +604,7 @@ impl OrderedSession { self.inner.invalidate_remote_tree(); let session = self.inner.clone(); let authoritative_generation = self.committed_mutation_generation.load(Ordering::Acquire); + let routing_generation = self.routing_mutation_committed.load(Ordering::Acquire); let refresh_sequence = self.remote_refresh_sequence.fetch_add(1, Ordering::AcqRel) + 1; let pending = self.pending_mutation(); let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); @@ -571,6 +617,7 @@ impl OrderedSession { pending.settle(SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation, + routing_generation, refresh_sequence, }); } @@ -608,17 +655,23 @@ impl OrderedSession { let session = self.inner.clone(); let events = self.events.clone(); let refresh_sequence = self.remote_refresh_sequence.fetch_add(1, Ordering::AcqRel) + 1; + let routing_generation = self.routing_mutation_committed.load(Ordering::Acquire); let claim = RemoteRefreshClaim(self.remote_refresh_queued.clone()); let spawn = std::thread::Builder::new().name("remote-tree-refresh".into()).spawn(move || { let result = session.refresh_tree_background().map_err(|error| error.to_string()); drop(claim); - let _ = events.send(AppEvent::RemoteTreeUpdated { refresh_sequence, result }); + let _ = events.send(AppEvent::RemoteTreeUpdated { + refresh_sequence, + routing_generation, + result, + }); }); if let Err(error) = spawn { self.remote_refresh_queued.store(false, Ordering::Release); let _ = self.events.send(AppEvent::RemoteTreeUpdated { refresh_sequence, + routing_generation, result: Err(error.to_string()), }); } @@ -696,9 +749,11 @@ impl OrderedSession { } else { match session.refresh_tree() { Ok(tree) => { + let routing_generation = routing_mutation_committed.load(Ordering::Acquire); pending.settle(SessionMutationOutcome::AuthoritativeMutationSucceeded { tree, authoritative_generation: mutation_generation, + routing_generation, completion, }); } @@ -1636,10 +1691,19 @@ pub fn run( // Session events → app channel. let event_source = session.inner.clone(); let session_events = event_source.events(); + let routing_mutation_committed = session.routing_mutation_committed.clone(); std::thread::Builder::new().name("mux-events".into()).spawn({ let tx = tx.clone(); let mux_titles = mux_titles.clone(); - move || forward_mux_events(event_source, session_events, tx, mux_titles) + move || { + forward_mux_events( + event_source, + session_events, + routing_mutation_committed, + tx, + mux_titles, + ); + } })?; // Crossterm input → app channel. @@ -1825,6 +1889,14 @@ impl App { Err(_) => break, } } + if self.session.take_cancellation_pending() { + if self.session.has_pending_mutations() { + self.session.defer_cancellation(); + } else { + self.apply_session_cancellation(); + action = action.merge(RenderAction::Draw); + } + } if self.quit { break; } @@ -1927,6 +1999,13 @@ impl App { } } + fn apply_session_cancellation(&mut self) { + self.deferred_input.clear(); + self.prefix_armed = false; + self.pending_session_completions.clear(); + self.status_message = Some("session operation was canceled".to_string()); + } + fn apply_mux_titles(&mut self) -> bool { let titles = self.mux_titles.take(); if titles.is_empty() { @@ -1948,8 +2027,8 @@ impl App { else { continue; }; - if tab.title != title { - tab.title = title; + if tab.title.as_str() != title.as_ref() { + tab.title = title.to_string(); changed = true; } } @@ -1981,10 +2060,14 @@ impl App { self.browser_input.forget_surface(surface); } self.tree = tree; - self.applied_routing_generation = self.session.routing_mutation_committed(); self.rebuild_tab_locations(); } + fn replace_authoritative_tree(&mut self, tree: TreeView, routing_generation: u64) { + self.replace_tree(tree); + self.applied_routing_generation = self.applied_routing_generation.max(routing_generation); + } + fn rebuild_tab_locations(&mut self) { self.tab_locations.clear(); for (workspace_index, workspace) in self.tree.workspaces.iter().enumerate() { @@ -2408,10 +2491,15 @@ impl App { AppEvent::MuxTitlesReady => { Ok(if self.apply_mux_titles() { RenderAction::Paint } else { RenderAction::None }) } - AppEvent::MuxSubscriptionRecovered(result) => { + AppEvent::MuxSubscriptionRecovered { routing_generation, result } => { match result { Ok(tree) => { - self.replace_tree(tree); + let empty = tree.workspaces.is_empty(); + self.replace_authoritative_tree(tree, routing_generation); + if empty { + self.quit = true; + return Ok(RenderAction::None); + } self.status_message = Some( "Mux event backlog overflowed; subscription recovered".to_string(), ); @@ -2506,16 +2594,18 @@ impl App { if failed_active_press || failure.lane_failed { self.drag = None; } - self.status_message = Some(if failure.label == "attach surface" - && failure.delivery == PtyOperationDelivery::Ambiguous - { - format!( - "surface attach outcome is unknown; detach and reconnect before sending more input: {}", - failure.error - ) - } else { - format!("{} failed: {}", failure.label, failure.error) - }); + self.status_message = Some( + if failure.label == "attach surface" + && failure.delivery == PtyOperationDelivery::Ambiguous + { + format!( + "surface attach outcome is unknown; detach and reconnect before sending more input: {}", + failure.error + ) + } else { + format!("{} failed: {}", failure.label, failure.error) + }, + ); Ok(RenderAction::Draw) } AppEvent::SessionMutationSettled(outcome) => { @@ -2530,10 +2620,11 @@ impl App { SessionMutationOutcome::AuthoritativeMutationSucceeded { tree, authoritative_generation, + routing_generation, completion, } => { self.session.clear_surface_sync_failures(); - self.replace_tree(tree); + self.replace_authoritative_tree(tree, routing_generation); self.routing_refresh_retries_remaining = 0; if let Some(completion) = completion { self.pending_session_completions.push_back(completion); @@ -2543,6 +2634,7 @@ impl App { SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation, + routing_generation, refresh_sequence, } => { if !self.accept_refresh_sequence(refresh_sequence) { @@ -2552,7 +2644,7 @@ impl App { } self.session.clear_surface_sync_failures(); self.session.reconcile_exited_surfaces(&tree); - self.replace_tree(tree); + self.replace_authoritative_tree(tree, routing_generation); self.routing_refresh_retries_remaining = 0; self.background_refresh_attempts = 0; self.background_refresh_retry_at = None; @@ -2623,12 +2715,10 @@ impl App { } SessionMutationOutcome::Canceled => { if self.session.has_pending_mutations() { + self.session.defer_cancellation(); return Ok(RenderAction::None); } - self.deferred_input.clear(); - self.prefix_armed = false; - self.pending_session_completions.clear(); - self.status_message = Some("session operation was canceled".to_string()); + self.apply_session_cancellation(); return Ok(RenderAction::Draw); } } @@ -2639,14 +2729,14 @@ impl App { self.routing_refresh_pending = true; Ok(RenderAction::Draw) } - AppEvent::RemoteTreeUpdated { refresh_sequence, result } => { + AppEvent::RemoteTreeUpdated { refresh_sequence, routing_generation, result } => { if !self.accept_refresh_sequence(refresh_sequence) { return Ok(RenderAction::None); } let refreshed = match result { Ok(tree) => { self.session.reconcile_exited_surfaces(&tree); - self.replace_tree(tree); + self.replace_authoritative_tree(tree, routing_generation); self.routing_refresh_pending = true; self.routing_refresh_retries_remaining = 0; self.background_refresh_attempts = 0; @@ -5557,12 +5647,12 @@ fn browser_key_mapping( mod tests { use super::{ App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, - RenderAction, SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, - browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, - pane_parts_for_rect, + PendingSessionMutation, RenderAction, SessionCompletion, SessionCompletionAction, + SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, + forward_mux_events, pane_parts_for_rect, }; use std::collections::{HashMap, HashSet, VecDeque}; - use std::sync::atomic::Ordering; + use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -6014,6 +6104,25 @@ mod tests { assert!(!app.session.has_pending_mutations()); } + #[test] + fn canceled_mutation_does_not_block_on_a_full_app_channel() { + let (events, receiver) = std::sync::mpsc::sync_channel(1); + events.send(AppEvent::MuxTitlesReady).unwrap(); + let pending_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(1)); + let cancellation_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + drop(PendingSessionMutation { + events, + pending_mutations: pending_mutations.clone(), + cancellation_pending: cancellation_pending.clone(), + settled: false, + }); + + assert_eq!(pending_mutations.load(Ordering::Acquire), 0); + assert!(cancellation_pending.load(Ordering::Acquire)); + assert!(matches!(receiver.try_recv(), Ok(AppEvent::MuxTitlesReady))); + } + #[test] fn deferred_input_is_discarded_when_its_destination_changes() { let mux = Mux::new("deferred-destination-test", SurfaceOptions::default()); @@ -6109,6 +6218,34 @@ mod tests { ); } + #[test] + fn stale_remote_snapshot_does_not_mark_pending_route_applied() { + let mux = Mux::new("stale-routing-snapshot-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.session.remote = true; + app.session.routing_mutation_started.store(1, Ordering::Release); + app.session.routing_mutation_committed.store(1, Ordering::Release); + app.replace_tree(notify_tree(41, false)); + app.routing_refresh_pending = true; + + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + + assert_eq!(app.applied_routing_generation, 0); + assert_eq!(app.deferred_input.front().and_then(|input| input.routing_intent), Some(1)); + + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence: 1, + routing_generation: 1, + result: Ok(notify_tree(42, false)), + }) + .unwrap(); + assert_eq!(app.applied_routing_generation, 1); + } + #[test] fn identity_refresh_completion_consumes_coalesced_background_refresh() { let mux = Mux::new("identity-refresh-dirty-test", SurfaceOptions::default()); @@ -6121,6 +6258,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: TreeView::default(), authoritative_generation: 0, + routing_generation: 0, refresh_sequence: 1, }, )) @@ -6167,8 +6305,9 @@ mod tests { let session_events = event_source.events(); let (tx, rx) = std::sync::mpsc::sync_channel(1); let titles = Arc::new(MuxTitleIngress::default()); + let routing_generation = Arc::new(AtomicU64::new(0)); let forwarder = std::thread::spawn(move || { - forward_mux_events(event_source, session_events, tx, titles); + forward_mux_events(event_source, session_events, routing_generation, tx, titles); }); for surface in 0..5_000 { @@ -6178,7 +6317,7 @@ mod tests { let mut recovered = false; for _ in 0..5_000 { let event = rx.recv_timeout(Duration::from_secs(1)).unwrap(); - if matches!(event, AppEvent::MuxSubscriptionRecovered(Ok(_))) { + if matches!(event, AppEvent::MuxSubscriptionRecovered { result: Ok(_), .. }) { recovered = true; break; } @@ -6477,12 +6616,17 @@ mod tests { routing_intent: None, }); - app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(newer.clone()) }) - .unwrap(); + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence: 2, + routing_generation: 0, + result: Ok(newer.clone()), + }) + .unwrap(); app.handle(AppEvent::SessionMutationSettled( super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: older.clone(), authoritative_generation: 0, + routing_generation: 0, refresh_sequence: 1, }, )) @@ -6497,11 +6641,17 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: newer, authoritative_generation: 0, + routing_generation: 0, refresh_sequence: 4, }, )) .unwrap(); - app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 3, result: Ok(older) }).unwrap(); + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence: 3, + routing_generation: 0, + result: Ok(older), + }) + .unwrap(); assert_eq!(app.tree.workspaces[0].screens[0].panes[0].tabs[0].surface, 22); } @@ -6517,13 +6667,18 @@ mod tests { action: SessionCompletionAction::BrowserTabCreated { surface }, }); - app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 2, result: Ok(tree.clone()) }) - .unwrap(); + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence: 2, + routing_generation: 0, + result: Ok(tree.clone()), + }) + .unwrap(); app.session.pending_mutations.store(1, Ordering::Release); app.handle(AppEvent::SessionMutationSettled( super::SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation: 4, + routing_generation: 0, refresh_sequence: 1, }, )) @@ -6539,6 +6694,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 1, + routing_generation: 0, result: Ok(notify_tree(22, false)), }) .unwrap(); @@ -6847,8 +7003,12 @@ mod tests { }); let tree = browser_completion_tree(surface, surface); - app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 1, result: Ok(tree.clone()) }) - .unwrap(); + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence: 1, + routing_generation: 0, + result: Ok(tree.clone()), + }) + .unwrap(); assert_eq!(app.pending_session_completions.len(), 1); assert!(app.omnibar.is_none()); @@ -6865,6 +7025,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: tree.clone(), authoritative_generation: 3, + routing_generation: 0, refresh_sequence: 2, }, )) @@ -6877,6 +7038,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree, authoritative_generation: 4, + routing_generation: 0, refresh_sequence: 3, }, )) @@ -6902,6 +7064,7 @@ mod tests { super::SessionMutationOutcome::IdentityRefreshSucceeded { tree: browser_completion_tree(created_surface, active_surface), authoritative_generation: 4, + routing_generation: 0, refresh_sequence: 1, }, )) diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 9984e65b4bc8..5a740b0f10ce 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -454,7 +454,10 @@ impl RemoteSession { surface.dirty.store(true, Ordering::Release); } if let Some(title) = value.get("title").and_then(Value::as_str) { - self.emit(MuxEvent::TitleChanged { surface: id, title: title.to_string() }); + self.emit(MuxEvent::TitleChanged { + surface: id, + title: Arc::::from(title), + }); } self.emit(MuxEvent::SurfaceOutput(id)); } @@ -498,7 +501,10 @@ impl RemoteSession { if !updated && self.invalidate_tree_once() { self.emit(MuxEvent::TreeChanged); } - self.emit(MuxEvent::TitleChanged { surface: id, title: title.to_string() }); + self.emit(MuxEvent::TitleChanged { + surface: id, + title: Arc::::from(title), + }); } else { if self.invalidate_tree_once() { self.emit(MuxEvent::TreeChanged); From 77320683aad8b3d170e4eb25631c6f98966dca6f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:39:11 -0700 Subject: [PATCH 049/103] Close TUI queue recovery gaps --- cmux-tui/crates/cmux-tui/src/app.rs | 427 +++++++++++++++++----- cmux-tui/crates/cmux-tui/src/pty_input.rs | 97 ++++- 2 files changed, 411 insertions(+), 113 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index f6597296e827..938407855f87 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -58,6 +58,7 @@ const BRACKETED_PASTE_MARKER_BYTES: usize = 12; const MAX_DEFERRED_INPUT_BYTES: usize = 4 * 1024 * 1024; const ROUTING_REFRESH_RETRIES: u8 = 1; const APP_EVENT_CAPACITY: usize = 4_096; +const PTY_FAILURE_CAPACITY: usize = 512; pub enum AppEvent { Mux(MuxEvent), @@ -68,6 +69,7 @@ pub enum AppEvent { }, Input(Event), BrowserResizeFailed(BrowserResizeFailure), + PtyFailuresReady, PtyOperationFailed(PtyOperationFailure), SessionMutationSettled(SessionMutationOutcome), RemoteTreeUpdated { @@ -90,6 +92,7 @@ fn forward_mux_events( event_source: Session, mut session_events: cmux_tui_core::MuxEventReceiver, routing_mutation_committed: Arc, + mux_recovery_pending: Arc, tx: SyncSender, mux_titles: Arc, ) { @@ -117,6 +120,7 @@ fn forward_mux_events( if !session_events.overflowed() { return; } + mux_recovery_pending.store(true, Ordering::Release); // Subscribe before fetching the authoritative tree so events emitted // during recovery are retained by the new mailbox. session_events = event_source.events(); @@ -161,6 +165,57 @@ impl MuxTitleIngress { } } +#[derive(Default)] +struct PtyFailureIngress { + state: Mutex, +} + +#[derive(Default)] +struct PtyFailureIngressState { + wake_queued: bool, + failures: VecDeque, +} + +impl PtyFailureIngress { + /// Returns true when the app channel needs one wake event. + fn push(&self, failure: PtyOperationFailure) -> bool { + let mut state = self.state.lock().unwrap(); + if failure.kind == Some(PtyInputKind::Motion) + && let Some(existing) = state.failures.iter_mut().find(|existing| { + existing.kind == Some(PtyInputKind::Motion) + && existing.surface_id == failure.surface_id + }) + { + *existing = failure; + } else { + if state.failures.len() >= PTY_FAILURE_CAPACITY { + if let Some(index) = state + .failures + .iter() + .position(|existing| existing.kind == Some(PtyInputKind::Motion)) + { + state.failures.remove(index); + } else { + state.failures.pop_front(); + } + } + state.failures.push_back(failure); + } + if state.wake_queued { + false + } else { + state.wake_queued = true; + true + } + } + + fn take(&self) -> VecDeque { + let mut state = self.state.lock().unwrap(); + state.wake_queued = false; + std::mem::take(&mut state.failures) + } +} + pub enum SessionMutationOutcome { Success { tree: Option, @@ -205,23 +260,37 @@ enum SessionCompletionAction { BrowserTabCreated { surface: SurfaceId }, } -struct PendingSessionMutation { +struct PendingSessionMutationState { events: SyncSender, pending_mutations: Arc, cancellation_pending: Arc, - settled: bool, + settled: AtomicBool, } +#[derive(Clone)] +struct PendingSessionMutation(Arc); + impl PendingSessionMutation { - fn settle(mut self, outcome: SessionMutationOutcome) { - let _ = self.events.send(AppEvent::SessionMutationSettled(outcome)); - self.settled = true; + fn settle(self, outcome: SessionMutationOutcome) { + if !self.0.settled.swap(true, Ordering::AcqRel) { + let _ = self.0.events.send(AppEvent::SessionMutationSettled(outcome)); + } + } + + fn supersede(self) { + if !self.0.settled.swap(true, Ordering::AcqRel) { + let _ = self.0.pending_mutations.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |pending| pending.checked_sub(1), + ); + } } } -impl Drop for PendingSessionMutation { +impl Drop for PendingSessionMutationState { fn drop(&mut self) { - if !self.settled { + if !self.settled.load(Ordering::Acquire) { match self .events .try_send(AppEvent::SessionMutationSettled(SessionMutationOutcome::Canceled)) @@ -417,12 +486,12 @@ impl OrderedSession { fn pending_mutation(&self) -> PendingSessionMutation { self.pending_mutations.fetch_add(1, Ordering::AcqRel); - PendingSessionMutation { + PendingSessionMutation(Arc::new(PendingSessionMutationState { events: self.events.clone(), pending_mutations: self.pending_mutations.clone(), cancellation_pending: self.cancellation_pending.clone(), - settled: false, - } + settled: AtomicBool::new(false), + })) } fn tree(&self) -> TreeView { @@ -475,10 +544,12 @@ impl OrderedSession { let enqueue_failures = attach_failures.clone(); let remote = self.remote; let pending = self.pending_mutation(); + let superseded = pending.clone(); let enqueue_result = self.operations.enqueue_coalescing_mutation( "attach surface", ("attach surface", id), self.remote, + move || superseded.supersede(), move || { let _claim = claim; if exited_surfaces.lock().unwrap().contains(&id) @@ -777,20 +848,27 @@ impl OrderedSession { let pending = self.pending_mutation(); let remote = self.remote; let committed_mutation_generation = self.committed_mutation_generation.clone(); - self.operations.enqueue_coalescing_mutation(label, key, remote, move || { - if let Err(error) = operation(session.clone()) { - if remote && is_remote_timeout(&error) { - session.invalidate_remote_tree(); - pending.settle(SessionMutationOutcome::MutationTimedOut(error.to_string())); - } else { - pending.settle(SessionMutationOutcome::Failed(error.to_string())); + let superseded = pending.clone(); + self.operations.enqueue_coalescing_mutation( + label, + key, + remote, + move || superseded.supersede(), + move || { + if let Err(error) = operation(session.clone()) { + if remote && is_remote_timeout(&error) { + session.invalidate_remote_tree(); + pending.settle(SessionMutationOutcome::MutationTimedOut(error.to_string())); + } else { + pending.settle(SessionMutationOutcome::Failed(error.to_string())); + } + return Err(error); } - return Err(error); - } - committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - pending.settle(SessionMutationOutcome::Success { tree: None }); - Ok(()) - }); + committed_mutation_generation.fetch_add(1, Ordering::AcqRel); + pending.settle(SessionMutationOutcome::Success { tree: None }); + Ok(()) + }, + ); } fn resize_surface( @@ -806,10 +884,12 @@ impl OrderedSession { let failures = self.surface_resize_failures.clone(); let enqueue_failures = failures.clone(); let committed_mutation_generation = self.committed_mutation_generation.clone(); + let superseded = pending.clone(); let enqueue_result = self.operations.enqueue_coalescing_mutation( "resize PTY surface", ("surface resize", surface_id), self.remote, + move || superseded.supersede(), move || { let _claim = claim; let result = if reassert { @@ -893,10 +973,12 @@ impl OrderedSession { let pending = self.pending_mutation(); let committed_mutation_generation = self.committed_mutation_generation.clone(); let config_generation = self.config_generation.clone(); + let superseded = pending.clone(); self.operations.enqueue_coalescing_mutation( "apply config", ("apply config", 0), self.remote, + move || superseded.supersede(), move || { session.apply_config(&config); config_generation.fetch_add(1, Ordering::AcqRel); @@ -927,6 +1009,7 @@ impl OrderedSession { let session = self.inner.clone(); let events = self.events.clone(); let pending = self.pending_mutation(); + let superseded = pending.clone(); let committed_mutation_generation = self.committed_mutation_generation.clone(); let operation = move || { let mut claim = claim; @@ -951,6 +1034,7 @@ impl OrderedSession { "sync sidebar plugin", ("sidebar plugin", 0), self.remote, + move || superseded.supersede(), operation, ); } @@ -1522,6 +1606,8 @@ pub struct App { applied_routing_generation: u64, pending_session_completions: VecDeque, mux_titles: Arc, + pty_failures: Arc, + mux_recovery_pending: Arc, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -1681,17 +1767,23 @@ pub fn run( let _ = browser_failure_tx.send(AppEvent::BrowserResizeFailed(failure)); })?; let failure_tx = tx.clone(); + let pty_failures = Arc::new(PtyFailureIngress::default()); + let failure_ingress = pty_failures.clone(); let pty_input = PtyInputDispatcher::spawn(move |failure| { - let _ = failure_tx.send(AppEvent::PtyOperationFailed(failure)); + if failure_ingress.push(failure) { + let _ = failure_tx.try_send(AppEvent::PtyFailuresReady); + } })?; let session = OrderedSession::new(session, pty_input.sender(), tx.clone()); let stdout_lock = Arc::new(Mutex::new(())); let mux_titles = Arc::new(MuxTitleIngress::default()); + let mux_recovery_pending = Arc::new(AtomicBool::new(false)); // Session events → app channel. let event_source = session.inner.clone(); let session_events = event_source.events(); let routing_mutation_committed = session.routing_mutation_committed.clone(); + let mux_recovery_barrier = mux_recovery_pending.clone(); std::thread::Builder::new().name("mux-events".into()).spawn({ let tx = tx.clone(); let mux_titles = mux_titles.clone(); @@ -1700,6 +1792,7 @@ pub fn run( event_source, session_events, routing_mutation_committed, + mux_recovery_barrier, tx, mux_titles, ); @@ -1806,6 +1899,8 @@ pub fn run( applied_routing_generation: 0, pending_session_completions: VecDeque::new(), mux_titles, + pty_failures, + mux_recovery_pending, drag: None, encoder, mouse_encoder, @@ -1889,6 +1984,7 @@ impl App { Err(_) => break, } } + action = action.merge(self.apply_pty_failures()); if self.session.take_cancellation_pending() { if self.session.has_pending_mutations() { self.session.defer_cancellation(); @@ -2006,6 +2102,49 @@ impl App { self.status_message = Some("session operation was canceled".to_string()); } + fn apply_pty_failures(&mut self) -> RenderAction { + let failures = self.pty_failures.take(); + let mut action = RenderAction::None; + for failure in failures { + action = action.merge(self.apply_pty_operation_failure(failure)); + } + action + } + + fn apply_pty_operation_failure(&mut self, failure: PtyOperationFailure) -> RenderAction { + if failure.label == "relaunch sidebar plugin" { + self.sidebar_focus_pending = false; + } + if failure.kind == Some(PtyInputKind::Motion) + && failure.delivery == PtyOperationDelivery::KnownNotDelivered + { + self.mouse_encoder.reset_motion_dedupe(); + } + let failed_active_press = failure.kind == Some(PtyInputKind::Press) + && failure.delivery == PtyOperationDelivery::KnownNotDelivered + && failure.surface_id.zip(failure.reservation_id).is_some_and( + |(surface, reservation_id)| { + matches!(&self.drag, Some(Drag::PtyMouse { surface: active_surface, reservation_id: active_reservation, .. }) if *active_surface == surface && *active_reservation == reservation_id) + }, + ); + if failed_active_press || failure.lane_failed { + self.drag = None; + } + self.status_message = Some( + if failure.label == "attach surface" + && failure.delivery == PtyOperationDelivery::Ambiguous + { + format!( + "surface attach outcome is unknown; detach and reconnect before sending more input: {}", + failure.error + ) + } else { + format!("{} failed: {}", failure.label, failure.error) + }, + ); + RenderAction::Draw + } + fn apply_mux_titles(&mut self) -> bool { let titles = self.mux_titles.take(); if titles.is_empty() { @@ -2064,10 +2203,54 @@ impl App { } fn replace_authoritative_tree(&mut self, tree: TreeView, routing_generation: u64) { + let live_surfaces = tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .map(|tab| tab.surface) + .collect::>(); + let removed_surfaces = self + .tab_locations + .keys() + .copied() + .filter(|surface| !live_surfaces.contains(surface)) + .collect::>(); + for surface in removed_surfaces { + self.retire_surface_state(surface); + } self.replace_tree(tree); + self.session.reconcile_exited_surfaces(&self.tree); self.applied_routing_generation = self.applied_routing_generation.max(routing_generation); } + fn retire_surface_state(&mut self, surface: SurfaceId) { + if matches!(&self.drag, Some(Drag::PtyMouse { surface: active, .. }) if *active == surface) + { + self.cancel_pty_release_reservation(); + self.drag = None; + } + self.render_states.remove(&surface); + self.session.forget_surface(surface); + if self.sidebar_plugin_surface == Some(surface) { + self.session.invalidate_sidebar_plugin_sync(); + self.sidebar_plugin_surface = None; + self.sidebar_plugin_error = Some("sidebar plugin exited".to_string()); + self.sidebar_focused = false; + } + if self.selection.is_some_and(|selection| selection.surface == surface) { + self.selection = None; + } + if self.omnibar.as_ref().is_some_and(|state| state.surface == surface) { + self.omnibar = None; + } + if self.last_browser_hover.is_some_and(|(hovered, _, _)| hovered == surface) { + self.last_browser_hover = None; + } + self.browser_input.forget_surface(surface); + } + fn rebuild_tab_locations(&mut self) { self.tab_locations.clear(); for (workspace_index, workspace) in self.tree.workspaces.iter().enumerate() { @@ -2480,6 +2663,7 @@ impl App { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if (self.session.has_pending_mutations() || self.session.remote_tree_is_stale() + || self.mux_recovery_pending.load(Ordering::Acquire) || self.routing_refresh_pending) && !self.input_can_update_pending_mutation(&input) => { @@ -2496,6 +2680,7 @@ impl App { Ok(tree) => { let empty = tree.workspaces.is_empty(); self.replace_authoritative_tree(tree, routing_generation); + self.mux_recovery_pending.store(false, Ordering::Release); if empty { self.quit = true; return Ok(RenderAction::None); @@ -2520,29 +2705,8 @@ impl App { Ok(RenderAction::None) } AppEvent::Mux(MuxEvent::SurfaceExited(id)) => { - if matches!(&self.drag, Some(Drag::PtyMouse { surface, .. }) if *surface == id) { - self.cancel_pty_release_reservation(); - self.drag = None; - } - self.render_states.remove(&id); - self.session.forget_surface(id); + self.retire_surface_state(id); self.remove_surface_from_tree(id); - if self.sidebar_plugin_surface == Some(id) { - self.session.invalidate_sidebar_plugin_sync(); - self.sidebar_plugin_surface = None; - self.sidebar_plugin_error = Some("sidebar plugin exited".to_string()); - self.sidebar_focused = false; - } - if self.selection.is_some_and(|s| s.surface == id) { - self.selection = None; - } - if self.omnibar.as_ref().is_some_and(|state| state.surface == id) { - self.omnibar = None; - } - if self.last_browser_hover.is_some_and(|(surface, _, _)| surface == id) { - self.last_browser_hover = None; - } - self.browser_input.forget_surface(id); Ok(RenderAction::Draw) } AppEvent::Mux(MuxEvent::Status(message)) => { @@ -2575,39 +2739,8 @@ impl App { )); Ok(RenderAction::Draw) } - AppEvent::PtyOperationFailed(failure) => { - if failure.label == "relaunch sidebar plugin" { - self.sidebar_focus_pending = false; - } - if failure.kind == Some(PtyInputKind::Motion) - && failure.delivery == PtyOperationDelivery::KnownNotDelivered - { - self.mouse_encoder.reset_motion_dedupe(); - } - let failed_active_press = failure.kind == Some(PtyInputKind::Press) - && failure.delivery == PtyOperationDelivery::KnownNotDelivered - && failure.surface_id.zip(failure.reservation_id).is_some_and( - |(surface, reservation_id)| { - matches!(&self.drag, Some(Drag::PtyMouse { surface: active_surface, reservation_id: active_reservation, .. }) if *active_surface == surface && *active_reservation == reservation_id) - }, - ); - if failed_active_press || failure.lane_failed { - self.drag = None; - } - self.status_message = Some( - if failure.label == "attach surface" - && failure.delivery == PtyOperationDelivery::Ambiguous - { - format!( - "surface attach outcome is unknown; detach and reconnect before sending more input: {}", - failure.error - ) - } else { - format!("{} failed: {}", failure.label, failure.error) - }, - ); - Ok(RenderAction::Draw) - } + AppEvent::PtyFailuresReady => Ok(self.apply_pty_failures()), + AppEvent::PtyOperationFailed(failure) => Ok(self.apply_pty_operation_failure(failure)), AppEvent::SessionMutationSettled(outcome) => { self.session.settle_pending_mutation(); match outcome { @@ -5485,13 +5618,16 @@ impl App { } fn surface_kind(&self, surface: SurfaceId) -> Option { - self.tree - .workspaces - .iter() - .flat_map(|workspace| workspace.screens.iter()) - .flat_map(|screen| screen.panes.iter()) - .flat_map(|pane| pane.tabs.iter()) - .find(|tab| tab.surface == surface) + self.tab_locations + .get(&surface) + .and_then(|[workspace, screen, pane, tab]| { + self.tree + .workspaces + .get(*workspace) + .and_then(|workspace| workspace.screens.get(*screen)) + .and_then(|screen| screen.panes.get(*pane)) + .and_then(|pane| pane.tabs.get(*tab)) + }) .map(|tab| tab.kind) .or_else(|| self.session.surface(surface).map(|surface| surface.kind())) } @@ -5647,12 +5783,13 @@ fn browser_key_mapping( mod tests { use super::{ App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, - PendingSessionMutation, RenderAction, SessionCompletion, SessionCompletionAction, - SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, - forward_mux_events, pane_parts_for_rect, + PendingSessionMutation, PendingSessionMutationState, PtyFailureIngress, RenderAction, + SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, + browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, + pane_parts_for_rect, }; use std::collections::{HashMap, HashSet, VecDeque}; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -6109,20 +6246,69 @@ mod tests { let (events, receiver) = std::sync::mpsc::sync_channel(1); events.send(AppEvent::MuxTitlesReady).unwrap(); let pending_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(1)); - let cancellation_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancellation_pending = Arc::new(AtomicBool::new(false)); - drop(PendingSessionMutation { + drop(PendingSessionMutation(Arc::new(PendingSessionMutationState { events, pending_mutations: pending_mutations.clone(), cancellation_pending: cancellation_pending.clone(), - settled: false, - }); + settled: AtomicBool::new(false), + }))); assert_eq!(pending_mutations.load(Ordering::Acquire), 0); assert!(cancellation_pending.load(Ordering::Acquire)); assert!(matches!(receiver.try_recv(), Ok(AppEvent::MuxTitlesReady))); } + #[test] + fn superseded_mutation_settles_without_canceling_session_input() { + let (events, receiver) = std::sync::mpsc::sync_channel(1); + let pending_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(1)); + let cancellation_pending = Arc::new(AtomicBool::new(false)); + let pending = PendingSessionMutation(Arc::new(PendingSessionMutationState { + events, + pending_mutations: pending_mutations.clone(), + cancellation_pending: cancellation_pending.clone(), + settled: AtomicBool::new(false), + })); + + pending.clone().supersede(); + drop(pending); + + assert_eq!(pending_mutations.load(Ordering::Acquire), 0); + assert!(!cancellation_pending.load(Ordering::Acquire)); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn pty_failure_ingress_stays_bounded_and_does_not_block_on_a_full_app_channel() { + let ingress = PtyFailureIngress::default(); + let (events, receiver) = std::sync::mpsc::sync_channel(1); + events.send(AppEvent::MuxTitlesReady).unwrap(); + for index in 0..1_000 { + let wake = ingress.push(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Motion), + reservation_id: None, + label: "PTY input", + error: format!("motion {index}"), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + }); + if wake { + assert!(matches!( + events.try_send(AppEvent::PtyFailuresReady), + Err(std::sync::mpsc::TrySendError::Full(_)) + )); + } + } + + let failures = ingress.take(); + assert_eq!(failures.len(), 1); + assert_eq!(failures.front().unwrap().error, "motion 999"); + assert!(matches!(receiver.try_recv(), Ok(AppEvent::MuxTitlesReady))); + } + #[test] fn deferred_input_is_discarded_when_its_destination_changes() { let mux = Mux::new("deferred-destination-test", SurfaceOptions::default()); @@ -6306,8 +6492,17 @@ mod tests { let (tx, rx) = std::sync::mpsc::sync_channel(1); let titles = Arc::new(MuxTitleIngress::default()); let routing_generation = Arc::new(AtomicU64::new(0)); + let recovery_pending = Arc::new(AtomicBool::new(false)); + let forwarder_recovery_pending = recovery_pending.clone(); let forwarder = std::thread::spawn(move || { - forward_mux_events(event_source, session_events, routing_generation, tx, titles); + forward_mux_events( + event_source, + session_events, + routing_generation, + forwarder_recovery_pending, + tx, + titles, + ); }); for surface in 0..5_000 { @@ -6323,6 +6518,7 @@ mod tests { } } assert!(recovered); + assert!(recovery_pending.load(Ordering::Acquire)); mux.emit(MuxEvent::Bell(9_999)); assert!(matches!( @@ -6338,6 +6534,38 @@ mod tests { forwarder.join().unwrap(); } + #[test] + fn mux_recovery_barrier_defers_input_until_authoritative_tree_is_applied() { + let mux = Mux::new("mux-recovery-barrier-test", SurfaceOptions::default()); + mux.new_workspace(None, None).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + app.mux_recovery_pending.store(true, Ordering::Release); + + app.handle(AppEvent::Input(Event::Paste("queued".to_string()))).unwrap(); + assert_eq!(app.deferred_input.len(), 1); + + app.handle(AppEvent::MuxSubscriptionRecovered { + routing_generation: 0, + result: Ok(app.session.tree()), + }) + .unwrap(); + assert!(!app.mux_recovery_pending.load(Ordering::Acquire)); + } + + #[test] + fn authoritative_recovery_prunes_render_state_for_missed_surface_exit() { + let mux = Mux::new("authoritative-prune-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(notify_tree(42, false)); + app.render_states.insert(42, RenderState::new().unwrap()); + + app.replace_authoritative_tree(TreeView::default(), 0); + + assert!(!app.render_states.contains_key(&42)); + assert!(!app.tab_locations.contains_key(&42)); + } + #[test] fn resize_claims_skip_identical_work_and_replace_a_b_a_reversions() { let mux = Mux::new("resize-claim-test", SurfaceOptions::default()); @@ -7170,14 +7398,10 @@ mod tests { app.session.settle_split_ratio(); release_tx.send(()).unwrap(); - let mut canceled = 0; let mut sample_without_tree = 0; let mut authoritative_snapshots = 0; - for _ in 0..4 { + for _ in 0..2 { match events.recv_timeout(Duration::from_secs(1)).unwrap() { - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Canceled) => { - canceled += 1; - } AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Success { tree: None, }) => sample_without_tree += 1, @@ -7188,7 +7412,6 @@ mod tests { } } - assert_eq!(canceled, 2, "only the latest deferred ratio should execute"); assert_eq!(sample_without_tree, 1, "the retained sample must not build a tree"); assert_eq!(authoritative_snapshots, 1, "final settlement must snapshot exactly once"); } @@ -7639,6 +7862,8 @@ mod tests { applied_routing_generation: 0, pending_session_completions: VecDeque::new(), mux_titles: Arc::new(MuxTitleIngress::default()), + pty_failures: Arc::new(PtyFailureIngress::default()), + mux_recovery_pending: Arc::new(AtomicBool::new(false)), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 43f353e35c96..5ec14be5f775 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -50,6 +50,7 @@ pub struct PtyInputEvent { pub bytes: PtyInputBytes, pub kind: PtyInputKind, mutation: Option anyhow::Result<()> + Send>>, + on_superseded: Option>, label: &'static str, coalesce_key: Option<(&'static str, u64)>, remote: bool, @@ -70,6 +71,7 @@ impl PtyInputEvent { bytes, kind, mutation: None, + on_superseded: None, label: "PTY input", coalesce_key: None, remote, @@ -88,11 +90,22 @@ impl PtyInputEvent { event } + #[cfg(test)] fn mutation( label: &'static str, coalesce_key: Option<(&'static str, u64)>, remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, + ) -> Self { + Self::mutation_with_superseded(label, coalesce_key, remote, None, operation) + } + + fn mutation_with_superseded( + label: &'static str, + coalesce_key: Option<(&'static str, u64)>, + remote: bool, + on_superseded: Option>, + operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) -> Self { Self { surface_id: 0, @@ -100,6 +113,7 @@ impl PtyInputEvent { bytes: PtyInputBytes::new(), kind: PtyInputKind::Mutation, mutation: Some(Box::new(operation)), + on_superseded, label, coalesce_key, remote, @@ -302,6 +316,9 @@ impl PtyInputSender { (PtyInputEnqueueResult::Saturated, None) }; drop(state); + if let Some(on_superseded) = outcome.superseded { + on_superseded(); + } for failure in outcome.evicted { (self.on_failure)(failure); } @@ -320,7 +337,7 @@ impl PtyInputSender { remote: bool, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) { - let _ = self.enqueue_mutation_with_key(label, None, remote, operation); + let _ = self.enqueue_mutation_with_key(label, None, remote, None, operation); } pub fn enqueue_coalescing_mutation( @@ -328,9 +345,16 @@ impl PtyInputSender { label: &'static str, key: (&'static str, u64), remote: bool, + on_superseded: impl FnOnce() + Send + 'static, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) -> PtyInputEnqueueResult { - self.enqueue_mutation_with_key(label, Some(key), remote, operation) + self.enqueue_mutation_with_key( + label, + Some(key), + remote, + Some(Box::new(on_superseded)), + operation, + ) } fn enqueue_mutation_with_key( @@ -338,9 +362,16 @@ impl PtyInputSender { label: &'static str, key: Option<(&'static str, u64)>, remote: bool, + on_superseded: Option>, operation: impl FnOnce() -> anyhow::Result<()> + Send + 'static, ) -> PtyInputEnqueueResult { - let result = self.enqueue(PtyInputEvent::mutation(label, key, remote, operation)); + let result = self.enqueue(PtyInputEvent::mutation_with_superseded( + label, + key, + remote, + on_superseded, + operation, + )); if result != PtyInputEnqueueResult::Accepted { (self.on_failure)(PtyOperationFailure { surface_id: None, @@ -384,20 +415,24 @@ fn enqueue_bounded( capacity: usize, max_bytes: usize, ) -> bool { - enqueue_bounded_with_evictions( + let outcome = enqueue_bounded_with_evictions( events, queued_bytes, release_reservations, event, capacity, max_bytes, - ) - .accepted + ); + if let Some(on_superseded) = outcome.superseded { + on_superseded(); + } + outcome.accepted } struct BoundedEnqueueOutcome { accepted: bool, evicted: Vec, + superseded: Option>, } fn enqueue_bounded_with_evictions( @@ -412,8 +447,9 @@ fn enqueue_bounded_with_evictions( if let Some(key) = event.coalesce_key { for index in (0..events.len()).rev() { if events[index].coalesce_key == Some(key) { - events[index] = event; - return BoundedEnqueueOutcome { accepted: true, evicted }; + let mut replaced = std::mem::replace(&mut events[index], event); + let superseded = replaced.on_superseded.take(); + return BoundedEnqueueOutcome { accepted: true, evicted, superseded }; } if events[index].coalesce_key.is_none() { break; @@ -430,11 +466,11 @@ fn enqueue_bounded_with_evictions( + event.bytes.len() + release_reservations.len() * RESERVED_RELEASE_BYTES; if projected_bytes > max_bytes { - return BoundedEnqueueOutcome { accepted: false, evicted }; + return BoundedEnqueueOutcome { accepted: false, evicted, superseded: None }; } *queued_bytes = queued_bytes.saturating_sub(previous_len) + event.bytes.len(); *events.back_mut().unwrap() = event; - return BoundedEnqueueOutcome { accepted: true, evicted }; + return BoundedEnqueueOutcome { accepted: true, evicted, superseded: None }; } let merge_stream = event.kind == PtyInputKind::Ordered @@ -468,7 +504,7 @@ fn enqueue_bounded_with_evictions( while projected > capacity || projected_bytes > max_bytes { let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) else { - return BoundedEnqueueOutcome { accepted: false, evicted }; + return BoundedEnqueueOutcome { accepted: false, evicted, superseded: None }; }; let removed = events.remove(index).unwrap(); *queued_bytes = queued_bytes.saturating_sub(removed.bytes.len()); @@ -502,7 +538,7 @@ fn enqueue_bounded_with_evictions( *queued_bytes += event.bytes.len(); events.push_back(event); } - BoundedEnqueueOutcome { accepted: true, evicted } + BoundedEnqueueOutcome { accepted: true, evicted, superseded: None } } fn worker(queue: Arc, on_failure: Arc) { @@ -843,6 +879,43 @@ mod tests { assert_eq!(events.len(), 4); } + #[test] + fn coalescing_mutation_reports_the_replaced_sample_as_superseded() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = ReleaseReservations::default(); + let superseded = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let replaced = superseded.clone(); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + PtyInputEvent::mutation_with_superseded( + "resize old", + Some(("resize", 1)), + false, + Some(Box::new(move || { + replaced.store(true, std::sync::atomic::Ordering::Release); + })), + || Ok(()), + ), + 8, + 1024, + )); + assert!(enqueue_bounded( + &mut events, + &mut queued_bytes, + &mut releases, + PtyInputEvent::mutation("resize latest", Some(("resize", 1)), false, || Ok(())), + 8, + 1024, + )); + + assert!(superseded.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!(events.len(), 1); + assert_eq!(events[0].label, "resize latest"); + } + #[test] fn accepted_press_guarantees_its_release_slot() { let mut events = VecDeque::new(); From 286d00536bb59d489e75f729242a5e91dd1f0fcb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:11:26 -0700 Subject: [PATCH 050/103] Order TUI recovery and mouse mode state --- cmux-tui/crates/cmux-tui/src/app.rs | 247 +++++++++++++++++---- cmux-tui/crates/ghostty-vt/src/mouse.rs | 28 ++- cmux-tui/crates/ghostty-vt/src/terminal.rs | 119 +++++++++- 3 files changed, 346 insertions(+), 48 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 938407855f87..bd21ea908cef 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -64,9 +64,13 @@ pub enum AppEvent { Mux(MuxEvent), MuxTitlesReady, MuxSubscriptionRecovered { + recovery_generation: u64, routing_generation: u64, result: Result, }, + MuxRecoveryComplete { + recovery_generation: u64, + }, Input(Event), BrowserResizeFailed(BrowserResizeFailure), PtyFailuresReady, @@ -92,49 +96,76 @@ fn forward_mux_events( event_source: Session, mut session_events: cmux_tui_core::MuxEventReceiver, routing_mutation_committed: Arc, - mux_recovery_pending: Arc, + mux_recovery_generation: Arc, tx: SyncSender, mux_titles: Arc, ) { + let mut next_recovery_generation = 0_u64; loop { while let Ok(event) = session_events.recv() { - match event { - MuxEvent::TitleChanged { surface, title } => { - if mux_titles.push(surface, title) && tx.send(AppEvent::MuxTitlesReady).is_err() - { - return; - } - continue; - } - MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), - _ => {} - } - let terminal = matches!(event, MuxEvent::Empty); - if tx.send(AppEvent::Mux(event)).is_err() { - return; - } - if terminal { + if !forward_mux_event(event, &tx, &mux_titles) { return; } } if !session_events.overflowed() { return; } - mux_recovery_pending.store(true, Ordering::Release); + next_recovery_generation = next_recovery_generation.wrapping_add(1).max(1); + let recovery_generation = next_recovery_generation; + mux_recovery_generation.store(recovery_generation, Ordering::Release); // Subscribe before fetching the authoritative tree so events emitted // during recovery are retained by the new mailbox. session_events = event_source.events(); let routing_generation = routing_mutation_committed.load(Ordering::Acquire); let recovered = event_source.refresh_tree().map_err(|error| error.to_string()); + let recovery_succeeded = recovered.is_ok(); if tx - .send(AppEvent::MuxSubscriptionRecovered { routing_generation, result: recovered }) + .send(AppEvent::MuxSubscriptionRecovered { + recovery_generation, + routing_generation, + result: recovered, + }) .is_err() { return; } + if !recovery_succeeded { + continue; + } + for event in session_events.try_iter() { + if !forward_mux_event(event, &tx, &mux_titles) { + return; + } + } + if session_events.overflowed() { + continue; + } + // A prior title wake may precede the authoritative snapshot. Queue a + // post-snapshot wake so retained title changes cannot be overwritten. + if tx.send(AppEvent::MuxTitlesReady).is_err() + || tx.send(AppEvent::MuxRecoveryComplete { recovery_generation }).is_err() + { + return; + } } } +fn forward_mux_event( + event: MuxEvent, + tx: &SyncSender, + mux_titles: &MuxTitleIngress, +) -> bool { + match event { + MuxEvent::TitleChanged { surface, title } => { + return !mux_titles.push(surface, title) || tx.send(AppEvent::MuxTitlesReady).is_ok(); + } + MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), + _ => {} + } + let terminal = matches!(event, MuxEvent::Empty); + tx.send(AppEvent::Mux(event)).is_ok() && !terminal +} + #[derive(Default)] struct MuxTitleIngressState { wake_queued: bool, @@ -394,6 +425,10 @@ struct SidebarPluginSyncClaim { applied: bool, } +fn sidebar_plugin_status_settles_passive_claim(status: &SidebarPluginSurface) -> bool { + (status.surface_id.is_some() && status.error.is_none()) || status.retry_after_ms.is_none() +} + impl SidebarPluginSyncClaim { fn mark_applied(&mut self) { let mut state = self.state.lock().unwrap(); @@ -1014,10 +1049,10 @@ impl OrderedSession { let operation = move || { let mut claim = claim; let status = session.sidebar_plugin(size, relaunch); - let synchronized = status.surface_id.is_some() && status.error.is_none(); + let settles_passive_claim = sidebar_plugin_status_settles_passive_claim(&status); let _ = events.send(AppEvent::SidebarPluginUpdated { status, relaunch }); committed_mutation_generation.fetch_add(1, Ordering::AcqRel); - if synchronized && let Some(claim) = &mut claim { + if settles_passive_claim && let Some(claim) = &mut claim { claim.mark_applied(); } pending.settle(SessionMutationOutcome::Success { tree: None }); @@ -1607,7 +1642,7 @@ pub struct App { pending_session_completions: VecDeque, mux_titles: Arc, pty_failures: Arc, - mux_recovery_pending: Arc, + mux_recovery_generation: Arc, drag: Option, encoder: KeyEncoder, mouse_encoder: MouseEncoder, @@ -1777,13 +1812,13 @@ pub fn run( let session = OrderedSession::new(session, pty_input.sender(), tx.clone()); let stdout_lock = Arc::new(Mutex::new(())); let mux_titles = Arc::new(MuxTitleIngress::default()); - let mux_recovery_pending = Arc::new(AtomicBool::new(false)); + let mux_recovery_generation = Arc::new(AtomicU64::new(0)); // Session events → app channel. let event_source = session.inner.clone(); let session_events = event_source.events(); let routing_mutation_committed = session.routing_mutation_committed.clone(); - let mux_recovery_barrier = mux_recovery_pending.clone(); + let mux_recovery_sequence = mux_recovery_generation.clone(); std::thread::Builder::new().name("mux-events".into()).spawn({ let tx = tx.clone(); let mux_titles = mux_titles.clone(); @@ -1792,7 +1827,7 @@ pub fn run( event_source, session_events, routing_mutation_committed, - mux_recovery_barrier, + mux_recovery_sequence, tx, mux_titles, ); @@ -1900,7 +1935,7 @@ pub fn run( pending_session_completions: VecDeque::new(), mux_titles, pty_failures, - mux_recovery_pending, + mux_recovery_generation, drag: None, encoder, mouse_encoder, @@ -2428,6 +2463,9 @@ impl App { fn reload_config(&mut self) { let mut config = crate::config::load(); config.apply_chrome_defaults(self.chrome); + self.sidebar_plugin_error = None; + self.sidebar_plugin_retry_after_ms = None; + self.sidebar_plugin_retry_at = None; self.session.apply_config(config.clone()); self.config = config; } @@ -2562,7 +2600,9 @@ impl App { if rect.width == 0 || rect.height == 0 { return false; } - if !relaunch && self.sidebar_plugin_retry_at.is_some() { + let terminal_failure = + self.sidebar_plugin_error.is_some() && self.sidebar_plugin_retry_after_ms.is_none(); + if !relaunch && (self.sidebar_plugin_retry_at.is_some() || terminal_failure) { return false; } if relaunch && !self.prepare_pty_input_before_mutation() { @@ -2663,7 +2703,7 @@ impl App { AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if (self.session.has_pending_mutations() || self.session.remote_tree_is_stale() - || self.mux_recovery_pending.load(Ordering::Acquire) + || self.mux_recovery_generation.load(Ordering::Acquire) != 0 || self.routing_refresh_pending) && !self.input_can_update_pending_mutation(&input) => { @@ -2675,12 +2715,18 @@ impl App { AppEvent::MuxTitlesReady => { Ok(if self.apply_mux_titles() { RenderAction::Paint } else { RenderAction::None }) } - AppEvent::MuxSubscriptionRecovered { routing_generation, result } => { + AppEvent::MuxSubscriptionRecovered { + recovery_generation, + routing_generation, + result, + } => { + if recovery_generation != self.mux_recovery_generation.load(Ordering::Acquire) { + return Ok(RenderAction::None); + } match result { Ok(tree) => { let empty = tree.workspaces.is_empty(); self.replace_authoritative_tree(tree, routing_generation); - self.mux_recovery_pending.store(false, Ordering::Release); if empty { self.quit = true; return Ok(RenderAction::None); @@ -2690,12 +2736,43 @@ impl App { ); } Err(error) => { - self.status_message = - Some(format!("Mux event backlog overflowed; recovery failed: {error}")); + if self + .mux_recovery_generation + .compare_exchange( + recovery_generation, + 0, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return Ok(RenderAction::None); + } + self.deferred_input.clear(); + self.prefix_armed = false; + self.session.invalidate_remote_tree(); + self.session.refresh_remote_tree_if_stale(); + self.status_message = Some(format!( + "Mux event backlog recovery failed; queued input was discarded while retrying: {error}" + )); } } Ok(RenderAction::Draw) } + AppEvent::MuxRecoveryComplete { recovery_generation } => { + if recovery_generation != self.mux_recovery_generation.load(Ordering::Acquire) { + return Ok(RenderAction::None); + } + if self + .mux_recovery_generation + .compare_exchange(recovery_generation, 0, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(RenderAction::None); + } + self.routing_refresh_pending = true; + Ok(RenderAction::Draw) + } AppEvent::SidebarPluginUpdated { status, relaunch } => { self.apply_sidebar_plugin_status(status, relaunch); Ok(RenderAction::Draw) @@ -5784,9 +5861,9 @@ mod tests { use super::{ App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, PendingSessionMutation, PendingSessionMutationState, PtyFailureIngress, RenderAction, - SessionCompletion, SessionCompletionAction, SurfaceResizeDecision, - browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, - pane_parts_for_rect, + SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, SidebarPluginSyncState, + SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, + forward_mux_events, pane_parts_for_rect, sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -6492,14 +6569,14 @@ mod tests { let (tx, rx) = std::sync::mpsc::sync_channel(1); let titles = Arc::new(MuxTitleIngress::default()); let routing_generation = Arc::new(AtomicU64::new(0)); - let recovery_pending = Arc::new(AtomicBool::new(false)); - let forwarder_recovery_pending = recovery_pending.clone(); + let recovery_generation = Arc::new(AtomicU64::new(0)); + let forwarder_recovery_generation = recovery_generation.clone(); let forwarder = std::thread::spawn(move || { forward_mux_events( event_source, session_events, routing_generation, - forwarder_recovery_pending, + forwarder_recovery_generation, tx, titles, ); @@ -6518,7 +6595,15 @@ mod tests { } } assert!(recovered); - assert!(recovery_pending.load(Ordering::Acquire)); + assert_eq!(recovery_generation.load(Ordering::Acquire), 1); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::MuxTitlesReady + )); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::MuxRecoveryComplete { recovery_generation: 1 } + )); mux.emit(MuxEvent::Bell(9_999)); assert!(matches!( @@ -6540,17 +6625,56 @@ mod tests { mux.new_workspace(None, None).unwrap(); let mut app = test_app(Session::Local(mux)); app.replace_tree(app.session.tree()); - app.mux_recovery_pending.store(true, Ordering::Release); + app.mux_recovery_generation.store(1, Ordering::Release); app.handle(AppEvent::Input(Event::Paste("queued".to_string()))).unwrap(); assert_eq!(app.deferred_input.len(), 1); app.handle(AppEvent::MuxSubscriptionRecovered { + recovery_generation: 1, routing_generation: 0, result: Ok(app.session.tree()), }) .unwrap(); - assert!(!app.mux_recovery_pending.load(Ordering::Acquire)); + assert_eq!(app.mux_recovery_generation.load(Ordering::Acquire), 1); + assert_eq!(app.deferred_input.len(), 1); + + app.handle(AppEvent::MuxRecoveryComplete { recovery_generation: 1 }).unwrap(); + assert_eq!(app.mux_recovery_generation.load(Ordering::Acquire), 0); + assert!(app.routing_refresh_pending); + } + + #[test] + fn failed_mux_recovery_releases_barrier_and_discards_ambiguous_input() { + let mux = Mux::new("mux-recovery-failure-test", SurfaceOptions::default()); + mux.new_workspace(None, None).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + app.mux_recovery_generation.store(1, Ordering::Release); + app.handle(AppEvent::Input(Event::Paste("queued".to_string()))).unwrap(); + + app.handle(AppEvent::MuxSubscriptionRecovered { + recovery_generation: 1, + routing_generation: 0, + result: Err("refresh failed".to_string()), + }) + .unwrap(); + + assert_eq!(app.mux_recovery_generation.load(Ordering::Acquire), 0); + assert!(app.deferred_input.is_empty()); + assert!(app.status_message.as_deref().unwrap().contains("discarded")); + } + + #[test] + fn stale_mux_recovery_completion_cannot_release_a_newer_barrier() { + let mux = Mux::new("stale-mux-recovery-completion-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.mux_recovery_generation.store(2, Ordering::Release); + + app.handle(AppEvent::MuxRecoveryComplete { recovery_generation: 1 }).unwrap(); + + assert_eq!(app.mux_recovery_generation.load(Ordering::Acquire), 2); + assert!(!app.routing_refresh_pending); } #[test] @@ -7034,6 +7158,45 @@ mod tests { assert!(app.session.sidebar_plugin_sync.lock().unwrap().applied.is_none()); } + #[test] + fn terminal_sidebar_failure_settles_passive_sync_claim() { + let desired = ((24, 8), 0, 0); + let state = Arc::new(Mutex::new(SidebarPluginSyncState { + epoch: 0, + claimed: Some(desired), + applied: None, + })); + let mut claim = SidebarPluginSyncClaim { state: state.clone(), desired, applied: false }; + let status = SidebarPluginSurface { + surface_id: None, + error: Some("terminal failure".to_string()), + retry_after_ms: None, + }; + + assert!(sidebar_plugin_status_settles_passive_claim(&status)); + claim.mark_applied(); + drop(claim); + + let state = state.lock().unwrap(); + assert_eq!(state.applied, Some(desired)); + assert!(state.claimed.is_none()); + drop(state); + + let mux = Mux::new("terminal-sidebar-failure-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.plugin = Some(cmux_tui_core::SidebarPluginOptions { + command: vec!["unused".to_string()], + cwd: None, + }); + app.sidebar_visible = true; + app.sidebar_width = 12; + app.content_area.height = 8; + app.apply_sidebar_plugin_status(status, false); + + assert!(!app.sync_sidebar_plugin(false)); + assert!(!app.session.has_pending_mutations()); + } + #[test] fn hiding_then_showing_sidebar_rehydrates_same_size_plugin_status() { let mux = Mux::new("sidebar-plugin-hide-show-test", SurfaceOptions::default()); @@ -7863,7 +8026,7 @@ mod tests { pending_session_completions: VecDeque::new(), mux_titles: Arc::new(MuxTitleIngress::default()), pty_failures: Arc::new(PtyFailureIngress::default()), - mux_recovery_pending: Arc::new(AtomicBool::new(false)), + mux_recovery_generation: Arc::new(AtomicU64::new(0)), drag: None, encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index 50e48cd6454f..bd0a77c408ab 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -44,7 +44,7 @@ pub struct MouseInput { pub struct MouseEncoder { encoder: sys::GhosttyMouseEncoder, event: sys::GhosttyMouseEvent, - terminal_state: Option<(usize, [bool; 8])>, + terminal_state: Option<(usize, u64)>, size: Option<((u32, u32), (u32, u32))>, } @@ -70,10 +70,7 @@ impl MouseEncoder { } pub fn sync_from_terminal(&mut self, terminal: &Terminal) { - let state = ( - terminal.raw() as usize, - [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016].map(|mode| terminal.mode(mode, false)), - ); + let state = (terminal.raw() as usize, terminal.mouse_mode_revision()); if self.terminal_state == Some(state) { return; } @@ -289,6 +286,27 @@ mod tests { assert_eq!(out, b"\x1b[<35;10;6M"); } + #[test] + fn reasserted_mouse_mode_resynchronizes_last_set_precedence() { + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1000h\x1b[?1002h\x1b[?1006h"); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + let mut event = input(MouseAction::Motion, Some(MouseButton::Left)); + event.any_button_pressed = true; + let mut out = Vec::new(); + + encoder.encode(event, &mut out).unwrap(); + assert!(!out.is_empty(), "button tracking must report drag motion"); + + terminal.vt_write(b"\x1b[?1000h"); + encoder.sync_from_terminal(&terminal); + out.clear(); + encoder.encode(event, &mut out).unwrap(); + + assert!(out.is_empty(), "reasserted normal tracking must suppress motion"); + } + #[test] fn reset_allows_same_cell_motion_to_be_encoded_again() { let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 4f3d5832d9c4..922250dae903 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -65,9 +65,99 @@ pub struct Callbacks { pub on_bell: Option, } +#[derive(Default)] +enum MouseModeScan { + #[default] + Ground, + Escape, + Csi { + private: bool, + at_start: bool, + parameter: u16, + has_parameter: bool, + has_mouse_mode: bool, + }, +} + +impl MouseModeScan { + fn feed(&mut self, data: &[u8]) -> bool { + let mut changed = false; + for &byte in data { + let state = std::mem::take(self); + *self = match state { + Self::Ground => match byte { + 0x1b => Self::Escape, + 0x9b => Self::csi(), + _ => Self::Ground, + }, + Self::Escape => match byte { + b'[' => Self::csi(), + 0x1b => Self::Escape, + _ => Self::Ground, + }, + Self::Csi { + mut private, + mut at_start, + mut parameter, + mut has_parameter, + mut has_mouse_mode, + } => match byte { + b'?' if at_start => { + private = true; + at_start = false; + Self::Csi { private, at_start, parameter, has_parameter, has_mouse_mode } + } + b'0'..=b'9' => { + at_start = false; + has_parameter = true; + parameter = + parameter.saturating_mul(10).saturating_add(u16::from(byte - b'0')); + Self::Csi { private, at_start, parameter, has_parameter, has_mouse_mode } + } + b';' => { + has_mouse_mode |= + private && has_parameter && Self::is_mouse_mode(parameter); + at_start = false; + parameter = 0; + has_parameter = false; + Self::Csi { private, at_start, parameter, has_parameter, has_mouse_mode } + } + 0x40..=0x7e => { + has_mouse_mode |= + private && has_parameter && Self::is_mouse_mode(parameter); + if has_mouse_mode && matches!(byte, b'h' | b'l') { + changed = true; + } + Self::Ground + } + 0x1b => Self::Escape, + _ => Self::Ground, + }, + }; + } + changed + } + + fn csi() -> Self { + Self::Csi { + private: false, + at_start: true, + parameter: 0, + has_parameter: false, + has_mouse_mode: false, + } + } + + fn is_mouse_mode(mode: u16) -> bool { + matches!(mode, 9 | 1000 | 1002 | 1003 | 1005 | 1006 | 1015 | 1016) + } +} + /// A terminal instance: VT parser plus full screen/scrollback state. pub struct Terminal { raw: sys::GhosttyTerminal, + mouse_mode_revision: u64, + mouse_mode_scan: MouseModeScan, // Heap-pinned so the userdata pointer stays valid for the terminal's // lifetime. callbacks: Box, @@ -114,7 +204,12 @@ impl Terminal { sys::GhosttyTerminalOptions { cols: cols.max(1), rows: rows.max(1), max_scrollback }; check(unsafe { sys::ghostty_terminal_new(ptr::null(), &mut raw, opts) })?; - let mut term = Terminal { raw, callbacks: Box::new(callbacks) }; + let mut term = Terminal { + raw, + mouse_mode_revision: 0, + mouse_mode_scan: MouseModeScan::default(), + callbacks: Box::new(callbacks), + }; let userdata = &mut *term.callbacks as *mut Callbacks as *mut c_void; unsafe { sys::ghostty_terminal_set(raw, sys::GHOSTTY_TERMINAL_OPT_USERDATA, userdata); @@ -141,11 +236,18 @@ impl Terminal { self.raw } + pub(crate) fn mouse_mode_revision(&self) -> u64 { + self.mouse_mode_revision + } + /// Feed VT-encoded bytes (pty output) into the terminal. pub fn vt_write(&mut self, data: &[u8]) { if data.is_empty() { return; } + if self.mouse_mode_scan.feed(data) { + self.mouse_mode_revision = self.mouse_mode_revision.wrapping_add(1); + } unsafe { sys::ghostty_terminal_vt_write(self.raw, data.as_ptr(), data.len()) } } @@ -482,3 +584,18 @@ impl Drop for Terminal { } } } + +#[cfg(test)] +mod tests { + use super::MouseModeScan; + + #[test] + fn mouse_mode_scan_tracks_split_private_mode_sequences() { + let mut scan = MouseModeScan::default(); + + assert!(!scan.feed(b"\x1b[?10")); + assert!(scan.feed(b"00;1006h")); + assert!(!scan.feed(b"ordinary output\x1b[31m")); + assert!(scan.feed(b"\x9b?1002l")); + } +} From aa08a2e911f3efff52725fe521df4c62ea42116a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:42:42 -0700 Subject: [PATCH 051/103] Bound browser and mouse recovery state --- cmux-tui/crates/cmux-tui/src/browser_input.rs | 41 +++++++++++---- cmux-tui/crates/cmux-tui/src/session/mod.rs | 38 ++++++++++---- .../crates/cmux-tui/src/session/remote.rs | 9 ---- cmux-tui/crates/ghostty-vt/src/mouse.rs | 21 ++++++++ cmux-tui/crates/ghostty-vt/src/terminal.rs | 52 +++++++++++++++++-- 5 files changed, 128 insertions(+), 33 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 98682a129fb8..44a4911b6f54 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -98,7 +98,7 @@ struct BrowserEnqueueOrder { struct FailedBrowserResize { desired: (u16, u16), attempts: u8, - retry_after: Instant, + retry_after: Option, } fn next_failed_browser_resize( @@ -113,12 +113,14 @@ fn next_failed_browser_resize( FailedBrowserResize { desired, attempts, - retry_after: Instant::now() + Duration::from_secs(delay_seconds.min(30)), + retry_after: (attempts < 6) + .then(|| Instant::now() + Duration::from_secs(delay_seconds.min(30))), } } fn failed_browser_resize_blocks(failure: FailedBrowserResize, desired: (u16, u16)) -> bool { - failure.desired == desired && Instant::now() < failure.retry_after + failure.desired == desired + && failure.retry_after.is_none_or(|retry_after| Instant::now() < retry_after) } impl BrowserInputKind { @@ -234,7 +236,11 @@ impl BrowserInputDispatcher { /// a failed resize does not depend on unrelated user input to run again. pub fn resize_retry_due(&self) -> bool { let now = Instant::now(); - self.failed_resizes.lock().unwrap().values().any(|failure| failure.retry_after <= now) + self.failed_resizes + .lock() + .unwrap() + .values() + .any(|failure| failure.retry_after.is_some_and(|retry_after| retry_after <= now)) } /// Expired failures for hidden surfaces are retired. A later layout pass @@ -243,9 +249,12 @@ impl BrowserInputDispatcher { let now = Instant::now(); let mut failures = self.failed_resizes.lock().unwrap(); failures.retain(|surface, failure| { - failure.retry_after > now || visible_surfaces.contains(surface) + failure.retry_after.is_some_and(|retry_after| retry_after > now) + || visible_surfaces.contains(surface) }); - failures.values().any(|failure| failure.retry_after <= now) + failures + .values() + .any(|failure| failure.retry_after.is_some_and(|retry_after| retry_after <= now)) } pub fn forget_surface(&self, surface_id: SurfaceId) { @@ -650,7 +659,7 @@ mod tests { assert!(failure_rx.try_recv().is_err()); dispatcher.failed_resizes.lock().unwrap().get_mut(&7).unwrap().retry_after = - Instant::now() - Duration::from_millis(1); + Some(Instant::now() - Duration::from_millis(1)); assert!(dispatcher.resize_retry_due()); assert!(!dispatcher.visible_resize_retry_due(&HashSet::new())); assert!(!dispatcher.resize_retry_due()); @@ -659,7 +668,7 @@ mod tests { FailedBrowserResize { desired: (100, 24), attempts: 1, - retry_after: Instant::now() - Duration::from_millis(1), + retry_after: Some(Instant::now() - Duration::from_millis(1)), }, ); assert!(dispatcher.visible_resize_retry_due(&HashSet::from([7]))); @@ -692,7 +701,7 @@ mod tests { FailedBrowserResize { desired: (80, 24), attempts: 1, - retry_after: Instant::now() + Duration::from_secs(1), + retry_after: Some(Instant::now() + Duration::from_secs(1)), }, ); @@ -708,6 +717,20 @@ mod tests { assert!(queued.load(Ordering::Acquire)); } + #[test] + fn persistent_resize_failure_requires_geometry_or_lifecycle_recovery() { + let mut failure = None; + for _ in 0..6 { + failure = Some(next_failed_browser_resize(failure, (100, 24))); + } + let failure = failure.unwrap(); + + assert_eq!(failure.attempts, 6); + assert!(failure.retry_after.is_none()); + assert!(failed_browser_resize_blocks(failure, (100, 24))); + assert!(!failed_browser_resize_blocks(failure, (120, 24))); + } + #[test] fn dropped_resize_slot_delivers_latest_geometry_after_queued_input() { let mut batch = vec![sequenced(0, click_event(1))]; diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 18a2d2ed0060..8dd22334aa44 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -202,22 +202,38 @@ impl Session { retry_after_ms: None, }; }; - let surface_id = data + let requested_surface_id = data .get("surface") .and_then(serde_json::Value::as_u64) .map(|id| id as SurfaceId); - let surface = surface_id.and_then(|id| { - remote - .ensure_surface_with_kind(id, SurfaceKind::Pty, Some(size)) - .map(|surface| SurfaceHandle::Remote(surface, remote.clone())) - }); - drop(surface); + let mut error = + data.get("error").and_then(serde_json::Value::as_str).map(str::to_string); + let surface_id = match requested_surface_id { + Some(id) => { + match remote.try_ensure_surface_with_kind(id, SurfaceKind::Pty, Some(size)) + { + Ok(Some(_)) => Some(id), + Ok(None) => { + error.get_or_insert_with(|| { + format!("sidebar plugin surface {id} is unavailable") + }); + None + } + Err(attach_error) => { + error.get_or_insert_with(|| { + format!( + "sidebar plugin surface {id} attach failed: {attach_error}" + ) + }); + None + } + } + } + None => None, + }; SidebarPluginSurface { surface_id, - error: data - .get("error") - .and_then(serde_json::Value::as_str) - .map(str::to_string), + error, retry_after_ms: data.get("retry_after_ms").and_then(serde_json::Value::as_u64), } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 5a740b0f10ce..aaa4321319f9 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -690,15 +690,6 @@ impl RemoteSession { self.try_ensure_surface_with_kind(id, kind, size) } - pub fn ensure_surface_with_kind( - self: &Arc, - id: SurfaceId, - kind: SurfaceKind, - size: Option<(u16, u16)>, - ) -> Option> { - self.try_ensure_surface_with_kind(id, kind, size).ok().flatten() - } - pub fn try_ensure_surface_with_kind( self: &Arc, id: SurfaceId, diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index bd0a77c408ab..4d9fdd947ddd 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -307,6 +307,27 @@ mod tests { assert!(out.is_empty(), "reasserted normal tracking must suppress motion"); } + #[test] + fn restored_mouse_mode_resynchronizes_saved_precedence() { + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b[?1000h\x1b[?1000s\x1b[?1002h\x1b[?1006h"); + let mut encoder = MouseEncoder::new().unwrap(); + encoder.sync_from_terminal(&terminal); + let mut event = input(MouseAction::Motion, Some(MouseButton::Left)); + event.any_button_pressed = true; + let mut out = Vec::new(); + + encoder.encode(event, &mut out).unwrap(); + assert!(!out.is_empty(), "button tracking must report drag motion"); + + terminal.vt_write(b"\x1b[?1000r"); + encoder.sync_from_terminal(&terminal); + out.clear(); + encoder.encode(event, &mut out).unwrap(); + + assert!(out.is_empty(), "restored normal tracking must suppress motion"); + } + #[test] fn reset_allows_same_cell_motion_to_be_encoded_again() { let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 922250dae903..ee73b8f437fa 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -76,6 +76,7 @@ enum MouseModeScan { parameter: u16, has_parameter: bool, has_mouse_mode: bool, + soft_reset: bool, }, } @@ -93,6 +94,10 @@ impl MouseModeScan { Self::Escape => match byte { b'[' => Self::csi(), 0x1b => Self::Escape, + b'c' => { + changed = true; + Self::Ground + } _ => Self::Ground, }, Self::Csi { @@ -101,18 +106,33 @@ impl MouseModeScan { mut parameter, mut has_parameter, mut has_mouse_mode, + mut soft_reset, } => match byte { b'?' if at_start => { private = true; at_start = false; - Self::Csi { private, at_start, parameter, has_parameter, has_mouse_mode } + Self::Csi { + private, + at_start, + parameter, + has_parameter, + has_mouse_mode, + soft_reset, + } } b'0'..=b'9' => { at_start = false; has_parameter = true; parameter = parameter.saturating_mul(10).saturating_add(u16::from(byte - b'0')); - Self::Csi { private, at_start, parameter, has_parameter, has_mouse_mode } + Self::Csi { + private, + at_start, + parameter, + has_parameter, + has_mouse_mode, + soft_reset, + } } b';' => { has_mouse_mode |= @@ -120,12 +140,32 @@ impl MouseModeScan { at_start = false; parameter = 0; has_parameter = false; - Self::Csi { private, at_start, parameter, has_parameter, has_mouse_mode } + Self::Csi { + private, + at_start, + parameter, + has_parameter, + has_mouse_mode, + soft_reset, + } + } + b'!' => { + soft_reset = true; + Self::Csi { + private, + at_start, + parameter, + has_parameter, + has_mouse_mode, + soft_reset, + } } 0x40..=0x7e => { has_mouse_mode |= private && has_parameter && Self::is_mouse_mode(parameter); - if has_mouse_mode && matches!(byte, b'h' | b'l') { + if (has_mouse_mode && matches!(byte, b'h' | b'l' | b'r')) + || (soft_reset && byte == b'p') + { changed = true; } Self::Ground @@ -145,6 +185,7 @@ impl MouseModeScan { parameter: 0, has_parameter: false, has_mouse_mode: false, + soft_reset: false, } } @@ -597,5 +638,8 @@ mod tests { assert!(scan.feed(b"00;1006h")); assert!(!scan.feed(b"ordinary output\x1b[31m")); assert!(scan.feed(b"\x9b?1002l")); + assert!(scan.feed(b"\x1b[?1000r")); + assert!(scan.feed(b"\x1b[!p")); + assert!(scan.feed(b"\x1bc")); } } From eeb7e82a06b1082e06ee12fa754e5f583d929748 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:16:32 -0700 Subject: [PATCH 052/103] Make TUI mouse snapshots lifetime stable --- cmux-tui/crates/cmux-tui/src/app.rs | 83 ++++++++++++++++++---- cmux-tui/crates/ghostty-vt/src/mouse.rs | 4 +- cmux-tui/crates/ghostty-vt/src/terminal.rs | 19 ++++- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index bd21ea908cef..d9e8573d0f2b 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -4418,21 +4418,11 @@ impl App { } self.sidebar_focused = false; self.selection = None; - let release_capture = - self.capture_pty_mouse_release(area.surface, area.content, x, y, button, modifiers); + let (release_capture, forwarded) = + self.prepare_pty_mouse_press(area.surface, area.content, x, y, button, modifiers); if matches!(release_capture, PtyMouseReleaseCapture::Failed) { return PtyMousePressResult::Consumed; } - let forwarded = self.forward_pty_mouse_to_surface( - area.surface, - area.content, - x, - y, - MouseAction::Press, - Some(Self::ghostty_mouse_button(button)), - modifiers, - true, - ); if !forwarded.accepted { return PtyMousePressResult::Consumed; } @@ -4455,6 +4445,75 @@ impl App { PtyMousePressResult::Started } + fn prepare_pty_mouse_press( + &mut self, + surface_id: SurfaceId, + content: Rect, + x: u16, + y: u16, + button: MouseButton, + modifiers: KeyModifiers, + ) -> (PtyMouseReleaseCapture, PtyInputForwardResult) { + let failed = || PtyInputForwardResult { accepted: false, reservation_id: None }; + let Some(surface) = self.session.surface(surface_id) else { + return (PtyMouseReleaseCapture::Failed, failed()); + }; + let cell_width = u32::from(self.cell_pixels.0.max(1)); + let cell_height = u32::from(self.cell_pixels.1.max(1)); + let position = ( + (x as f32 - content.x as f32 + 0.5) * cell_width as f32, + (y as f32 - content.y as f32 + 0.5) * cell_height as f32, + ); + let screen_size = ( + u32::from(content.width).saturating_mul(cell_width), + u32::from(content.height).saturating_mul(cell_height), + ); + let press = MouseInput { + action: MouseAction::Press, + button: Some(Self::ghostty_mouse_button(button)), + mods: Self::ghostty_mouse_mods(modifiers), + position, + screen_size, + cell_size: (cell_width, cell_height), + any_button_pressed: true, + }; + let release = + MouseInput { action: MouseAction::Release, any_button_pressed: false, ..press }; + let Ok(mut release_encoder) = MouseEncoder::new() else { + return (PtyMouseReleaseCapture::Failed, failed()); + }; + let mut release_output = Vec::new(); + self.encode_buf.clear(); + let encoded = surface.with_terminal(|terminal| { + release_encoder.sync_from_terminal(terminal); + release_encoder.encode(release, &mut release_output)?; + self.mouse_encoder.sync_from_terminal(terminal); + self.mouse_encoder.encode(press, &mut self.encode_buf) + }); + if !matches!(encoded, Some(Ok(()))) { + return (PtyMouseReleaseCapture::Failed, failed()); + } + let release_capture = if release_output.is_empty() { + PtyMouseReleaseCapture::NotReported + } else { + PtyMouseReleaseCapture::Bytes(PtyInputBytes::from_slice(&release_output)) + }; + if self.encode_buf.is_empty() { + return ( + release_capture, + PtyInputForwardResult { accepted: true, reservation_id: None }, + ); + } + let kind = if matches!(release_capture, PtyMouseReleaseCapture::Bytes(_)) { + PtyInputKind::Press + } else { + PtyInputKind::Ordered + }; + let bytes = PtyInputBytes::from_slice(&self.encode_buf); + let forwarded = self.enqueue_pty_bytes(surface_id, surface, bytes, kind); + (release_capture, forwarded) + } + fn forward_pty_mouse_drag( &mut self, x: u16, diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index 4d9fdd947ddd..c1dca6c0919e 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -44,7 +44,7 @@ pub struct MouseInput { pub struct MouseEncoder { encoder: sys::GhosttyMouseEncoder, event: sys::GhosttyMouseEvent, - terminal_state: Option<(usize, u64)>, + terminal_state: Option<(u64, u64)>, size: Option<((u32, u32), (u32, u32))>, } @@ -70,7 +70,7 @@ impl MouseEncoder { } pub fn sync_from_terminal(&mut self, terminal: &Terminal) { - let state = (terminal.raw() as usize, terminal.mouse_mode_revision()); + let state = (terminal.instance_id(), terminal.mouse_mode_revision()); if self.terminal_state == Some(state) { return; } diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index ee73b8f437fa..771d802e95d5 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -1,10 +1,13 @@ use std::ffi::c_void; use std::ptr; +use std::sync::atomic::{AtomicU64, Ordering}; use ghostty_vt_sys as sys; use crate::{Result, check}; +static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(1); + /// RGB color triple. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Rgb { @@ -197,6 +200,7 @@ impl MouseModeScan { /// A terminal instance: VT parser plus full screen/scrollback state. pub struct Terminal { raw: sys::GhosttyTerminal, + instance_id: u64, mouse_mode_revision: u64, mouse_mode_scan: MouseModeScan, // Heap-pinned so the userdata pointer stays valid for the terminal's @@ -247,6 +251,7 @@ impl Terminal { let mut term = Terminal { raw, + instance_id: NEXT_TERMINAL_ID.fetch_add(1, Ordering::Relaxed), mouse_mode_revision: 0, mouse_mode_scan: MouseModeScan::default(), callbacks: Box::new(callbacks), @@ -281,6 +286,10 @@ impl Terminal { self.mouse_mode_revision } + pub(crate) fn instance_id(&self) -> u64 { + self.instance_id + } + /// Feed VT-encoded bytes (pty output) into the terminal. pub fn vt_write(&mut self, data: &[u8]) { if data.is_empty() { @@ -628,7 +637,7 @@ impl Drop for Terminal { #[cfg(test)] mod tests { - use super::MouseModeScan; + use super::{Callbacks, MouseModeScan, Terminal}; #[test] fn mouse_mode_scan_tracks_split_private_mode_sequences() { @@ -642,4 +651,12 @@ mod tests { assert!(scan.feed(b"\x1b[!p")); assert!(scan.feed(b"\x1bc")); } + + #[test] + fn terminal_instances_have_lifetime_stable_ids() { + let first = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + let second = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + + assert_ne!(first.instance_id(), second.instance_id()); + } } From 4666f711d279909e27a92b270e57335529b7714b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:48:47 -0700 Subject: [PATCH 053/103] Bound TUI attach and deferred routing --- cmux-tui/crates/cmux-tui-core/src/server.rs | 101 +++++++++++--- cmux-tui/crates/cmux-tui-core/src/surface.rs | 119 ++++++++++++++-- cmux-tui/crates/cmux-tui/src/app.rs | 136 +++++++++++++++---- cmux-tui/crates/cmux-tui/src/pty_input.rs | 57 +++++++- 4 files changed, 348 insertions(+), 65 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 93fb9a7b70ff..e85999d9d3ba 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::Instant; +use std::time::{Duration, Instant}; use base64::Engine; use ghostty_vt::{KeyEncoder, key_input_from_chord}; @@ -32,6 +32,7 @@ use serde_json::{Value, json}; use crate::model::{Screen, State}; use crate::platform::{self, transport}; +use crate::surface::AttachLifecycle; use crate::{ AgentRecord, AgentSource, AgentState, AttachFrame, DefaultColors, Direction, LayoutLeafSpec, LayoutSpec, Mux, MuxEvent, Node, NotificationLevel, PaneId, Rgb, ScreenId, SidebarPluginStatus, @@ -833,12 +834,18 @@ fn spawn_attach_notification_stream( mux: Arc, surface_id: SurfaceId, writer: LineWriter, + lifecycle: AttachLifecycle, ) -> std::io::Result<()> { let events = mux.subscribe(); std::thread::Builder::new() .name("mux-attach-notifications".into()) .spawn(move || { - while let Ok(event) = events.recv() { + while !lifecycle.is_canceled() { + let event = match events.recv_timeout(Duration::from_millis(100)) { + Ok(event) => event, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + }; let value = match event { MuxEvent::Notification(notification) if notification.surface == Some(surface_id) => @@ -865,21 +872,29 @@ fn spawn_attach_notification_stream( _ => continue, }; if writer.send(&value).is_err() { + lifecycle.cancel(); break; } } if events.overflowed() { - let _ = writer.send(&json!({ - "event": "overflow", - "scope": "surface", - "surface": surface_id, - "error": "surface event subscriber fell behind; reattach the surface", - })); + lifecycle.mark_overflow(); } + report_attach_overflow(&writer, surface_id, &lifecycle); }) .map(|_| ()) } +fn report_attach_overflow(writer: &LineWriter, surface_id: SurfaceId, lifecycle: &AttachLifecycle) { + if lifecycle.claim_overflow_report() { + let _ = writer.send(&json!({ + "event": "overflow", + "scope": "surface", + "surface": surface_id, + "error": "surface stream fell behind; reattach the surface", + })); + } +} + fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow::Result { match cmd { Command::Identify => Ok(json!({ @@ -966,7 +981,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: } anyhow::bail!("timeout waiting for pattern"); } - let deadline = start + std::time::Duration::from_millis(timeout_ms); + let deadline = start + Duration::from_millis(timeout_ms); let attach = surface.attach_stream()?; if let Some(text) = check()? { return Ok(json!({ @@ -1389,17 +1404,43 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: } Command::AttachSurface { surface: surface_id } => { let surface = get_surface(mux, surface_id)?; - spawn_attach_notification_stream(mux.clone(), surface_id, writer.clone())?; + let lifecycle = AttachLifecycle::default(); + spawn_attach_notification_stream( + mux.clone(), + surface_id, + writer.clone(), + lifecycle.clone(), + )?; if surface.kind() == SurfaceKind::Browser { - let (state, frames) = surface.attach_frames()?; - writer.send(&browser_state_json(surface_id, &state, true))?; + let (state, frames) = match surface.attach_frames() { + Ok(attach) => attach, + Err(error) => { + lifecycle.cancel(); + return Err(error); + } + }; + if let Err(error) = writer.send(&browser_state_json(surface_id, &state, true)) { + lifecycle.cancel(); + return Err(error.into()); + } let writer = writer.clone(); std::thread::Builder::new().name("mux-attach-out".into()).spawn(move || { - while frames.notify.recv().is_ok() { + while !lifecycle.is_canceled() { + match frames.notify.recv_timeout(Duration::from_millis(100)) { + Ok(()) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + lifecycle.cancel(); + let _ = writer + .send(&json!({"event": "detached", "surface": surface_id})); + return; + } + } let update = std::mem::take(&mut *frames.slot.lock().unwrap()); if let Some(state) = update.state && writer.send(&browser_state_json(surface_id, &state, false)).is_err() { + lifecycle.cancel(); break; } if let Some(frame) = update.frame { @@ -1412,25 +1453,45 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: "data": frame.data_b64, }); if writer.send(&value).is_err() { + lifecycle.cancel(); break; } } } - let _ = writer.send(&json!({"event": "detached", "surface": surface_id})); + report_attach_overflow(&writer, surface_id, &lifecycle); })?; return Ok(json!({})); } - let attach = surface.attach_stream()?; - writer.send(&json!({ + let attach = match surface.attach_stream_with_lifecycle(lifecycle.clone()) { + Ok(attach) => attach, + Err(error) => { + lifecycle.cancel(); + return Err(error.into()); + } + }; + if let Err(error) = writer.send(&json!({ "event": "vt-state", "surface": surface_id, "cols": attach.cols, "rows": attach.rows, "data": base64::engine::general_purpose::STANDARD.encode(attach.replay), - }))?; + })) { + lifecycle.cancel(); + return Err(error.into()); + } let writer = writer.clone(); std::thread::Builder::new().name("mux-attach-out".into()).spawn(move || { - while let Ok(frame) = attach.stream.recv() { + while !attach.lifecycle.is_canceled() { + let frame = match attach.stream.recv_timeout(Duration::from_millis(100)) { + Ok(frame) => frame, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + attach.lifecycle.cancel(); + let _ = + writer.send(&json!({"event": "detached", "surface": surface_id})); + return; + } + }; let value = match frame { AttachFrame::Output(chunk) => json!({ "event": "output", @@ -1446,11 +1507,11 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: }), }; if writer.send(&value).is_err() { + attach.lifecycle.cancel(); break; } } - // Surface gone (or reader stopped): signal end of stream. - let _ = writer.send(&json!({"event": "detached", "surface": surface_id})); + report_attach_overflow(&writer, surface_id, &attach.lifecycle); })?; Ok(json!({})) } diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index cff0e9bc05dc..ec2f4a7b1d7f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -8,6 +8,7 @@ use std::io::{Read, Write}; use std::ops::Deref; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{SyncSender, TrySendError}; use std::sync::{Arc, Mutex, Weak}; use ghostty_vt::{Callbacks, RenderState, Rgb, Terminal}; @@ -94,6 +95,7 @@ pub struct AttachStream { pub rows: u16, pub replay: Vec, pub stream: std::sync::mpsc::Receiver, + pub(crate) lifecycle: AttachLifecycle, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,6 +104,72 @@ pub enum AttachFrame { Resized { cols: u16, rows: u16, replay: Vec }, } +const ATTACH_STREAM_CAPACITY: usize = 256; + +#[derive(Clone, Default)] +pub(crate) struct AttachLifecycle { + state: Arc, +} + +#[derive(Default)] +struct AttachLifecycleState { + canceled: AtomicBool, + overflowed: AtomicBool, + overflow_reported: AtomicBool, +} + +impl AttachLifecycle { + pub(crate) fn cancel(&self) { + self.state.canceled.store(true, Ordering::Release); + } + + pub(crate) fn mark_overflow(&self) { + self.state.overflowed.store(true, Ordering::Release); + self.cancel(); + } + + pub(crate) fn is_canceled(&self) -> bool { + self.state.canceled.load(Ordering::Acquire) + } + + pub(crate) fn overflowed(&self) -> bool { + self.state.overflowed.load(Ordering::Acquire) + } + + pub(crate) fn claim_overflow_report(&self) -> bool { + self.overflowed() + && self + .state + .overflow_reported + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } +} + +struct AttachTap { + sender: SyncSender, + lifecycle: AttachLifecycle, +} + +impl AttachTap { + fn try_send(&self, frame: AttachFrame) -> bool { + if self.lifecycle.is_canceled() { + return false; + } + match self.sender.try_send(frame) { + Ok(()) => true, + Err(TrySendError::Full(_)) => { + self.lifecycle.mark_overflow(); + false + } + Err(TrySendError::Disconnected(_)) => { + self.lifecycle.cancel(); + false + } + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SurfaceKind { Pty, @@ -168,7 +236,7 @@ pub struct PtySurface { /// terminal lock, and [`Surface::attach_stream`] registers taps under /// the same lock, so a subscriber sees exactly the bytes applied /// after its replay snapshot — no gap, no duplication. - taps: Mutex>>, + taps: Mutex>, } impl std::fmt::Debug for Surface { @@ -283,11 +351,7 @@ impl Surface { term.vt_write(&buf[..n]); let after = terminal_scroll_position(&term); { - let mut taps = pty.taps.lock().unwrap(); - if !taps.is_empty() { - let frame = AttachFrame::Output(buf[..n].to_vec()); - taps.retain(|tap| tap.send(frame.clone()).is_ok()); - } + pty.broadcast_attach_frame(AttachFrame::Output(buf[..n].to_vec())); } if title_changed.swap(false, Ordering::Relaxed) { let title = term.title().unwrap_or_default(); @@ -587,17 +651,24 @@ impl Surface { /// Attach to a PTY surface: a VT replay plus a live byte stream. pub fn attach_stream(&self) -> ghostty_vt::Result { + self.attach_stream_with_lifecycle(AttachLifecycle::default()) + } + + pub(crate) fn attach_stream_with_lifecycle( + &self, + lifecycle: AttachLifecycle, + ) -> ghostty_vt::Result { let Some(pty) = self.as_pty() else { return Err(ghostty_vt::Error::InvalidValue); }; let mut term = pty.term.lock().unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::sync_channel(ATTACH_STREAM_CAPACITY); // Snapshot and tap registration under the same terminal lock: // the reader thread cannot apply bytes between the two. let replay = term.vt_replay()?; let (cols, rows) = (term.cols(), term.rows()); - pty.taps.lock().unwrap().push(tx); - Ok(AttachStream { cols, rows, replay, stream: rx }) + pty.taps.lock().unwrap().push(AttachTap { sender: tx, lifecycle: lifecycle.clone() }); + Ok(AttachStream { cols, rows, replay, stream: rx, lifecycle }) } pub fn kill(&self) { @@ -771,6 +842,10 @@ impl ChildKiller for TestChildKiller { } impl PtySurface { + fn broadcast_attach_frame(&self, frame: AttachFrame) { + self.taps.lock().unwrap().retain(|tap| tap.try_send(frame.clone())); + } + /// Resize both the PTY and the terminal state. Returns whether the /// final clamped size actually changed. fn resize(&self, cols: u16, rows: u16) -> bool { @@ -795,12 +870,7 @@ impl PtySurface { // Nominal cell metrics; only pixel size reports observe these. let _ = term.resize(cols, rows, 8, 16); let replay = term.vt_replay().unwrap_or_default(); - let mut taps = self.taps.lock().unwrap(); - if !taps.is_empty() { - taps.retain(|tap| { - tap.send(AttachFrame::Resized { cols, rows, replay: replay.clone() }).is_ok() - }); - } + self.broadcast_attach_frame(AttachFrame::Resized { cols, rows, replay }); true } } @@ -811,3 +881,22 @@ fn terminal_scroll_position(term: &Terminal) -> (u64, bool) { None => (0, true), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attach_tap_overflow_cancels_the_shared_lifecycle_once() { + let lifecycle = AttachLifecycle::default(); + let (sender, _receiver) = std::sync::mpsc::sync_channel(1); + let tap = AttachTap { sender, lifecycle: lifecycle.clone() }; + + assert!(tap.try_send(AttachFrame::Output(vec![1]))); + assert!(!tap.try_send(AttachFrame::Output(vec![2]))); + assert!(lifecycle.is_canceled()); + assert!(lifecycle.overflowed()); + assert!(lifecycle.claim_overflow_report()); + assert!(!lifecycle.claim_overflow_report()); + } +} diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index d9e8573d0f2b..28b5288709a5 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1581,6 +1581,7 @@ struct DeferredInput { event: Event, destination: Option, routing_intent: Option, + sidebar_focus_intent: bool, } pub struct App { @@ -2090,11 +2091,16 @@ impl App { break; } let Some(input) = self.deferred_input.pop_front() else { break }; - let follows_pending_route = input.routing_intent.is_some_and(|intent| { - self.session.routing_mutation_committed() >= intent - && self.session.routing_mutation_started() == intent - }); - if !follows_pending_route && self.input_destination(&input.event) != input.destination { + let follows_pending_route = matches!(&input.event, Event::Key(_) | Event::Paste(_)) + && input.routing_intent.is_some_and(|intent| { + self.session.routing_mutation_committed() >= intent + && self.session.routing_mutation_started() == intent + }); + let follows_sidebar_focus = input.sidebar_focus_intent && self.sidebar_focused; + if !follows_pending_route + && !follows_sidebar_focus + && self.input_destination(&input.event) != input.destination + { self.status_message = Some( "Deferred input was discarded because its destination changed".to_string(), ); @@ -2700,6 +2706,17 @@ impl App { self.queue_surface_attach(surface); return Ok(self.defer_input(input)); } + AppEvent::Input(input @ Event::Mouse(_)) + if (self.session.has_pending_mutations() + || self.session.remote_tree_is_stale() + || self.mux_recovery_generation.load(Ordering::Acquire) != 0 + || self.routing_refresh_pending) + && !self.input_can_update_pending_mutation(&input) => + { + self.status_message = + Some("Pointer input was discarded while the layout changed".to_string()); + return Ok(RenderAction::Draw); + } AppEvent::Input(input @ (Event::Key(_) | Event::Mouse(_) | Event::Paste(_))) if (self.session.has_pending_mutations() || self.session.remote_tree_is_stale() @@ -3060,6 +3077,8 @@ impl App { fn defer_input(&mut self, input: Event) -> RenderAction { let destination = self.input_destination(&input); + let sidebar_focus_intent = + self.sidebar_focus_pending && matches!(&input, Event::Key(_) | Event::Paste(_)); let routing_started = self.session.routing_mutation_started(); let routing_intent = (routing_started > self.applied_routing_generation).then_some(routing_started); @@ -3070,25 +3089,32 @@ impl App { event: Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, .. }), destination: previous_destination, routing_intent: previous_intent, + sidebar_focus_intent: previous_sidebar_intent, }), - ) => *previous_destination == destination && *previous_intent == routing_intent, + ) => { + *previous_destination == destination + && *previous_intent == routing_intent + && *previous_sidebar_intent == sidebar_focus_intent + } ( Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(button), .. }), Some(DeferredInput { event: Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(previous), .. }), destination: previous_destination, routing_intent: previous_intent, + sidebar_focus_intent: previous_sidebar_intent, }), ) => { button == previous && *previous_destination == destination && *previous_intent == routing_intent + && *previous_sidebar_intent == sidebar_focus_intent } _ => false, }; if replace_motion { *self.deferred_input.back_mut().unwrap() = - DeferredInput { event: input, destination, routing_intent }; + DeferredInput { event: input, destination, routing_intent, sidebar_focus_intent }; return RenderAction::None; } let input_bytes = deferred_input_bytes(&input); @@ -3111,7 +3137,12 @@ impl App { let Some(removed) = self.deferred_input.pop_front() else { break }; queued_bytes = queued_bytes.saturating_sub(deferred_input_bytes(&removed.event)); } - self.deferred_input.push_back(DeferredInput { event: input, destination, routing_intent }); + self.deferred_input.push_back(DeferredInput { + event: input, + destination, + routing_intent, + sidebar_focus_intent, + }); RenderAction::None } @@ -6923,6 +6954,7 @@ mod tests { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: Some(77), routing_intent: None, + sidebar_focus_intent: false, }); app.session.pending_mutations.store(1, Ordering::Release); @@ -7025,6 +7057,7 @@ mod tests { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, routing_intent: None, + sidebar_focus_intent: false, }); app.handle(AppEvent::RemoteTreeUpdated { @@ -7294,6 +7327,7 @@ mod tests { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: Some(surface), routing_intent: None, + sidebar_focus_intent: false, }); assert_eq!( @@ -7373,6 +7407,61 @@ mod tests { assert_eq!(app.sidebar_plugin_surface, Some(42)); } + #[test] + fn key_typed_during_pending_sidebar_focus_follows_successful_focus() { + let mux = Mux::new("sidebar-plugin-deferred-key-success-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, None).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + app.sidebar_focus_pending = true; + app.session.pending_mutations.store(1, Ordering::Release); + + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + assert!(app.deferred_input.front().unwrap().sidebar_focus_intent); + + app.session.pending_mutations.store(0, Ordering::Release); + app.sidebar_focus_pending = false; + app.sidebar_focused = true; + app.sidebar_plugin_surface = Some(surface.id); + app.replay_deferred_input().unwrap(); + + assert!(app.deferred_input.is_empty()); + assert_ne!( + app.status_message.as_deref(), + Some("Deferred input was discarded because its destination changed") + ); + } + + #[test] + fn key_typed_during_pending_sidebar_focus_keeps_pane_target_on_failure() { + let mux = Mux::new("sidebar-plugin-deferred-key-failure-test", SurfaceOptions::default()); + mux.new_workspace(None, None).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + app.sidebar_focus_pending = true; + app.session.pending_mutations.store(1, Ordering::Release); + + app.handle(AppEvent::Input(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + + app.session.pending_mutations.store(0, Ordering::Release); + app.sidebar_focus_pending = false; + app.replay_deferred_input().unwrap(); + + assert!(app.deferred_input.is_empty()); + assert_ne!( + app.status_message.as_deref(), + Some("Deferred input was discarded because its destination changed") + ); + } + #[test] fn surface_output_is_paint_only_and_preserves_the_topology_index() { let mux = Mux::new("surface-output-paint-test", SurfaceOptions::default()); @@ -7394,6 +7483,7 @@ mod tests { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, routing_intent: None, + sidebar_focus_intent: false, }); app.session .enqueue("timed out mutation", |_| Err(crate::session::test_remote_timeout_error())); @@ -7420,6 +7510,7 @@ mod tests { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, routing_intent: None, + sidebar_focus_intent: false, }); for (label, key) in [ @@ -7639,7 +7730,7 @@ mod tests { } #[test] - fn deferred_pointer_motion_keeps_only_the_latest_sample() { + fn pointer_motion_is_discarded_while_a_mutation_can_change_its_target() { let mux = Mux::new("deferred-motion-test", SurfaceOptions::default()); let (mut app, events) = test_app_with_events(Session::Local(mux)); let (started_tx, started_rx) = std::sync::mpsc::channel(); @@ -7651,21 +7742,19 @@ mod tests { }); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - for column in [4, 9] { - app.handle(AppEvent::Input(Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - column, - row: 3, - modifiers: KeyModifiers::NONE, - }))) - .unwrap(); - } + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 9, + row: 3, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); - assert_eq!(app.deferred_input.len(), 1); - assert!(matches!( - app.deferred_input.front(), - Some(DeferredInput { event: Event::Mouse(MouseEvent { column: 9, .. }), .. }) - )); + assert!(app.deferred_input.is_empty()); + assert_eq!( + app.status_message.as_deref(), + Some("Pointer input was discarded while the layout changed") + ); release_tx.send(()).unwrap(); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); app.handle(settled).unwrap(); @@ -7759,6 +7848,7 @@ mod tests { event: Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), destination: None, routing_intent: None, + sidebar_focus_intent: false, }); app.handle(AppEvent::SessionMutationSettled( diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 5ec14be5f775..5629650df4b6 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -444,12 +444,14 @@ fn enqueue_bounded_with_evictions( max_bytes: usize, ) -> BoundedEnqueueOutcome { let mut evicted = Vec::new(); + let mut replaced = None; if let Some(key) = event.coalesce_key { for index in (0..events.len()).rev() { if events[index].coalesce_key == Some(key) { - let mut replaced = std::mem::replace(&mut events[index], event); - let superseded = replaced.on_superseded.take(); - return BoundedEnqueueOutcome { accepted: true, evicted, superseded }; + let previous = events.remove(index).unwrap(); + *queued_bytes = queued_bytes.saturating_sub(previous.bytes.len()); + replaced = Some((index, previous)); + break; } if events[index].coalesce_key.is_none() { break; @@ -466,11 +468,16 @@ fn enqueue_bounded_with_evictions( + event.bytes.len() + release_reservations.len() * RESERVED_RELEASE_BYTES; if projected_bytes > max_bytes { + if let Some((index, previous)) = replaced.take() { + *queued_bytes += previous.bytes.len(); + events.insert(index, previous); + } return BoundedEnqueueOutcome { accepted: false, evicted, superseded: None }; } *queued_bytes = queued_bytes.saturating_sub(previous_len) + event.bytes.len(); *events.back_mut().unwrap() = event; - return BoundedEnqueueOutcome { accepted: true, evicted, superseded: None }; + let superseded = replaced.as_mut().and_then(|(_, previous)| previous.on_superseded.take()); + return BoundedEnqueueOutcome { accepted: true, evicted, superseded }; } let merge_stream = event.kind == PtyInputKind::Ordered @@ -504,6 +511,10 @@ fn enqueue_bounded_with_evictions( while projected > capacity || projected_bytes > max_bytes { let Some(index) = events.iter().position(|queued| queued.kind == PtyInputKind::Motion) else { + if let Some((index, previous)) = replaced.take() { + *queued_bytes += previous.bytes.len(); + events.insert(index, previous); + } return BoundedEnqueueOutcome { accepted: false, evicted, superseded: None }; }; let removed = events.remove(index).unwrap(); @@ -538,7 +549,8 @@ fn enqueue_bounded_with_evictions( *queued_bytes += event.bytes.len(); events.push_back(event); } - BoundedEnqueueOutcome { accepted: true, evicted, superseded: None } + let superseded = replaced.as_mut().and_then(|(_, previous)| previous.on_superseded.take()); + BoundedEnqueueOutcome { accepted: true, evicted, superseded } } fn worker(queue: Arc, on_failure: Arc) { @@ -857,8 +869,8 @@ mod tests { } assert_eq!(events.len(), 2); - assert_eq!(events[0].label, "resize one latest"); - assert_eq!(events[1].label, "resize two"); + assert_eq!(events[0].label, "resize two"); + assert_eq!(events[1].label, "resize one latest"); assert!(enqueue_bounded( &mut events, @@ -916,6 +928,37 @@ mod tests { assert_eq!(events[0].label, "resize latest"); } + #[test] + fn rejected_coalescing_replacement_restores_the_previous_sample() { + let mut events = VecDeque::new(); + let mut queued_bytes = 0; + let mut releases = ReleaseReservations::default(); + let superseded = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let replaced = superseded.clone(); + let mut previous = PtyInputEvent::mutation_with_superseded( + "resize old", + Some(("resize", 1)), + false, + Some(Box::new(move || { + replaced.store(true, std::sync::atomic::Ordering::Release); + })), + || Ok(()), + ); + previous.bytes = PtyInputBytes::from_slice(&[1]); + assert!(enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, previous, 8, 1,)); + + let mut too_large = + PtyInputEvent::mutation("resize latest", Some(("resize", 1)), false, || Ok(())); + too_large.bytes = PtyInputBytes::from_slice(&[2, 3]); + assert!(!enqueue_bounded(&mut events, &mut queued_bytes, &mut releases, too_large, 8, 1,)); + + assert!(!superseded.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!(queued_bytes, 1); + assert_eq!(events.len(), 1); + assert_eq!(events[0].label, "resize old"); + assert_eq!(events[0].bytes.as_slice(), &[1]); + } + #[test] fn accepted_press_guarantees_its_release_slot() { let mut events = VecDeque::new(); From abf223208a14a2015fb28e0bb961c84a728d3071 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:04:00 -0700 Subject: [PATCH 054/103] Clean up merged TUI input sender --- cmux-tui/crates/cmux-tui/src/app.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 2e3cea69136d..c104dadb4a96 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1894,7 +1894,6 @@ pub fn run( // Crossterm input → app channel. Start this after startup terminal // probes so DA / window-size responses are not consumed as key input. std::thread::Builder::new().name("input".into()).spawn({ - let tx = tx.clone(); move || { while let Ok(event) = crossterm::event::read() { if tx.send(AppEvent::Input(event)).is_err() { From ff2530fcc531c5222fd2dbb0bef74b3b0468e61c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:13:21 -0700 Subject: [PATCH 055/103] Recover bounded TUI event streams --- cmux-tui/bindings/typescript/src/client.ts | 19 ++++- .../bindings/typescript/test/client.test.ts | 37 +++++++++ cmux-tui/crates/cmux-tui-core/src/server.rs | 82 ++++++++++++++----- .../crates/cmux-tui/src/session/remote.rs | 42 +++++++++- 4 files changed, 156 insertions(+), 24 deletions(-) diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index 64362eda9617..d06997cda0f7 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -387,7 +387,8 @@ export class CmuxClient { return this.openStream( { cmd: "subscribe", ...options }, (event) => event as SubscribeEvent, - (event, dedicated) => dedicated || !this.attachOnlyEvent(event.event), + (event, dedicated) => dedicated + || (!this.attachOnlyEvent(event.event) && !this.isSurfaceOverflow(event)), ); } @@ -400,7 +401,7 @@ export class CmuxClient { { cmd: "attach-surface", surface }, (event) => this.decodeAttachEvent(event as AttachEvent), (event, dedicated) => dedicated || this.matchesAttachEvent(event, surface), - (event) => event.event === "detached", + (event) => event.event === "detached" || this.isSurfaceOverflow(event, surface), ); } @@ -491,7 +492,19 @@ export class CmuxClient { private matchesAttachEvent(event: UnknownEvent, surface: Id): boolean { if (!("surface" in event) || event.surface !== surface) return false; - return this.attachOnlyEvent(event.event) || event.event === "scroll-changed"; + return this.attachOnlyEvent(event.event) + || event.event === "scroll-changed" + || this.isSurfaceOverflow(event, surface); + } + + private isSurfaceOverflow( + event: { event: string; scope?: unknown; surface?: unknown }, + surface?: Id, + ): boolean { + return event.event === "overflow" + && event.scope === "surface" + && "surface" in event + && (surface === undefined || event.surface === surface); } private attachOnlyEvent(event: string): boolean { diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index 70361c6cbe9d..3af3ddb5e272 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -54,6 +54,43 @@ test("attachSurface decodes VT, output, and resized payloads", async () => { await client.close(); }); +test("surface overflow terminates only the matching shared attach stream", async () => { + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 6, session: "main", pid: 1 }, + }); + return; + } + assert.ok(request.cmd === "attach-surface" || request.cmd === "subscribe"); + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + const attach = await client.attachSurface(7); + const subscription = await client.subscribe(); + + transport.emit({ + event: "overflow", + scope: "surface", + surface: 7, + error: "surface stream fell behind", + }); + transport.emit({ event: "overflow", error: "subscriber fell behind" }); + + const attachOverflow = await attach.next(); + assert.equal(attachOverflow.event, "overflow"); + await assert.rejects(() => attach.next(), /stream is closed/); + const subscriptionOverflow = await subscription.next(); + assert.equal(subscriptionOverflow.event, "overflow"); + if (subscriptionOverflow.event === "overflow") { + assert.equal(subscriptionOverflow.scope, undefined); + } + subscription.close(); + await client.close(); +}); + test("generic request preserves exact wire command and typed result", async () => { let sent: Record | undefined; const transport = new ScriptedTransport((request, connection) => { diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 8c188c3e39fd..062ba5fefe4a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -23,7 +23,7 @@ use std::io::{BufRead, BufReader, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{Sender, TryRecvError, channel}; +use std::sync::mpsc::{SyncSender, TryRecvError, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -439,7 +439,11 @@ impl MessageWriter { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - self.sink.send(value) + let result = self.sink.send(value); + if result.is_err() { + self.close(); + } + result } fn is_open(&self) -> bool { @@ -466,13 +470,21 @@ impl MessageSink for LineSink { fn close(&self) {} } -struct WebSocketSink(Sender); +const WEBSOCKET_OUTBOUND_CAPACITY: usize = 256; + +struct WebSocketSink(SyncSender); impl MessageSink for WebSocketSink { fn send(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; - self.0.send(text).map_err(|_| { - std::io::Error::new(std::io::ErrorKind::BrokenPipe, "WebSocket connection closed") + self.0.try_send(text).map_err(|error| match error { + TrySendError::Full(_) => std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "WebSocket outbound queue overflowed", + ), + TrySendError::Disconnected(_) => { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "WebSocket connection closed") + } }) } @@ -638,7 +650,7 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); - let (outbound_tx, outbound_rx) = channel(); + let (outbound_tx, outbound_rx) = sync_channel(WEBSOCKET_OUTBOUND_CAPACITY); let writer = MessageWriter::new(WebSocketSink(outbound_tx)); 'connection: while writer.is_open() { @@ -1086,8 +1098,8 @@ fn spawn_attach_notification_stream( } _ => continue, }; - if writer.send(&value).is_err() { - lifecycle.cancel(); + if let Err(error) = writer.send(&value) { + handle_attach_send_error(&lifecycle, &error); break; } } @@ -1114,6 +1126,14 @@ fn report_attach_overflow( } } +fn handle_attach_send_error(lifecycle: &AttachLifecycle, error: &std::io::Error) { + if error.kind() == std::io::ErrorKind::WouldBlock { + lifecycle.mark_overflow(); + } else { + lifecycle.cancel(); + } +} + fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyhow::Result { match cmd { Command::Identify => Ok(json!({ @@ -1644,7 +1664,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho } }; if let Err(error) = writer.send(&browser_state_json(surface_id, &state, true)) { - lifecycle.cancel(); + handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } let writer = writer.clone(); @@ -1663,11 +1683,12 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho } } let update = std::mem::take(&mut *frames.slot.lock().unwrap()); - if let Some(state) = update.state - && writer.send(&browser_state_json(surface_id, &state, false)).is_err() - { - lifecycle.cancel(); - break; + if let Some(state) = update.state { + let value = browser_state_json(surface_id, &state, false); + if let Err(error) = writer.send(&value) { + handle_attach_send_error(&lifecycle, &error); + break; + } } if let Some(frame) = update.frame { let value = json!({ @@ -1678,8 +1699,8 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho "height": frame.css_height, "data": frame.data_b64, }); - if writer.send(&value).is_err() { - lifecycle.cancel(); + if let Err(error) = writer.send(&value) { + handle_attach_send_error(&lifecycle, &error); break; } } @@ -1702,7 +1723,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho "rows": attach.rows, "data": base64::engine::general_purpose::STANDARD.encode(attach.replay), })) { - lifecycle.cancel(); + handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } let writer = writer.clone(); @@ -1734,8 +1755,8 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho "replay": base64::engine::general_purpose::STANDARD.encode(replay), }), }; - if writer.send(&value).is_err() { - attach.lifecycle.cancel(); + if let Err(error) = writer.send(&value) { + handle_attach_send_error(&attach.lifecycle, &error); break; } } @@ -1843,6 +1864,29 @@ mod tests { )))) } + #[test] + fn websocket_writer_closes_when_bounded_queue_overflows() { + let (sender, _receiver) = sync_channel(1); + let writer = MessageWriter::new(WebSocketSink(sender)); + + writer.send(&json!({"event": "first"})).unwrap(); + let error = writer.send(&json!({"event": "second"})).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + assert!(!writer.is_open()); + } + + #[test] + fn websocket_overflow_marks_attach_lifecycle() { + let lifecycle = AttachLifecycle::default(); + let error = std::io::Error::new(std::io::ErrorKind::WouldBlock, "queue full"); + + handle_attach_send_error(&lifecycle, &error); + + assert!(lifecycle.is_canceled()); + assert!(lifecycle.overflowed()); + } + #[test] fn ping_returns_version_and_protocol() { let mux = test_mux(); diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index aaa4321319f9..08feb2c1f942 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -540,9 +540,14 @@ impl RemoteSession { } Some("overflow") => { if value.get("scope").and_then(Value::as_str) == Some("surface") { - let surface = surface_id().map_or_else(String::new, |id| format!(" {id}")); + let surface_id = surface_id(); + if let Some(surface_id) = surface_id { + self.surfaces.lock().unwrap().remove(&surface_id); + self.emit(MuxEvent::SurfaceOutput(surface_id)); + } + let surface = surface_id.map_or_else(String::new, |id| format!(" {id}")); self.emit(MuxEvent::Status(format!( - "surface{surface} event stream overflowed; global events remain active" + "surface{surface} event stream overflowed; mirror will reattach" ))); return; } @@ -1264,6 +1269,39 @@ mod tests { assert!(received.iter().any(|event| matches!(event, MuxEvent::TreeChanged))); } + #[cfg(unix)] + #[test] + fn surface_overflow_invalidates_mirror_and_requests_reattach() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + session.surfaces.lock().unwrap().insert( + 7, + Arc::new(RemoteSurface { + id: 7, + kind: SurfaceKind::Pty, + term: Mutex::new(Terminal::new(80, 24, 100, Callbacks::default()).unwrap()), + dirty: AtomicBool::new(false), + server_size: Mutex::new((80, 24)), + asserted_size: Mutex::new(None), + browser: Mutex::new(RemoteBrowserState::default()), + }), + ); + + session.handle_line(json!({ + "event": "overflow", + "scope": "surface", + "surface": 7, + "error": "surface stream fell behind", + })); + + assert!(!session.has_surface(7)); + assert!(!session.exited_surfaces.lock().unwrap().contains(&7)); + let received = events.try_iter().collect::>(); + assert!(received.iter().any(|event| matches!(event, MuxEvent::SurfaceOutput(7)))); + assert!(received.iter().any(|event| matches!(event, MuxEvent::Status(_)))); + } + #[test] fn ordered_resize_replay_recovers_from_stale_initial_replay() { let mut server = Terminal::new(12, 3, 100, Callbacks::default()).unwrap(); From 486867cccd1303d9fb37204672375bf49b32dc70 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:20:02 -0700 Subject: [PATCH 056/103] Scope attach events and preserve pointer state --- .../crates/cmux-tui-core/src/event_bus.rs | 68 +++++++++++++++++-- cmux-tui/crates/cmux-tui-core/src/mux.rs | 4 ++ cmux-tui/crates/cmux-tui-core/src/server.rs | 2 +- cmux-tui/crates/cmux-tui/src/app.rs | 56 +++++++++++++-- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs index ebf2e0afdc67..50882eea9e05 100644 --- a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -12,7 +12,18 @@ const MAX_PENDING_EVENTS: usize = 4_096; #[derive(Default)] pub struct MuxEventBroadcaster { - subscribers: Mutex>>, + subscribers: Mutex>, +} + +struct MuxEventSubscriber { + mailbox: Weak, + filter: MuxEventFilter, +} + +#[derive(Clone, Copy)] +enum MuxEventFilter { + All, + AttachedSurface(SurfaceId), } pub struct MuxEventReceiver { @@ -39,24 +50,48 @@ struct MuxEventMailboxState { impl MuxEventBroadcaster { pub fn subscribe(&self) -> MuxEventReceiver { + self.subscribe_with_filter(MuxEventFilter::All) + } + + pub fn subscribe_attached_surface(&self, surface: SurfaceId) -> MuxEventReceiver { + self.subscribe_with_filter(MuxEventFilter::AttachedSurface(surface)) + } + + fn subscribe_with_filter(&self, filter: MuxEventFilter) -> MuxEventReceiver { let mailbox = Arc::new(MuxEventMailbox::default()); - self.subscribers.lock().unwrap().push(Arc::downgrade(&mailbox)); + self.subscribers + .lock() + .unwrap() + .push(MuxEventSubscriber { mailbox: Arc::downgrade(&mailbox), filter }); MuxEventReceiver { mailbox } } pub fn emit(&self, event: MuxEvent) { let mut subscribers = self.subscribers.lock().unwrap(); subscribers.retain(|subscriber| { - let Some(mailbox) = subscriber.upgrade() else { return false }; - mailbox.push(event.clone()) + let Some(mailbox) = subscriber.mailbox.upgrade() else { return false }; + !subscriber.filter.accepts(&event) || mailbox.push(event.clone()) }); } } +impl MuxEventFilter { + fn accepts(self, event: &MuxEvent) -> bool { + match self { + Self::All => true, + Self::AttachedSurface(surface) => match event { + MuxEvent::Notification(notification) => notification.surface == Some(surface), + MuxEvent::ScrollChanged { surface: event_surface, .. } => *event_surface == surface, + _ => false, + }, + } + } +} + impl Drop for MuxEventBroadcaster { fn drop(&mut self) { for subscriber in self.subscribers.get_mut().unwrap().drain(..) { - if let Some(mailbox) = subscriber.upgrade() { + if let Some(mailbox) = subscriber.mailbox.upgrade() { mailbox.close(); } } @@ -361,6 +396,29 @@ mod tests { assert!(matches!(fast.recv().unwrap(), MuxEvent::Bell(9_999))); } + #[test] + fn attached_surface_subscription_filters_before_bounded_mailbox() { + let broadcaster = MuxEventBroadcaster::default(); + let events = broadcaster.subscribe_attached_surface(7); + + for index in 0..=MAX_PENDING_EVENTS { + broadcaster.emit(MuxEvent::Bell(index as SurfaceId)); + broadcaster.emit(MuxEvent::ScrollChanged { + surface: 8, + offset: index as u64, + at_bottom: false, + }); + } + broadcaster.emit(MuxEvent::ScrollChanged { surface: 7, offset: 42, at_bottom: false }); + + assert!(matches!( + events.recv().unwrap(), + MuxEvent::ScrollChanged { surface: 7, offset: 42, at_bottom: false } + )); + assert!(!events.overflowed()); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + } + #[test] fn empty_preempts_a_full_mailbox() { let broadcaster = MuxEventBroadcaster::default(); diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index a7c124b2547d..cab304888f56 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -300,6 +300,10 @@ impl Mux { self.subscribers.subscribe() } + pub fn subscribe_attached_surface(&self, surface: SurfaceId) -> MuxEventReceiver { + self.subscribers.subscribe_attached_surface(surface) + } + pub fn emit(&self, event: MuxEvent) { self.subscribers.emit(event); } diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 062ba5fefe4a..7b8749b637fe 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -1063,7 +1063,7 @@ fn spawn_attach_notification_stream( writer: MessageWriter, lifecycle: AttachLifecycle, ) -> std::io::Result<()> { - let events = mux.subscribe(); + let events = mux.subscribe_attached_surface(surface_id); std::thread::Builder::new() .name("mux-attach-notifications".into()) .spawn(move || { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index c104dadb4a96..c0f5bc953948 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3157,6 +3157,13 @@ impl App { .. }), Some(Drag::ResizeSplit { .. }) + ) | ( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left), + .. + }), + Some(Drag::Select { .. }) ) | ( Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left) @@ -4814,7 +4821,7 @@ impl App { &mut self, x: u16, y: u16, - _reported_button: MouseButton, + reported_button: MouseButton, modifiers: KeyModifiers, ) -> bool { let Some(Drag::PtyMouse { @@ -4825,6 +4832,9 @@ impl App { }; let (surface, reservation_id, fallback, content, button) = (*surface, *reservation_id, release_bytes.clone(), *content, *button); + if reported_button != button { + return true; + } let content = self.current_pty_content(surface).unwrap_or(content); let release = self.capture_pty_mouse_release(surface, content, x, y, button, modifiers); self.drag = None; @@ -6176,9 +6186,10 @@ mod tests { use super::{ App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, PendingSessionMutation, PendingSessionMutationState, PtyFailureIngress, RenderAction, - SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, SidebarPluginSyncState, - SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, - forward_mux_events, pane_parts_for_rect, sidebar_plugin_status_settles_passive_claim, + Selection, SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, + SidebarPluginSyncState, SurfaceResizeDecision, browser_content_size_for_rect, + browser_hover_forward_allowed, forward_mux_events, pane_parts_for_rect, + sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -6327,6 +6338,10 @@ mod tests { ); assert!(app.encode_buf.is_empty()); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert!(app.encode_buf.is_empty()); + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Right), KeyModifiers::NONE)) + .unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); @@ -8014,6 +8029,37 @@ mod tests { assert!(app.drag.is_none()); } + #[test] + fn selection_drag_and_release_bypass_a_pending_focus_mutation() { + let mux = Mux::new("selection-release-barrier-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.session.pending_mutations.store(1, Ordering::Release); + let content = Rect { x: 2, y: 3, width: 20, height: 8 }; + app.selection = Some(Selection { surface: surface.id, anchor: (1, 1), head: (1, 1) }); + app.drag = Some(Drag::Select { content, auto_scroll: None, col: 1 }); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 8, + row: 6, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + assert!(app.deferred_input.is_empty()); + assert_eq!(app.selection.map(|selection| selection.head), Some((6, 3))); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 8, + row: 6, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + assert!(app.deferred_input.is_empty()); + assert!(app.drag.is_none()); + } + #[test] fn pty_drag_motion_and_release_bypass_pending_mutation_with_pinned_surface() { let mux = Mux::new("pty-release-barrier-test", SurfaceOptions::default()); @@ -8040,7 +8086,7 @@ mod tests { assert!(matches!(app.drag, Some(Drag::PtyMouse { surface: 42, position: (8, 6), .. }))); app.handle(AppEvent::Input(Event::Mouse(MouseEvent { - kind: MouseEventKind::Up(MouseButton::Left), + kind: MouseEventKind::Up(MouseButton::Right), column: 8, row: 6, modifiers: KeyModifiers::NONE, From 14fcce5e26f6e36aa3c8fa31f4cbb38883e47c2d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:32 -0700 Subject: [PATCH 057/103] Deliver terminal overflow recovery events --- cmux-tui/bindings/go/client.go | 21 +++++- cmux-tui/bindings/go/client_test.go | 30 ++++++++ .../java/src/com/cmux/CmuxClient.java | 16 +++- .../java/tests/com/cmux/StreamOpenTest.java | 40 ++++++++++ cmux-tui/bindings/python/cmux/client.py | 7 +- cmux-tui/bindings/python/tests/test_events.py | 22 +++++- cmux-tui/bindings/rust/src/lib.rs | 52 +++++++++++-- cmux-tui/crates/cmux-tui-core/src/server.rs | 74 +++++++++++++++---- 8 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 cmux-tui/bindings/go/client_test.go diff --git a/cmux-tui/bindings/go/client.go b/cmux-tui/bindings/go/client.go index 3682c9904a78..ee9870483294 100644 --- a/cmux-tui/bindings/go/client.go +++ b/cmux-tui/bindings/go/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -398,17 +399,25 @@ type Stream struct { conn *jsonLineConn timeout time.Duration buffered []Event + closed bool } func (s *Stream) Close() error { + if s.closed { + return nil + } + s.closed = true return s.conn.Close() } func (s *Stream) Recv(ctx context.Context) (Event, error) { + if s.closed { + return nil, io.EOF + } if len(s.buffered) > 0 { event := s.buffered[0] s.buffered = s.buffered[1:] - return event, nil + return s.finishTerminal(event), nil } for { value, err := s.conn.Recv(ctx, s.timeout) @@ -416,11 +425,19 @@ func (s *Stream) Recv(ctx context.Context) (Event, error) { return nil, err } if _, ok := value["event"].(string); ok { - return parseEvent(value), nil + return s.finishTerminal(parseEvent(value)), nil } } } +func (s *Stream) finishTerminal(event Event) Event { + switch event.(type) { + case DetachedEvent, OverflowEvent: + _ = s.Close() + } + return event +} + type jsonLineConn struct { conn net.Conn reader *bufio.Reader diff --git a/cmux-tui/bindings/go/client_test.go b/cmux-tui/bindings/go/client_test.go new file mode 100644 index 000000000000..189381f4d708 --- /dev/null +++ b/cmux-tui/bindings/go/client_test.go @@ -0,0 +1,30 @@ +package cmux + +import ( + "bufio" + "context" + "errors" + "io" + "net" + "testing" +) + +func TestStreamYieldsBufferedOverflowOnceThenStops(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + stream := &Stream{ + conn: &jsonLineConn{conn: client, reader: bufio.NewReader(client)}, + buffered: []Event{OverflowEvent{Error: "fell behind"}}, + } + + event, err := stream.Recv(context.Background()) + if err != nil { + t.Fatalf("first Recv() error = %v", err) + } + if _, ok := event.(OverflowEvent); !ok { + t.Fatalf("first Recv() event = %#v", event) + } + if _, err := stream.Recv(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("second Recv() error = %v, want io.EOF", err) + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java index 61b1a4901eb6..b6f8b6ae5912 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java @@ -520,6 +520,7 @@ public CmuxClient build() throws CmuxException { public static final class CmuxStream implements AutoCloseable { private final JsonLineConnection connection; private final ArrayDeque buffered; + private boolean finished; private CmuxStream(JsonLineConnection connection, ArrayDeque buffered) { this.connection = connection; @@ -554,17 +555,28 @@ static CmuxStream open(String socketPath, Duration timeout, Map } public CmuxEvent next(Duration timeout) throws CmuxException { + if (finished) { + throw new CmuxException("stream is closed"); + } if (!buffered.isEmpty()) { - return buffered.removeFirst(); + return finishTerminal(buffered.removeFirst()); } while (true) { Map response = connection.recv(timeout); if (response.containsKey("event")) { - return CmuxEvent.from(response); + return finishTerminal(CmuxEvent.from(response)); } } } + private CmuxEvent finishTerminal(CmuxEvent event) throws CmuxException { + if (event instanceof OverflowEvent || "detached".equals(event.event())) { + finished = true; + connection.close(); + } + return event; + } + @Override public void close() throws CmuxException { connection.close(); diff --git a/cmux-tui/bindings/java/tests/com/cmux/StreamOpenTest.java b/cmux-tui/bindings/java/tests/com/cmux/StreamOpenTest.java index 4e42b6b3de5f..fb499f79ca5b 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/StreamOpenTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/StreamOpenTest.java @@ -17,6 +17,7 @@ public final class StreamOpenTest { public static void main(String[] args) throws Exception { runBufferedPreAckEventTest(); runLargePreAckEventTest(); + runOverflowTerminatesStreamTest(); } private static void runBufferedPreAckEventTest() throws Exception { @@ -77,6 +78,33 @@ private static void runLargePreAckEventTest() throws Exception { } } + private static void runOverflowTerminatesStreamTest() throws Exception { + Path socket = Files.createTempFile("cmux-java-stream-overflow", ".sock"); + Files.deleteIfExists(socket); + ServerSocketChannel listener = ServerSocketChannel.open(StandardProtocolFamily.UNIX); + listener.bind(UnixDomainSocketAddress.of(socket)); + Thread server = new Thread(() -> runOverflowServer(listener), "stream-overflow-test-server"); + server.setDaemon(true); + server.start(); + try { + Map request = new LinkedHashMap<>(); + request.put("id", 9); + request.put("cmd", "subscribe"); + try (CmuxClient.CmuxStream stream = CmuxClient.CmuxStream.open(socket.toString(), Duration.ofSeconds(1), request)) { + assertTrue(stream.next(Duration.ofSeconds(1)) instanceof OverflowEvent, "overflow was not delivered"); + try { + stream.next(Duration.ofSeconds(3)); + throw new AssertionError("overflow stream did not terminate"); + } catch (CmuxException expected) { + assertTrue("stream is closed".equals(expected.getMessage()), "unexpected terminal error"); + } + } + } finally { + server.join(5_000); + Files.deleteIfExists(socket); + } + } + private static void runBufferedPreAckEventServer(ServerSocketChannel listener) { try (ServerSocketChannel server = listener) { try (SocketChannel client = server.accept()) { @@ -104,6 +132,18 @@ private static void runLargePreAckEventServer(ServerSocketChannel listener, Stri } } + private static void runOverflowServer(ServerSocketChannel listener) { + try (ServerSocketChannel server = listener) { + try (SocketChannel client = server.accept()) { + readRequestLine(client); + writeLine(client, "{\"id\":9,\"ok\":true,\"data\":{}}"); + writeLine(client, "{\"event\":\"overflow\",\"error\":\"subscriber fell behind\"}"); + } + } catch (Exception err) { + throw new RuntimeException(err); + } + } + private static void readRequestLine(SocketChannel client) throws IOException { ByteBuffer one = ByteBuffer.allocate(1); while (client.read(one) >= 0) { diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index 24fdca4fba96..2c1839e30c5d 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -235,12 +235,15 @@ def __next__(self) -> Event: if self._closed: raise StopIteration if self._queue: - return self._queue.pop(0) + event = self._queue.pop(0) + if event.event in ("detached", "overflow"): + self.close() + return event value = self._conn.recv() if "event" not in value: return self.__next__() event = _parse_event(value) - if event.event == "detached": + if event.event in ("detached", "overflow"): self.close() return event diff --git a/cmux-tui/bindings/python/tests/test_events.py b/cmux-tui/bindings/python/tests/test_events.py index b04bd0a4fbb3..eef456f2fff2 100644 --- a/cmux-tui/bindings/python/tests/test_events.py +++ b/cmux-tui/bindings/python/tests/test_events.py @@ -2,7 +2,15 @@ import unittest -from cmux.client import _parse_event +from cmux.client import _Stream, _parse_event + + +class _FakeConnection: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True class EventTests(unittest.TestCase): @@ -41,6 +49,18 @@ def test_overflow_exposes_recovery_fields(self) -> None: self.assertEqual(event.scope, "surface") self.assertEqual(event.surface, 7) + def test_stream_yields_buffered_overflow_once_then_stops(self) -> None: + connection = _FakeConnection() + stream = _Stream.__new__(_Stream) + stream._conn = connection + stream._queue = [_parse_event({"event": "overflow", "error": "fell behind"})] + stream._closed = False + + self.assertEqual(next(stream).event, "overflow") + self.assertTrue(connection.closed) + with self.assertRaises(StopIteration): + next(stream) + if __name__ == "__main__": unittest.main() diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index 509d32ab0492..efb3ddc7b0d5 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::fmt; use std::io::{BufRead, BufReader, Write}; +use std::net::Shutdown; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::time::Duration; @@ -552,6 +553,7 @@ impl CmuxClient { pub struct CmuxStream { conn: JsonLineConnection, buffered: Vec, + finished: bool, } impl CmuxStream { @@ -570,7 +572,7 @@ impl CmuxStream { continue; } if response.get("ok") == Some(&Value::Bool(true)) { - return Ok(Self { conn, buffered }); + return Ok(Self { conn, buffered, finished: false }); } return Err(CmuxError::Command { message: response @@ -584,29 +586,47 @@ impl CmuxStream { } pub fn recv(&mut self) -> Result { + if self.finished { + return Err(CmuxError::Connection("stream is closed".to_string())); + } if !self.buffered.is_empty() { - return Ok(self.buffered.remove(0)); + let event = self.buffered.remove(0); + return Ok(self.finish_terminal(event)); } loop { let value = self.conn.recv()?; if value.get("event").is_some() { - return Ok(parse_event(value)); + let event = parse_event(value); + return Ok(self.finish_terminal(event)); } } } pub fn recv_timeout(&mut self, timeout: Duration) -> Result { + if self.finished { + return Err(CmuxError::Connection("stream is closed".to_string())); + } if !self.buffered.is_empty() { - return Ok(self.buffered.remove(0)); + let event = self.buffered.remove(0); + return Ok(self.finish_terminal(event)); } - self.conn.with_read_timeout(timeout, |conn| { + let event = self.conn.with_read_timeout(timeout, |conn| { loop { let value = conn.recv()?; if value.get("event").is_some() { return Ok(parse_event(value)); } } - }) + })?; + Ok(self.finish_terminal(event)) + } + + fn finish_terminal(&mut self, event: Event) -> Event { + if matches!(&event, Event::Detached(_) | Event::Overflow(_)) { + self.finished = true; + let _ = self.conn.writer.shutdown(Shutdown::Both); + } + event } } @@ -614,7 +634,7 @@ impl Iterator for CmuxStream { type Item = Result; fn next(&mut self) -> Option { - Some(self.recv()) + (!self.finished).then(|| self.recv()) } } @@ -796,4 +816,22 @@ mod tests { && surface == Some(7) )); } + + #[test] + fn iterator_yields_buffered_overflow_once_then_stops() { + let (socket, _peer) = UnixStream::pair().unwrap(); + let writer = socket.try_clone().unwrap(); + let mut stream = CmuxStream { + conn: JsonLineConnection { writer, reader: BufReader::new(socket) }, + buffered: vec![Event::Overflow(OverflowEvent { + error: "fell behind".to_string(), + scope: None, + surface: None, + })], + finished: false, + }; + + assert!(matches!(stream.next(), Some(Ok(Event::Overflow(_))))); + assert!(stream.next().is_none()); + } } diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 7b8749b637fe..4499116c7cb6 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -420,6 +420,9 @@ const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); trait MessageSink: Send + Sync { fn send(&self, value: &Value) -> std::io::Result<()>; + fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + self.send(value) + } fn close(&self); } @@ -440,6 +443,17 @@ impl MessageWriter { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } let result = self.sink.send(value); + if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { + self.close(); + } + result + } + + fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self.sink.send_terminal(value); if result.is_err() { self.close(); } @@ -472,12 +486,15 @@ impl MessageSink for LineSink { const WEBSOCKET_OUTBOUND_CAPACITY: usize = 256; -struct WebSocketSink(SyncSender); +struct WebSocketSink { + outbound: SyncSender, + terminal: SyncSender, +} impl MessageSink for WebSocketSink { fn send(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; - self.0.try_send(text).map_err(|error| match error { + self.outbound.try_send(text).map_err(|error| match error { TrySendError::Full(_) => std::io::Error::new( std::io::ErrorKind::WouldBlock, "WebSocket outbound queue overflowed", @@ -488,6 +505,19 @@ impl MessageSink for WebSocketSink { }) } + fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + let text = serde_json::to_string(value)?; + self.terminal.try_send(text).map_err(|error| match error { + TrySendError::Full(_) => std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "WebSocket terminal queue overflowed", + ), + TrySendError::Disconnected(_) => { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "WebSocket connection closed") + } + }) + } + fn close(&self) {} } @@ -651,9 +681,18 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); let (outbound_tx, outbound_rx) = sync_channel(WEBSOCKET_OUTBOUND_CAPACITY); - let writer = MessageWriter::new(WebSocketSink(outbound_tx)); + let (terminal_tx, terminal_rx) = sync_channel(1); + let writer = MessageWriter::new(WebSocketSink { outbound: outbound_tx, terminal: terminal_tx }); - 'connection: while writer.is_open() { + 'connection: loop { + match terminal_rx.try_recv() { + Ok(text) => { + let _ = websocket.send(Message::Text(text.into())); + break; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => break, + } loop { match outbound_rx.try_recv() { Ok(text) => { @@ -665,6 +704,9 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& Err(TryRecvError::Disconnected) => break 'connection, } } + if !writer.is_open() { + break; + } match websocket.read() { Ok(Message::Text(text)) => { @@ -1117,7 +1159,7 @@ fn report_attach_overflow( lifecycle: &AttachLifecycle, ) { if lifecycle.claim_overflow_report() { - let _ = writer.send(&json!({ + let _ = writer.send_terminal(&json!({ "event": "overflow", "scope": "surface", "surface": surface_id, @@ -1629,6 +1671,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho let events = mux.subscribe(); let writer = writer.clone(); std::thread::Builder::new().name("mux-events-out".into()).spawn(move || { + let mut transport_overflow = false; while writer.is_open() { let event = match events.recv_timeout(STREAM_DISCONNECT_POLL) { Ok(event) => event, @@ -1636,12 +1679,13 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, }; let value = subscribed_event_json(&event); - if writer.send(&value).is_err() { + if let Err(error) = writer.send(&value) { + transport_overflow = error.kind() == std::io::ErrorKind::WouldBlock; break; } } - if events.overflowed() { - let _ = writer.send(&subscription_overflow_json()); + if events.overflowed() || transport_overflow { + let _ = writer.send_terminal(&subscription_overflow_json()); } })?; Ok(json!({})) @@ -1865,15 +1909,19 @@ mod tests { } #[test] - fn websocket_writer_closes_when_bounded_queue_overflows() { - let (sender, _receiver) = sync_channel(1); - let writer = MessageWriter::new(WebSocketSink(sender)); + fn websocket_writer_reserves_a_terminal_overflow_lane() { + let (outbound, _outbound_receiver) = sync_channel(1); + let (terminal, terminal_receiver) = sync_channel(1); + let writer = MessageWriter::new(WebSocketSink { outbound, terminal }); writer.send(&json!({"event": "first"})).unwrap(); let error = writer.send(&json!({"event": "second"})).unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); - assert!(!writer.is_open()); + assert!(writer.is_open()); + + writer.send_terminal(&subscription_overflow_json()).unwrap(); + let terminal: Value = serde_json::from_str(&terminal_receiver.recv().unwrap()).unwrap(); + assert_eq!(terminal["event"], "overflow"); } #[test] From 94c39ab636e95e01571268bb195b8a751c8e53f7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:44:15 -0700 Subject: [PATCH 058/103] Keep scoped overflow on shared transports --- cmux-tui/bindings/typescript/src/client.ts | 1 + .../bindings/typescript/test/client.test.ts | 2 +- cmux-tui/crates/cmux-tui-core/src/server.rs | 114 ++++++++++-------- 3 files changed, 68 insertions(+), 49 deletions(-) diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index d06997cda0f7..cc9b4c344adb 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -389,6 +389,7 @@ export class CmuxClient { (event) => event as SubscribeEvent, (event, dedicated) => dedicated || (!this.attachOnlyEvent(event.event) && !this.isSurfaceOverflow(event)), + (event) => event.event === "overflow" && !this.isSurfaceOverflow(event), ); } diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index 3af3ddb5e272..89474dfe591c 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -87,7 +87,7 @@ test("surface overflow terminates only the matching shared attach stream", async if (subscriptionOverflow.event === "overflow") { assert.equal(subscriptionOverflow.scope, undefined); } - subscription.close(); + await assert.rejects(() => subscription.next(), /stream is closed/); await client.close(); }); diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 4499116c7cb6..68299e7d3478 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -18,12 +18,11 @@ //! {"id":1,"ok":true,"data":{"app":"cmux-tui","session":"main",...}} //! ``` -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::io::{BufRead, BufReader, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{SyncSender, TryRecvError, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -485,37 +484,65 @@ impl MessageSink for LineSink { } const WEBSOCKET_OUTBOUND_CAPACITY: usize = 256; +const WEBSOCKET_CONTROL_RESERVE: usize = 256; struct WebSocketSink { - outbound: SyncSender, - terminal: SyncSender, + outbound: Arc, +} + +#[derive(Default)] +struct WebSocketOutbound { + state: Mutex, +} + +#[derive(Default)] +struct WebSocketOutboundState { + queue: VecDeque<(String, bool)>, + regular_count: usize, +} + +impl WebSocketOutbound { + fn push(&self, text: String, terminal: bool) -> std::io::Result<()> { + let mut state = self.state.lock().unwrap(); + let total_capacity = WEBSOCKET_OUTBOUND_CAPACITY + WEBSOCKET_CONTROL_RESERVE; + if state.queue.len() >= total_capacity + || (!terminal && state.regular_count >= WEBSOCKET_OUTBOUND_CAPACITY) + { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + if terminal { + "WebSocket control reserve overflowed" + } else { + "WebSocket outbound queue overflowed" + }, + )); + } + if !terminal { + state.regular_count += 1; + } + state.queue.push_back((text, terminal)); + Ok(()) + } + + fn pop(&self) -> Option { + let mut state = self.state.lock().unwrap(); + let (text, terminal) = state.queue.pop_front()?; + if !terminal { + state.regular_count -= 1; + } + Some(text) + } } impl MessageSink for WebSocketSink { fn send(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; - self.outbound.try_send(text).map_err(|error| match error { - TrySendError::Full(_) => std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "WebSocket outbound queue overflowed", - ), - TrySendError::Disconnected(_) => { - std::io::Error::new(std::io::ErrorKind::BrokenPipe, "WebSocket connection closed") - } - }) + self.outbound.push(text, false) } fn send_terminal(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; - self.terminal.try_send(text).map_err(|error| match error { - TrySendError::Full(_) => std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "WebSocket terminal queue overflowed", - ), - TrySendError::Disconnected(_) => { - std::io::Error::new(std::io::ErrorKind::BrokenPipe, "WebSocket connection closed") - } - }) + self.outbound.push(text, true) } fn close(&self) {} @@ -680,28 +707,13 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); - let (outbound_tx, outbound_rx) = sync_channel(WEBSOCKET_OUTBOUND_CAPACITY); - let (terminal_tx, terminal_rx) = sync_channel(1); - let writer = MessageWriter::new(WebSocketSink { outbound: outbound_tx, terminal: terminal_tx }); + let outbound = Arc::new(WebSocketOutbound::default()); + let writer = MessageWriter::new(WebSocketSink { outbound: outbound.clone() }); 'connection: loop { - match terminal_rx.try_recv() { - Ok(text) => { - let _ = websocket.send(Message::Text(text.into())); - break; - } - Err(TryRecvError::Empty) => {} - Err(TryRecvError::Disconnected) => break, - } - loop { - match outbound_rx.try_recv() { - Ok(text) => { - if websocket.send(Message::Text(text.into())).is_err() { - break 'connection; - } - } - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => break 'connection, + while let Some(text) = outbound.pop() { + if websocket.send(Message::Text(text.into())).is_err() { + break 'connection; } } if !writer.is_open() { @@ -1910,18 +1922,24 @@ mod tests { #[test] fn websocket_writer_reserves_a_terminal_overflow_lane() { - let (outbound, _outbound_receiver) = sync_channel(1); - let (terminal, terminal_receiver) = sync_channel(1); - let writer = MessageWriter::new(WebSocketSink { outbound, terminal }); + let outbound = Arc::new(WebSocketOutbound::default()); + let writer = MessageWriter::new(WebSocketSink { outbound: outbound.clone() }); - writer.send(&json!({"event": "first"})).unwrap(); - let error = writer.send(&json!({"event": "second"})).unwrap_err(); + for sequence in 0..WEBSOCKET_OUTBOUND_CAPACITY { + writer.send(&json!({"event": "output", "sequence": sequence})).unwrap(); + } + let error = writer.send(&json!({"event": "extra"})).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); assert!(writer.is_open()); writer.send_terminal(&subscription_overflow_json()).unwrap(); - let terminal: Value = serde_json::from_str(&terminal_receiver.recv().unwrap()).unwrap(); + let drained = (0..WEBSOCKET_OUTBOUND_CAPACITY) + .map(|_| outbound.pop().expect("accepted output")) + .collect::>(); + assert!(drained[0].contains("\"sequence\":0")); + let terminal: Value = serde_json::from_str(&outbound.pop().unwrap()).unwrap(); assert_eq!(terminal["event"], "overflow"); + assert!(writer.is_open()); } #[test] From c099810d7b5072b71f08f001bc95576cd6157a37 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:51:27 -0700 Subject: [PATCH 059/103] Acknowledge TUI subscription recovery --- .../typescript/src/protocol/events.ts | 12 +-- .../typescript/test/protocol-types.ts | 16 ++++ .../crates/cmux-tui/src/session/remote.rs | 87 ++++++++++++++++--- 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/cmux-tui/bindings/typescript/src/protocol/events.ts b/cmux-tui/bindings/typescript/src/protocol/events.ts index a55c5384f751..72e5df87cb5a 100644 --- a/cmux-tui/bindings/typescript/src/protocol/events.ts +++ b/cmux-tui/bindings/typescript/src/protocol/events.ts @@ -48,17 +48,19 @@ export interface VtStateEvent { /** Live base64 PTY bytes after the attach snapshot. */ export interface OutputEvent { event: "output"; surface: Id; data: Base64 } -/** A protocol v7 replay that replaces the existing terminal mirror. */ -export interface ResizedEvent { +interface ResizedEventBase { event: "resized"; surface: Id; cols: number; rows: number; - replay: Base64; - /** Compatibility with protocol-v6 servers that used `data`. */ - data?: Base64; } +/** A replacement replay using the protocol-v7 field or protocol-v6 compatibility field. */ +export type ResizedEvent = ResizedEventBase & ( + | { replay: Base64; data?: Base64 } + | { data: Base64; replay?: Base64 } +); + export interface DetachedEvent { event: "detached"; surface: Id } export interface OverflowEvent { diff --git a/cmux-tui/bindings/typescript/test/protocol-types.ts b/cmux-tui/bindings/typescript/test/protocol-types.ts index 4c8fa4b01cdd..a9421c2b9e18 100644 --- a/cmux-tui/bindings/typescript/test/protocol-types.ts +++ b/cmux-tui/bindings/typescript/test/protocol-types.ts @@ -79,8 +79,24 @@ function surfaceFromKnownEvent(event: KnownCmuxEvent): number | undefined { } const futureEvent: CmuxEvent = { event: "future-event", extension: true }; +const protocolV6Resize: KnownCmuxEvent = { + event: "resized", + surface: 1, + cols: 80, + rows: 24, + data: "cmVwbGF5", +}; +const protocolV7Resize: KnownCmuxEvent = { + event: "resized", + surface: 1, + cols: 80, + rows: 24, + replay: "cmVwbGF5", +}; void surfaceFromKnownEvent; void futureEvent; +void protocolV6Resize; +void protocolV7Resize; // @ts-expect-error `read-screen` requires a surface id. const invalidRequest: CmuxRequest = { cmd: "read-screen" }; diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 08feb2c1f942..cd166aecae69 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -296,6 +296,7 @@ pub struct RemoteSession { tree: Mutex, tree_refresh: Mutex<()>, tree_stale: AtomicBool, + subscription_recovery_in_flight: AtomicBool, subscribers: MuxEventBroadcaster, frame_logs: Mutex>>, } @@ -324,6 +325,7 @@ impl RemoteSession { tree: Mutex::new(RemoteTreeCache::default()), tree_refresh: Mutex::new(()), tree_stale: AtomicBool::new(true), + subscription_recovery_in_flight: AtomicBool::new(false), subscribers: MuxEventBroadcaster::default(), frame_logs: Mutex::new(HashMap::new()), }); @@ -552,19 +554,8 @@ impl RemoteSession { return; } self.tree_stale.store(true, Ordering::Release); - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let mut line = serde_json::to_vec(&json!({"cmd": "subscribe", "id": id})) - .expect("subscribe recovery command is serializable"); - line.push(b'\n'); - let recovery = self.writer.lock().unwrap().write_all(&line); - let message = match recovery { - Ok(()) => "event subscription overflowed; resubscribing".to_string(), - Err(error) => { - format!("event subscription overflowed; resubscribe failed: {error}") - } - }; - self.emit(MuxEvent::Status(message)); self.emit(MuxEvent::TreeChanged); + self.start_subscription_recovery(); } Some("status") => { if let Some(message) = value.get("message").and_then(|v| v.as_str()) { @@ -591,6 +582,37 @@ impl RemoteSession { } } + fn start_subscription_recovery(self: &Arc) { + if self.subscription_recovery_in_flight.swap(true, Ordering::AcqRel) { + return; + } + self.emit(MuxEvent::Status("event subscription overflowed; resubscribing".to_string())); + let session = self.clone(); + let spawn = + std::thread::Builder::new().name("remote-resubscribe".into()).spawn(move || { + let result = session + .request(json!({"cmd": "subscribe"})) + .or_else(|_| session.request(json!({"cmd": "subscribe"}))); + session.subscription_recovery_in_flight.store(false, Ordering::Release); + match result { + Ok(_) => session.emit(MuxEvent::Status( + "event subscription overflowed; resubscribed".to_string(), + )), + Err(error) => { + session.emit(MuxEvent::Status(format!( + "event subscription overflowed; resubscribe failed: {error}" + ))); + } + } + }); + if let Err(error) = spawn { + self.subscription_recovery_in_flight.store(false, Ordering::Release); + self.emit(MuxEvent::Status(format!( + "event subscription overflowed; resubscribe failed: {error}" + ))); + } + } + fn log_frame(&self, surface: SurfaceId, line: String) { if std::env::var_os("CMUX_MUX_DEBUG_MIRROR_DUMP").is_none() { return; @@ -919,6 +941,7 @@ mod tests { tree: Mutex::new(RemoteTreeCache::default()), tree_refresh: Mutex::new(()), tree_stale: AtomicBool::new(true), + subscription_recovery_in_flight: AtomicBool::new(false), subscribers: MuxEventBroadcaster::default(), frame_logs: Mutex::new(HashMap::new()), }) @@ -1263,12 +1286,52 @@ mod tests { BufReader::new(server).read_line(&mut line).unwrap(); let command: Value = serde_json::from_str(&line).unwrap(); assert_eq!(command.get("cmd").and_then(Value::as_str), Some("subscribe")); + session.handle_line(json!({"id": command["id"], "ok": true, "data": {}})); assert!(session.tree_is_stale()); let received = events.try_iter().collect::>(); assert!(received.iter().any(|event| matches!(event, MuxEvent::Status(_)))); assert!(received.iter().any(|event| matches!(event, MuxEvent::TreeChanged))); } + #[cfg(unix)] + #[test] + fn rejected_subscription_recovery_retries_then_surfaces_failure() { + let (client, server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + + session.handle_line(json!({"event": "overflow", "error": "subscriber fell behind"})); + + let mut line = String::new(); + let mut server = BufReader::new(server); + server.read_line(&mut line).unwrap(); + let command: Value = serde_json::from_str(&line).unwrap(); + session.handle_line(json!({ + "id": command["id"], + "ok": false, + "error": "replacement rejected", + })); + + line.clear(); + server.read_line(&mut line).unwrap(); + let retry: Value = serde_json::from_str(&line).unwrap(); + session.handle_line(json!({ + "id": retry["id"], + "ok": false, + "error": "replacement rejected again", + })); + + loop { + match events.recv_timeout(Duration::from_secs(1)).unwrap() { + MuxEvent::Status(message) if message.contains("resubscribe failed") => { + break; + } + _ => {} + } + } + assert!(!session.subscription_recovery_in_flight.load(Ordering::Acquire)); + } + #[cfg(unix)] #[test] fn surface_overflow_invalidates_mirror_and_requests_reattach() { From 593ee4ee4bd4d1f2ea6058ba2fd1e876d4538ca2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:01:49 -0700 Subject: [PATCH 060/103] Order remote subscription reconciliation --- .../crates/cmux-tui/src/session/remote.rs | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index cd166aecae69..dd09dec056f4 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -554,7 +554,6 @@ impl RemoteSession { return; } self.tree_stale.store(true, Ordering::Release); - self.emit(MuxEvent::TreeChanged); self.start_subscription_recovery(); } Some("status") => { @@ -590,14 +589,21 @@ impl RemoteSession { let session = self.clone(); let spawn = std::thread::Builder::new().name("remote-resubscribe".into()).spawn(move || { - let result = session - .request(json!({"cmd": "subscribe"})) - .or_else(|_| session.request(json!({"cmd": "subscribe"}))); + let first = session.request(json!({"cmd": "subscribe"})); + let result = match first { + Err(error) if Self::subscription_recovery_is_retryable(&error) => { + session.request(json!({"cmd": "subscribe"})) + } + result => result, + }; session.subscription_recovery_in_flight.store(false, Ordering::Release); match result { - Ok(_) => session.emit(MuxEvent::Status( - "event subscription overflowed; resubscribed".to_string(), - )), + Ok(_) => { + session.emit(MuxEvent::Status( + "event subscription overflowed; resubscribed".to_string(), + )); + session.emit(MuxEvent::TreeChanged); + } Err(error) => { session.emit(MuxEvent::Status(format!( "event subscription overflowed; resubscribe failed: {error}" @@ -613,6 +619,10 @@ impl RemoteSession { } } + fn subscription_recovery_is_retryable(error: &anyhow::Error) -> bool { + matches!(error.downcast_ref::(), Some(RemoteRequestError::Rejected(_))) + } + fn log_frame(&self, surface: SurfaceId, line: String) { if std::env::var_os("CMUX_MUX_DEBUG_MIRROR_DUMP").is_none() { return; @@ -1288,9 +1298,15 @@ mod tests { assert_eq!(command.get("cmd").and_then(Value::as_str), Some("subscribe")); session.handle_line(json!({"id": command["id"], "ok": true, "data": {}})); assert!(session.tree_is_stale()); - let received = events.try_iter().collect::>(); - assert!(received.iter().any(|event| matches!(event, MuxEvent::Status(_)))); - assert!(received.iter().any(|event| matches!(event, MuxEvent::TreeChanged))); + let mut saw_status = false; + loop { + match events.recv_timeout(Duration::from_secs(1)).unwrap() { + MuxEvent::Status(_) => saw_status = true, + MuxEvent::TreeChanged => break, + _ => {} + } + } + assert!(saw_status); } #[cfg(unix)] @@ -1332,6 +1348,17 @@ mod tests { assert!(!session.subscription_recovery_in_flight.load(Ordering::Acquire)); } + #[test] + fn subscription_recovery_retries_only_explicit_rejection() { + let rejected = anyhow::Error::new(RemoteRequestError::Rejected("no capacity".to_string())); + let timeout = anyhow::Error::new(RemoteRequestError::Timeout); + let shutdown = anyhow::Error::new(RemoteRequestError::Shutdown); + + assert!(RemoteSession::subscription_recovery_is_retryable(&rejected)); + assert!(!RemoteSession::subscription_recovery_is_retryable(&timeout)); + assert!(!RemoteSession::subscription_recovery_is_retryable(&shutdown)); + } + #[cfg(unix)] #[test] fn surface_overflow_invalidates_mirror_and_requests_reattach() { From b738e3a2ed36b4c3961fcb9c49b5a91ad405c64b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:11:43 -0700 Subject: [PATCH 061/103] Unify bounded TUI transport output --- cmux-tui/bindings/go/client.go | 7 +- cmux-tui/bindings/go/client_test.go | 23 +++ cmux-tui/crates/cmux-tui-core/src/platform.rs | 9 + cmux-tui/crates/cmux-tui-core/src/server.rs | 181 ++++++++++-------- cmux-tui/crates/cmux-tui-core/src/surface.rs | 13 +- .../crates/cmux-tui/src/session/remote.rs | 11 +- 6 files changed, 154 insertions(+), 90 deletions(-) diff --git a/cmux-tui/bindings/go/client.go b/cmux-tui/bindings/go/client.go index ee9870483294..2e370ea66cde 100644 --- a/cmux-tui/bindings/go/client.go +++ b/cmux-tui/bindings/go/client.go @@ -399,19 +399,18 @@ type Stream struct { conn *jsonLineConn timeout time.Duration buffered []Event - closed bool + closed atomic.Bool } func (s *Stream) Close() error { - if s.closed { + if !s.closed.CompareAndSwap(false, true) { return nil } - s.closed = true return s.conn.Close() } func (s *Stream) Recv(ctx context.Context) (Event, error) { - if s.closed { + if s.closed.Load() { return nil, io.EOF } if len(s.buffered) > 0 { diff --git a/cmux-tui/bindings/go/client_test.go b/cmux-tui/bindings/go/client_test.go index 189381f4d708..ba3362e145f2 100644 --- a/cmux-tui/bindings/go/client_test.go +++ b/cmux-tui/bindings/go/client_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net" + "sync" "testing" ) @@ -28,3 +29,25 @@ func TestStreamYieldsBufferedOverflowOnceThenStops(t *testing.T) { t.Fatalf("second Recv() error = %v, want io.EOF", err) } } + +func TestStreamCloseIsConcurrentSafe(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + stream := &Stream{conn: &jsonLineConn{conn: client, reader: bufio.NewReader(client)}} + + var wait sync.WaitGroup + for range 16 { + wait.Add(1) + go func() { + defer wait.Done() + if err := stream.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } + }() + } + wait.Wait() + + if _, err := stream.Recv(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("Recv() error = %v, want io.EOF", err) + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/platform.rs b/cmux-tui/crates/cmux-tui-core/src/platform.rs index 77b57a0d9caa..629b3a704a48 100644 --- a/cmux-tui/crates/cmux-tui-core/src/platform.rs +++ b/cmux-tui/crates/cmux-tui-core/src/platform.rs @@ -10,6 +10,7 @@ pub mod transport { pub trait Stream: Read + Write + Send { fn try_clone_box(&self) -> io::Result>; fn set_read_timeout(&self, timeout: Option) -> io::Result<()>; + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; } pub struct Listener { @@ -66,6 +67,10 @@ pub mod transport { fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_read_timeout(self, timeout) } + + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + UnixStream::set_write_timeout(self, timeout) + } } } @@ -105,6 +110,10 @@ pub mod transport { fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_read_timeout(self, timeout) } + + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + UnixStream::set_write_timeout(self, timeout) + } } } } diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 68299e7d3478..66eddaa7250d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -23,7 +23,7 @@ use std::io::{BufRead, BufReader, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -416,12 +416,16 @@ struct Response { } const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); +const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); +const OUTBOUND_CAPACITY: usize = 256; +const OUTBOUND_CONTROL_RESERVE: usize = 256; trait MessageSink: Send + Sync { fn send(&self, value: &Value) -> std::io::Result<()>; fn send_terminal(&self, value: &Value) -> std::io::Result<()> { self.send(value) } + fn is_open(&self) -> bool; fn close(&self); } @@ -460,7 +464,7 @@ impl MessageWriter { } fn is_open(&self) -> bool { - self.open.load(Ordering::Acquire) + self.open.load(Ordering::Acquire) && self.sink.is_open() } fn close(&self) { @@ -470,50 +474,35 @@ impl MessageWriter { } } -struct LineSink(Arc>>); - -impl MessageSink for LineSink { - fn send(&self, value: &Value) -> std::io::Result<()> { - let mut bytes = serde_json::to_vec(value)?; - bytes.push(b'\n'); - let mut stream = self.0.lock().unwrap(); - stream.write_all(&bytes) - } - - fn close(&self) {} -} - -const WEBSOCKET_OUTBOUND_CAPACITY: usize = 256; -const WEBSOCKET_CONTROL_RESERVE: usize = 256; - -struct WebSocketSink { - outbound: Arc, -} - #[derive(Default)] -struct WebSocketOutbound { - state: Mutex, +struct BoundedOutbound { + state: Mutex, + changed: Condvar, } #[derive(Default)] -struct WebSocketOutboundState { +struct BoundedOutboundState { queue: VecDeque<(String, bool)>, regular_count: usize, + closed: bool, } -impl WebSocketOutbound { +impl BoundedOutbound { fn push(&self, text: String, terminal: bool) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); - let total_capacity = WEBSOCKET_OUTBOUND_CAPACITY + WEBSOCKET_CONTROL_RESERVE; + if state.closed { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let total_capacity = OUTBOUND_CAPACITY + OUTBOUND_CONTROL_RESERVE; if state.queue.len() >= total_capacity - || (!terminal && state.regular_count >= WEBSOCKET_OUTBOUND_CAPACITY) + || (!terminal && state.regular_count >= OUTBOUND_CAPACITY) { return Err(std::io::Error::new( std::io::ErrorKind::WouldBlock, if terminal { - "WebSocket control reserve overflowed" + "outbound control reserve overflowed" } else { - "WebSocket outbound queue overflowed" + "outbound queue overflowed" }, )); } @@ -521,20 +510,51 @@ impl WebSocketOutbound { state.regular_count += 1; } state.queue.push_back((text, terminal)); + self.changed.notify_one(); Ok(()) } - fn pop(&self) -> Option { + fn try_pop(&self) -> Option { let mut state = self.state.lock().unwrap(); + Self::pop_locked(&mut state) + } + + fn recv(&self) -> Option { + let mut state = self.state.lock().unwrap(); + loop { + if let Some(text) = Self::pop_locked(&mut state) { + return Some(text); + } + if state.closed { + return None; + } + state = self.changed.wait(state).unwrap(); + } + } + + fn pop_locked(state: &mut BoundedOutboundState) -> Option { let (text, terminal) = state.queue.pop_front()?; if !terminal { state.regular_count -= 1; } Some(text) } + + fn is_open(&self) -> bool { + !self.state.lock().unwrap().closed + } + + fn close(&self) { + self.state.lock().unwrap().closed = true; + self.changed.notify_all(); + } } -impl MessageSink for WebSocketSink { +struct QueuedSink { + outbound: Arc, +} + +impl MessageSink for QueuedSink { fn send(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; self.outbound.push(text, false) @@ -545,7 +565,13 @@ impl MessageSink for WebSocketSink { self.outbound.push(text, true) } - fn close(&self) {} + fn is_open(&self) -> bool { + self.outbound.is_open() + } + + fn close(&self) { + self.outbound.close(); + } } /// Bind the socket and serve connections on background threads. @@ -675,8 +701,28 @@ fn sanitize_window_title(title: &str) -> String { } fn handle_connection(mux: Arc, stream: Box) { - let Ok(write_half) = stream.try_clone_box() else { return }; - let writer = MessageWriter::new(LineSink(Arc::new(Mutex::new(write_half)))); + let Ok(mut write_half) = stream.try_clone_box() else { return }; + if write_half.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)).is_err() { + return; + } + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); + let writer_outbound = outbound; + let Ok(writer_thread) = + std::thread::Builder::new().name("mux-line-out".into()).spawn(move || { + while let Some(text) = writer_outbound.recv() { + if write_half.write_all(text.as_bytes()).is_err() + || write_half.write_all(b"\n").is_err() + { + writer_outbound.close(); + break; + } + } + }) + else { + writer.close(); + return; + }; let reader = BufReader::new(stream); for line in reader.lines() { let Ok(line) = line else { break }; @@ -688,6 +734,7 @@ fn handle_connection(mux: Arc, stream: Box) { } } writer.close(); + let _ = writer_thread.join(); } fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<&str>) { @@ -707,11 +754,11 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); - let outbound = Arc::new(WebSocketOutbound::default()); - let writer = MessageWriter::new(WebSocketSink { outbound: outbound.clone() }); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); 'connection: loop { - while let Some(text) = outbound.pop() { + while let Some(text) = outbound.try_pop() { if websocket.send(Message::Text(text.into())).is_err() { break 'connection; } @@ -1878,54 +1925,23 @@ pub fn cleanup(path: &Path) { mod tests { use super::*; use crate::SurfaceOptions; - use std::io::{Read, Write}; use std::sync::mpsc::TryRecvError; use std::time::Duration; - struct NullStream; - - impl Read for NullStream { - fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { - Ok(0) - } - } - - impl Write for NullStream { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl transport::Stream for NullStream { - fn try_clone_box(&self) -> std::io::Result> { - Ok(Box::new(NullStream)) - } - - fn set_read_timeout(&self, _timeout: Option) -> std::io::Result<()> { - Ok(()) - } - } - fn test_mux() -> Arc { Mux::new_for_test("test", SurfaceOptions::default()) } fn test_writer() -> MessageWriter { - MessageWriter::new(LineSink(Arc::new(Mutex::new( - Box::new(NullStream) as Box - )))) + MessageWriter::new(QueuedSink { outbound: Arc::new(BoundedOutbound::default()) }) } #[test] - fn websocket_writer_reserves_a_terminal_overflow_lane() { - let outbound = Arc::new(WebSocketOutbound::default()); - let writer = MessageWriter::new(WebSocketSink { outbound: outbound.clone() }); + fn bounded_writer_reserves_a_terminal_overflow_lane() { + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); - for sequence in 0..WEBSOCKET_OUTBOUND_CAPACITY { + for sequence in 0..OUTBOUND_CAPACITY { writer.send(&json!({"event": "output", "sequence": sequence})).unwrap(); } let error = writer.send(&json!({"event": "extra"})).unwrap_err(); @@ -1933,15 +1949,26 @@ mod tests { assert!(writer.is_open()); writer.send_terminal(&subscription_overflow_json()).unwrap(); - let drained = (0..WEBSOCKET_OUTBOUND_CAPACITY) - .map(|_| outbound.pop().expect("accepted output")) + let drained = (0..OUTBOUND_CAPACITY) + .map(|_| outbound.try_pop().expect("accepted output")) .collect::>(); assert!(drained[0].contains("\"sequence\":0")); - let terminal: Value = serde_json::from_str(&outbound.pop().unwrap()).unwrap(); + let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(terminal["event"], "overflow"); assert!(writer.is_open()); } + #[test] + fn closing_bounded_writer_wakes_a_waiting_drain() { + let outbound = Arc::new(BoundedOutbound::default()); + let waiting = outbound.clone(); + let drain = std::thread::spawn(move || waiting.recv()); + + outbound.close(); + + assert_eq!(drain.join().unwrap(), None); + } + #[test] fn websocket_overflow_marks_attach_lifecycle() { let lifecycle = AttachLifecycle::default(); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 4da482a1d493..73096920935a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -354,9 +354,7 @@ impl Surface { let before = terminal_scroll_position(&term); term.vt_write(&buf[..n]); let after = terminal_scroll_position(&term); - { - pty.broadcast_attach_frame(AttachFrame::Output(buf[..n].to_vec())); - } + pty.broadcast_attach_output(&buf[..n]); if title_changed.swap(false, Ordering::Relaxed) { let title = term.title().unwrap_or_default(); *pty.title.lock().unwrap() = title.clone(); @@ -846,6 +844,15 @@ impl ChildKiller for TestChildKiller { } impl PtySurface { + fn broadcast_attach_output(&self, bytes: &[u8]) { + let mut taps = self.taps.lock().unwrap(); + if taps.is_empty() { + return; + } + let frame = AttachFrame::Output(bytes.to_vec()); + taps.retain(|tap| tap.try_send(frame.clone())); + } + fn broadcast_attach_frame(&self, frame: AttachFrame) { self.taps.lock().unwrap().retain(|tap| tap.try_send(frame.clone())); } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index dd09dec056f4..cd6d2602f973 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -608,6 +608,7 @@ impl RemoteSession { session.emit(MuxEvent::Status(format!( "event subscription overflowed; resubscribe failed: {error}" ))); + session.emit(MuxEvent::Empty); } } }); @@ -616,6 +617,7 @@ impl RemoteSession { self.emit(MuxEvent::Status(format!( "event subscription overflowed; resubscribe failed: {error}" ))); + self.emit(MuxEvent::Empty); } } @@ -1311,7 +1313,7 @@ mod tests { #[cfg(unix)] #[test] - fn rejected_subscription_recovery_retries_then_surfaces_failure() { + fn rejected_subscription_recovery_retries_then_closes_session() { let (client, server) = UnixStream::pair().unwrap(); let session = socket_test_session(client); let events = session.subscribe(); @@ -1338,11 +1340,8 @@ mod tests { })); loop { - match events.recv_timeout(Duration::from_secs(1)).unwrap() { - MuxEvent::Status(message) if message.contains("resubscribe failed") => { - break; - } - _ => {} + if matches!(events.recv_timeout(Duration::from_secs(1)).unwrap(), MuxEvent::Empty) { + break; } } assert!(!session.subscription_recovery_in_flight.load(Ordering::Acquire)); From 6a76fdeb6b6e96e2e7577c3a036bce88dbd46ca1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:31:50 -0700 Subject: [PATCH 062/103] Reserve TUI transport capacity for responses --- cmux-tui/crates/cmux-tui-core/src/server.rs | 39 +++++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 66eddaa7250d..39079c103cdd 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -422,9 +422,12 @@ const OUTBOUND_CONTROL_RESERVE: usize = 256; trait MessageSink: Send + Sync { fn send(&self, value: &Value) -> std::io::Result<()>; - fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + fn send_control(&self, value: &Value) -> std::io::Result<()> { self.send(value) } + fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + self.send_control(value) + } fn is_open(&self) -> bool; fn close(&self); } @@ -463,6 +466,17 @@ impl MessageWriter { result } + fn send_control(&self, value: &Value) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self.sink.send_control(value); + if result.is_err() { + self.close(); + } + result + } + fn is_open(&self) -> bool { self.open.load(Ordering::Acquire) && self.sink.is_open() } @@ -488,28 +502,28 @@ struct BoundedOutboundState { } impl BoundedOutbound { - fn push(&self, text: String, terminal: bool) -> std::io::Result<()> { + fn push(&self, text: String, control: bool) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); if state.closed { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } let total_capacity = OUTBOUND_CAPACITY + OUTBOUND_CONTROL_RESERVE; if state.queue.len() >= total_capacity - || (!terminal && state.regular_count >= OUTBOUND_CAPACITY) + || (!control && state.regular_count >= OUTBOUND_CAPACITY) { return Err(std::io::Error::new( std::io::ErrorKind::WouldBlock, - if terminal { + if control { "outbound control reserve overflowed" } else { "outbound queue overflowed" }, )); } - if !terminal { + if !control { state.regular_count += 1; } - state.queue.push_back((text, terminal)); + state.queue.push_back((text, control)); self.changed.notify_one(); Ok(()) } @@ -533,8 +547,8 @@ impl BoundedOutbound { } fn pop_locked(state: &mut BoundedOutboundState) -> Option { - let (text, terminal) = state.queue.pop_front()?; - if !terminal { + let (text, control) = state.queue.pop_front()?; + if !control { state.regular_count -= 1; } Some(text) @@ -560,7 +574,7 @@ impl MessageSink for QueuedSink { self.outbound.push(text, false) } - fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + fn send_control(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; self.outbound.push(text, true) } @@ -803,7 +817,7 @@ fn handle_message(mux: &Arc, message: &str, writer: &MessageWriter) -> bool Response { id: None, ok: false, data: None, error: Some(format!("bad request: {e}")) } } }; - serde_json::to_value(&response).is_ok_and(|value| writer.send(&value).is_ok()) + serde_json::to_value(&response).is_ok_and(|value| writer.send_control(&value).is_ok()) } fn auth_token(message: &str) -> Option { @@ -1937,7 +1951,7 @@ mod tests { } #[test] - fn bounded_writer_reserves_a_terminal_overflow_lane() { + fn bounded_writer_reserves_a_control_lane_for_responses_and_overflow() { let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); @@ -1948,11 +1962,14 @@ mod tests { assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); assert!(writer.is_open()); + writer.send_control(&json!({"id": 42, "ok": true, "data": {}})).unwrap(); writer.send_terminal(&subscription_overflow_json()).unwrap(); let drained = (0..OUTBOUND_CAPACITY) .map(|_| outbound.try_pop().expect("accepted output")) .collect::>(); assert!(drained[0].contains("\"sequence\":0")); + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["id"], 42); let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(terminal["event"], "overflow"); assert!(writer.is_open()); From e5afd5965030c19a852c99df9ccd3c3465b7ba34 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:38:04 -0700 Subject: [PATCH 063/103] Bound and prioritize TUI transport control --- cmux-tui/crates/cmux-tui-core/src/server.rs | 65 +++++++++++++++------ 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 39079c103cdd..46387c9632e9 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -419,6 +419,8 @@ const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); const OUTBOUND_CAPACITY: usize = 256; const OUTBOUND_CONTROL_RESERVE: usize = 256; +const OUTBOUND_BYTE_CAPACITY: usize = 16 * 1024 * 1024; +const OUTBOUND_CONTROL_BYTE_RESERVE: usize = 16 * 1024 * 1024; trait MessageSink: Send + Sync { fn send(&self, value: &Value) -> std::io::Result<()>; @@ -496,8 +498,10 @@ struct BoundedOutbound { #[derive(Default)] struct BoundedOutboundState { - queue: VecDeque<(String, bool)>, - regular_count: usize, + control: VecDeque, + regular: VecDeque, + control_bytes: usize, + regular_bytes: usize, closed: bool, } @@ -507,10 +511,15 @@ impl BoundedOutbound { if state.closed { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let total_capacity = OUTBOUND_CAPACITY + OUTBOUND_CONTROL_RESERVE; - if state.queue.len() >= total_capacity - || (!control && state.regular_count >= OUTBOUND_CAPACITY) - { + let bytes = text.len(); + let over_capacity = if control { + state.control.len() >= OUTBOUND_CONTROL_RESERVE + || bytes > OUTBOUND_CONTROL_BYTE_RESERVE.saturating_sub(state.control_bytes) + } else { + state.regular.len() >= OUTBOUND_CAPACITY + || bytes > OUTBOUND_BYTE_CAPACITY.saturating_sub(state.regular_bytes) + }; + if over_capacity { return Err(std::io::Error::new( std::io::ErrorKind::WouldBlock, if control { @@ -520,10 +529,13 @@ impl BoundedOutbound { }, )); } - if !control { - state.regular_count += 1; + if control { + state.control_bytes += bytes; + state.control.push_back(text); + } else { + state.regular_bytes += bytes; + state.regular.push_back(text); } - state.queue.push_back((text, control)); self.changed.notify_one(); Ok(()) } @@ -547,10 +559,12 @@ impl BoundedOutbound { } fn pop_locked(state: &mut BoundedOutboundState) -> Option { - let (text, control) = state.queue.pop_front()?; - if !control { - state.regular_count -= 1; + if let Some(text) = state.control.pop_front() { + state.control_bytes -= text.len(); + return Some(text); } + let text = state.regular.pop_front()?; + state.regular_bytes -= text.len(); Some(text) } @@ -768,6 +782,7 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); + let _ = websocket.get_mut().set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); @@ -1780,7 +1795,9 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho return Err(error); } }; - if let Err(error) = writer.send(&browser_state_json(surface_id, &state, true)) { + if let Err(error) = + writer.send_control(&browser_state_json(surface_id, &state, true)) + { handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } @@ -1833,7 +1850,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho return Err(error.into()); } }; - if let Err(error) = writer.send(&json!({ + if let Err(error) = writer.send_control(&json!({ "event": "vt-state", "surface": surface_id, "cols": attach.cols, @@ -1964,17 +1981,29 @@ mod tests { writer.send_control(&json!({"id": 42, "ok": true, "data": {}})).unwrap(); writer.send_terminal(&subscription_overflow_json()).unwrap(); - let drained = (0..OUTBOUND_CAPACITY) - .map(|_| outbound.try_pop().expect("accepted output")) - .collect::>(); - assert!(drained[0].contains("\"sequence\":0")); let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(response["id"], 42); let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(terminal["event"], "overflow"); + let drained = (0..OUTBOUND_CAPACITY) + .map(|_| outbound.try_pop().expect("accepted output")) + .collect::>(); + assert!(drained[0].contains("\"sequence\":0")); assert!(writer.is_open()); } + #[test] + fn bounded_writer_rejects_payloads_beyond_each_byte_budget() { + let outbound = BoundedOutbound::default(); + + let regular = outbound.push("x".repeat(OUTBOUND_BYTE_CAPACITY + 1), false).unwrap_err(); + assert_eq!(regular.kind(), std::io::ErrorKind::WouldBlock); + let control = + outbound.push("x".repeat(OUTBOUND_CONTROL_BYTE_RESERVE + 1), true).unwrap_err(); + assert_eq!(control.kind(), std::io::ErrorKind::WouldBlock); + assert_eq!(outbound.try_pop(), None); + } + #[test] fn closing_bounded_writer_wakes_a_waiting_drain() { let outbound = Arc::new(BoundedOutbound::default()); From 5777f21600d19fc359bed88685ed789fce086d6c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:47:23 -0700 Subject: [PATCH 064/103] Purge stale TUI frames on stream overflow --- cmux-tui/crates/cmux-tui-core/src/server.rs | 234 ++++++++++++++------ 1 file changed, 170 insertions(+), 64 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 46387c9632e9..3b54ce3cb30b 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -422,14 +422,30 @@ const OUTBOUND_CONTROL_RESERVE: usize = 256; const OUTBOUND_BYTE_CAPACITY: usize = 16 * 1024 * 1024; const OUTBOUND_CONTROL_BYTE_RESERVE: usize = 16 * 1024 * 1024; -trait MessageSink: Send + Sync { - fn send(&self, value: &Value) -> std::io::Result<()>; - fn send_control(&self, value: &Value) -> std::io::Result<()> { - self.send(value) +#[derive(Clone)] +struct OutboundStream { + id: u64, + open: Arc, +} + +impl OutboundStream { + fn new(id: u64) -> Self { + Self { id, open: Arc::new(AtomicBool::new(true)) } + } + + fn is_open(&self) -> bool { + self.open.load(Ordering::Acquire) } - fn send_terminal(&self, value: &Value) -> std::io::Result<()> { - self.send_control(value) + + fn close(&self) { + self.open.store(false, Ordering::Release); } +} + +trait MessageSink: Send + Sync { + fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; + fn send_control(&self, value: &Value) -> std::io::Result<()>; + fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; fn is_open(&self) -> bool; fn close(&self); } @@ -439,29 +455,38 @@ trait MessageSink: Send + Sync { struct MessageWriter { sink: Arc, open: Arc, + next_stream_id: Arc, } impl MessageWriter { fn new(sink: impl MessageSink + 'static) -> Self { - Self { sink: Arc::new(sink), open: Arc::new(AtomicBool::new(true)) } + Self { + sink: Arc::new(sink), + open: Arc::new(AtomicBool::new(true)), + next_stream_id: Arc::new(AtomicU64::new(1)), + } + } + + fn start_stream(&self) -> OutboundStream { + OutboundStream::new(self.next_stream_id.fetch_add(1, Ordering::Relaxed)) } - fn send(&self, value: &Value) -> std::io::Result<()> { + fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let result = self.sink.send(value); + let result = self.sink.send_stream(value, stream); if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { - self.close(); + stream.close(); } result } - fn send_terminal(&self, value: &Value) -> std::io::Result<()> { + fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let result = self.sink.send_terminal(value); + let result = self.sink.send_terminal(value, stream); if result.is_err() { self.close(); } @@ -499,47 +524,84 @@ struct BoundedOutbound { #[derive(Default)] struct BoundedOutboundState { control: VecDeque, - regular: VecDeque, + regular: VecDeque, control_bytes: usize, regular_bytes: usize, closed: bool, } +struct RegularOutbound { + text: String, + stream_id: u64, +} + impl BoundedOutbound { - fn push(&self, text: String, control: bool) -> std::io::Result<()> { + fn push_regular(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); if state.closed { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } + if !stream.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stream closed")); + } let bytes = text.len(); - let over_capacity = if control { - state.control.len() >= OUTBOUND_CONTROL_RESERVE - || bytes > OUTBOUND_CONTROL_BYTE_RESERVE.saturating_sub(state.control_bytes) - } else { - state.regular.len() >= OUTBOUND_CAPACITY - || bytes > OUTBOUND_BYTE_CAPACITY.saturating_sub(state.regular_bytes) - }; - if over_capacity { + if state.regular.len() >= OUTBOUND_CAPACITY + || bytes > OUTBOUND_BYTE_CAPACITY.saturating_sub(state.regular_bytes) + { return Err(std::io::Error::new( std::io::ErrorKind::WouldBlock, - if control { - "outbound control reserve overflowed" - } else { - "outbound queue overflowed" - }, + "outbound queue overflowed", )); } - if control { - state.control_bytes += bytes; - state.control.push_back(text); - } else { - state.regular_bytes += bytes; - state.regular.push_back(text); - } + state.regular_bytes += bytes; + state.regular.push_back(RegularOutbound { text, stream_id: stream.id }); self.changed.notify_one(); Ok(()) } + fn push_control(&self, text: String) -> std::io::Result<()> { + let mut state = self.state.lock().unwrap(); + Self::push_control_locked(&mut state, text)?; + self.changed.notify_one(); + Ok(()) + } + + fn push_terminal(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { + stream.close(); + let mut state = self.state.lock().unwrap(); + let mut removed_bytes = 0; + state.regular.retain(|message| { + if message.stream_id == stream.id { + removed_bytes += message.text.len(); + false + } else { + true + } + }); + state.regular_bytes -= removed_bytes; + Self::push_control_locked(&mut state, text)?; + self.changed.notify_one(); + Ok(()) + } + + fn push_control_locked(state: &mut BoundedOutboundState, text: String) -> std::io::Result<()> { + if state.closed { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let bytes = text.len(); + if state.control.len() >= OUTBOUND_CONTROL_RESERVE + || bytes > OUTBOUND_CONTROL_BYTE_RESERVE.saturating_sub(state.control_bytes) + { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "outbound control reserve overflowed", + )); + } + state.control_bytes += bytes; + state.control.push_back(text); + Ok(()) + } + fn try_pop(&self) -> Option { let mut state = self.state.lock().unwrap(); Self::pop_locked(&mut state) @@ -563,9 +625,9 @@ impl BoundedOutbound { state.control_bytes -= text.len(); return Some(text); } - let text = state.regular.pop_front()?; - state.regular_bytes -= text.len(); - Some(text) + let message = state.regular.pop_front()?; + state.regular_bytes -= message.text.len(); + Some(message.text) } fn is_open(&self) -> bool { @@ -583,14 +645,19 @@ struct QueuedSink { } impl MessageSink for QueuedSink { - fn send(&self, value: &Value) -> std::io::Result<()> { + fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { let text = serde_json::to_string(value)?; - self.outbound.push(text, false) + self.outbound.push_regular(text, stream) } fn send_control(&self, value: &Value) -> std::io::Result<()> { let text = serde_json::to_string(value)?; - self.outbound.push(text, true) + self.outbound.push_control(text) + } + + fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { + let text = serde_json::to_string(value)?; + self.outbound.push_terminal(text, stream) } fn is_open(&self) -> bool { @@ -1192,6 +1259,7 @@ fn spawn_attach_notification_stream( surface_id: SurfaceId, writer: MessageWriter, lifecycle: AttachLifecycle, + outbound_stream: OutboundStream, ) -> std::io::Result<()> { let events = mux.subscribe_attached_surface(surface_id); std::thread::Builder::new() @@ -1228,7 +1296,7 @@ fn spawn_attach_notification_stream( } _ => continue, }; - if let Err(error) = writer.send(&value) { + if let Err(error) = writer.send_stream(&value, &outbound_stream) { handle_attach_send_error(&lifecycle, &error); break; } @@ -1236,7 +1304,7 @@ fn spawn_attach_notification_stream( if events.overflowed() { lifecycle.mark_overflow(); } - report_attach_overflow(&writer, surface_id, &lifecycle); + report_attach_overflow(&writer, surface_id, &lifecycle, &outbound_stream); }) .map(|_| ()) } @@ -1245,14 +1313,18 @@ fn report_attach_overflow( writer: &MessageWriter, surface_id: SurfaceId, lifecycle: &AttachLifecycle, + outbound_stream: &OutboundStream, ) { if lifecycle.claim_overflow_report() { - let _ = writer.send_terminal(&json!({ - "event": "overflow", - "scope": "surface", - "surface": surface_id, - "error": "surface stream fell behind; reattach the surface", - })); + let _ = writer.send_terminal( + &json!({ + "event": "overflow", + "scope": "surface", + "surface": surface_id, + "error": "surface stream fell behind; reattach the surface", + }), + outbound_stream, + ); } } @@ -1758,6 +1830,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Command::Subscribe => { let events = mux.subscribe(); let writer = writer.clone(); + let outbound_stream = writer.start_stream(); std::thread::Builder::new().name("mux-events-out".into()).spawn(move || { let mut transport_overflow = false; while writer.is_open() { @@ -1767,13 +1840,13 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, }; let value = subscribed_event_json(&event); - if let Err(error) = writer.send(&value) { + if let Err(error) = writer.send_stream(&value, &outbound_stream) { transport_overflow = error.kind() == std::io::ErrorKind::WouldBlock; break; } } if events.overflowed() || transport_overflow { - let _ = writer.send_terminal(&subscription_overflow_json()); + let _ = writer.send_terminal(&subscription_overflow_json(), &outbound_stream); } })?; Ok(json!({})) @@ -1781,11 +1854,13 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Command::AttachSurface { surface: surface_id } => { let surface = get_surface(mux, surface_id)?; let lifecycle = AttachLifecycle::default(); + let outbound_stream = writer.start_stream(); spawn_attach_notification_stream( mux.clone(), surface_id, writer.clone(), lifecycle.clone(), + outbound_stream.clone(), )?; if surface.kind() == SurfaceKind::Browser { let (state, frames) = match surface.attach_frames() { @@ -1810,8 +1885,10 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { lifecycle.cancel(); if writer.is_open() { - let _ = writer - .send(&json!({"event": "detached", "surface": surface_id})); + let _ = writer.send_stream( + &json!({"event": "detached", "surface": surface_id}), + &outbound_stream, + ); } return; } @@ -1819,7 +1896,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho let update = std::mem::take(&mut *frames.slot.lock().unwrap()); if let Some(state) = update.state { let value = browser_state_json(surface_id, &state, false); - if let Err(error) = writer.send(&value) { + if let Err(error) = writer.send_stream(&value, &outbound_stream) { handle_attach_send_error(&lifecycle, &error); break; } @@ -1833,13 +1910,13 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho "height": frame.css_height, "data": frame.data_b64, }); - if let Err(error) = writer.send(&value) { + if let Err(error) = writer.send_stream(&value, &outbound_stream) { handle_attach_send_error(&lifecycle, &error); break; } } } - report_attach_overflow(&writer, surface_id, &lifecycle); + report_attach_overflow(&writer, surface_id, &lifecycle, &outbound_stream); })?; return Ok(json!({})); } @@ -1869,8 +1946,10 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { attach.lifecycle.cancel(); if writer.is_open() { - let _ = writer - .send(&json!({"event": "detached", "surface": surface_id})); + let _ = writer.send_stream( + &json!({"event": "detached", "surface": surface_id}), + &outbound_stream, + ); } return; } @@ -1889,12 +1968,12 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho "replay": base64::engine::general_purpose::STANDARD.encode(replay), }), }; - if let Err(error) = writer.send(&value) { + if let Err(error) = writer.send_stream(&value, &outbound_stream) { handle_attach_send_error(&attach.lifecycle, &error); break; } } - report_attach_overflow(&writer, surface_id, &attach.lifecycle); + report_attach_overflow(&writer, surface_id, &attach.lifecycle, &outbound_stream); })?; Ok(json!({})) } @@ -1971,16 +2050,20 @@ mod tests { fn bounded_writer_reserves_a_control_lane_for_responses_and_overflow() { let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); + let backlog = writer.start_stream(); for sequence in 0..OUTBOUND_CAPACITY { - writer.send(&json!({"event": "output", "sequence": sequence})).unwrap(); + writer + .send_stream(&json!({"event": "output", "sequence": sequence}), &backlog) + .unwrap(); } - let error = writer.send(&json!({"event": "extra"})).unwrap_err(); + let error = writer.send_stream(&json!({"event": "extra"}), &backlog).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); assert!(writer.is_open()); + let failed_stream = writer.start_stream(); writer.send_control(&json!({"id": 42, "ok": true, "data": {}})).unwrap(); - writer.send_terminal(&subscription_overflow_json()).unwrap(); + writer.send_terminal(&subscription_overflow_json(), &failed_stream).unwrap(); let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(response["id"], 42); let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); @@ -1995,15 +2078,38 @@ mod tests { #[test] fn bounded_writer_rejects_payloads_beyond_each_byte_budget() { let outbound = BoundedOutbound::default(); + let stream = OutboundStream::new(1); - let regular = outbound.push("x".repeat(OUTBOUND_BYTE_CAPACITY + 1), false).unwrap_err(); + let regular = + outbound.push_regular("x".repeat(OUTBOUND_BYTE_CAPACITY + 1), &stream).unwrap_err(); assert_eq!(regular.kind(), std::io::ErrorKind::WouldBlock); let control = - outbound.push("x".repeat(OUTBOUND_CONTROL_BYTE_RESERVE + 1), true).unwrap_err(); + outbound.push_control("x".repeat(OUTBOUND_CONTROL_BYTE_RESERVE + 1)).unwrap_err(); assert_eq!(control.kind(), std::io::ErrorKind::WouldBlock); assert_eq!(outbound.try_pop(), None); } + #[test] + fn terminal_overflow_purges_only_its_stream_and_rejects_late_frames() { + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); + let stale = writer.start_stream(); + let unrelated = writer.start_stream(); + + writer.send_stream(&json!({"event": "output", "stream": "stale"}), &stale).unwrap(); + writer.send_stream(&json!({"event": "output", "stream": "unrelated"}), &unrelated).unwrap(); + writer.send_terminal(&subscription_overflow_json(), &stale).unwrap(); + + let late = writer.send_stream(&json!({"event": "output", "stream": "late"}), &stale); + assert_eq!(late.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe); + let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(terminal["event"], "overflow"); + let remaining: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(remaining["stream"], "unrelated"); + assert_eq!(outbound.try_pop(), None); + assert!(writer.is_open()); + } + #[test] fn closing_bounded_writer_wakes_a_waiting_drain() { let outbound = Arc::new(BoundedOutbound::default()); From 87582f986b7a2f28eb99271faeedc7fafa0ff115 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:03:11 -0700 Subject: [PATCH 065/103] Preserve TUI titles and bound browser frames --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 24 +++++++++++++----- cmux-tui/crates/cmux-tui/src/app.rs | 26 +++++++++++++++++--- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 62bbe458cad0..12b67534e3b7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -208,7 +208,10 @@ struct BrowserCaptureOptions { fixed_capture_scale: Option, } -const DEFAULT_CAPTURE_MEGAPIXELS: f64 = 2.0; +// Two megapixels leave headroom below the 16 MiB transport message cap even +// for an incompressible RGBA PNG after base64 and JSON encoding. +const TRANSPORT_SAFE_CAPTURE_MEGAPIXELS: f64 = 2.0; +const DEFAULT_CAPTURE_MEGAPIXELS: f64 = TRANSPORT_SAFE_CAPTURE_MEGAPIXELS; const STALL_THRESHOLD: Duration = Duration::from_secs(2); const BROWSER_COMMAND_QUEUE_CAPACITY: usize = 64; const BROWSER_NOT_RESPONDING_MESSAGE: &str = "browser is not responding"; @@ -424,7 +427,8 @@ impl BrowserCaptureOptions { opts.browser_max_capture_megapixels } else { DEFAULT_CAPTURE_MEGAPIXELS - }; + } + .min(TRANSPORT_SAFE_CAPTURE_MEGAPIXELS); let fixed_capture_scale = opts .browser_capture_scale .filter(|scale| scale.is_finite() && *scale > 0.0 && *scale <= 1.0); @@ -433,12 +437,11 @@ impl BrowserCaptureOptions { } fn capture_scale_for(pane_px_w: u32, pane_px_h: u32, opts: BrowserCaptureOptions) -> f64 { - if let Some(scale) = opts.fixed_capture_scale { - return scale; - } let area = f64::from(pane_px_w.max(1)) * f64::from(pane_px_h.max(1)); let budget = opts.max_capture_megapixels.max(f64::MIN_POSITIVE) * 1_000_000.0; - if area <= budget { 1.0 } else { (budget / area).sqrt().clamp(f64::MIN_POSITIVE, 1.0) } + let budget_scale = + if area <= budget { 1.0 } else { (budget / area).sqrt().clamp(f64::MIN_POSITIVE, 1.0) }; + opts.fixed_capture_scale.map_or(budget_scale, |scale| scale.min(budget_scale)) } fn scaled_pixels(pane_px_w: u32, pane_px_h: u32, scale: f64) -> (u32, u32) { @@ -1779,6 +1782,15 @@ mod tests { BrowserCaptureOptions { max_capture_megapixels: 2.0, fixed_capture_scale: Some(0.5) }; assert_eq!(capture_scale_for(800, 600, fixed), 0.5); assert_eq!(scaled_pixels(800, 600, 0.5), (400, 300)); + + let configured = BrowserCaptureOptions::from_options(&SurfaceOptions { + browser_max_capture_megapixels: 20.0, + browser_capture_scale: Some(1.0), + ..SurfaceOptions::default() + }); + let capped_scale = capture_scale_for(4760, 2548, configured); + let capped = scaled_pixels(4760, 2548, capped_scale); + assert!(u64::from(capped.0) * u64::from(capped.1) <= 2_010_000); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index c0f5bc953948..37f35d6e2ff6 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -191,10 +191,10 @@ impl MuxTitleIngress { self.state.lock().unwrap().titles.remove(&surface); } - fn take(&self) -> HashMap> { + fn snapshot(&self) -> HashMap> { let mut state = self.state.lock().unwrap(); state.wake_queued = false; - std::mem::take(&mut state.titles) + state.titles.clone() } } @@ -2229,7 +2229,7 @@ impl App { } fn apply_mux_titles(&mut self) -> bool { - let titles = self.mux_titles.take(); + let titles = self.mux_titles.snapshot(); if titles.is_empty() { return false; } @@ -2283,6 +2283,7 @@ impl App { } self.tree = tree; self.rebuild_tab_locations(); + self.apply_mux_titles(); } fn replace_authoritative_tree(&mut self, tree: TreeView, routing_generation: u64) { @@ -6893,6 +6894,25 @@ mod tests { assert!(app.mux_titles.push(updated_surface, "next".to_string())); } + #[test] + fn title_ingress_reapplies_after_old_and_future_tree_snapshots() { + let mux = Mux::new("title-snapshot-order-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + + app.replace_tree(notify_tree(41, false)); + assert!(app.mux_titles.push(41, "live-title".to_string())); + app.handle(AppEvent::MuxTitlesReady).unwrap(); + assert_eq!(app.tree.pane(2).unwrap().tabs[0].title, "live-title"); + + app.replace_tree(notify_tree(41, false)); + assert_eq!(app.tree.pane(2).unwrap().tabs[0].title, "live-title"); + + assert!(app.mux_titles.push(42, "future-title".to_string())); + app.handle(AppEvent::MuxTitlesReady).unwrap(); + app.replace_tree(notify_tree(42, false)); + assert_eq!(app.tree.pane(2).unwrap().tabs[0].title, "future-title"); + } + #[test] fn mux_forwarder_recovers_after_bounded_mailbox_overflow() { let mux = Mux::new("mux-forwarder-overflow-test", SurfaceOptions::default()); From bbfb6695416e21fbf1d1142c2c08ace87efff9e3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:12:15 -0700 Subject: [PATCH 066/103] Scope retained TUI title updates --- cmux-tui/crates/cmux-tui/src/app.rs | 35 ++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 37f35d6e2ff6..93be2086a3b5 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -172,6 +172,7 @@ fn forward_mux_event( struct MuxTitleIngressState { wake_queued: bool, titles: HashMap>, + dirty: HashSet, } impl MuxTitleIngress { @@ -179,6 +180,7 @@ impl MuxTitleIngress { fn push(&self, surface: SurfaceId, title: impl Into>) -> bool { let mut state = self.state.lock().unwrap(); state.titles.insert(surface, title.into()); + state.dirty.insert(surface); if state.wake_queued { false } else { @@ -188,12 +190,23 @@ impl MuxTitleIngress { } fn remove(&self, surface: SurfaceId) { - self.state.lock().unwrap().titles.remove(&surface); + let mut state = self.state.lock().unwrap(); + state.titles.remove(&surface); + state.dirty.remove(&surface); } - fn snapshot(&self) -> HashMap> { + fn take_dirty(&self) -> HashMap> { let mut state = self.state.lock().unwrap(); state.wake_queued = false; + let dirty = std::mem::take(&mut state.dirty); + dirty + .into_iter() + .filter_map(|surface| state.titles.get(&surface).cloned().map(|title| (surface, title))) + .collect() + } + + fn snapshot(&self) -> HashMap> { + let state = self.state.lock().unwrap(); state.titles.clone() } } @@ -2229,7 +2242,16 @@ impl App { } fn apply_mux_titles(&mut self) -> bool { + let titles = self.mux_titles.take_dirty(); + self.apply_mux_title_snapshot(titles) + } + + fn reapply_mux_titles(&mut self) -> bool { let titles = self.mux_titles.snapshot(); + self.apply_mux_title_snapshot(titles) + } + + fn apply_mux_title_snapshot(&mut self, titles: HashMap>) -> bool { if titles.is_empty() { return false; } @@ -2283,7 +2305,7 @@ impl App { } self.tree = tree; self.rebuild_tab_locations(); - self.apply_mux_titles(); + self.reapply_mux_titles(); } fn replace_authoritative_tree(&mut self, tree: TreeView, routing_generation: u64) { @@ -2316,6 +2338,7 @@ impl App { self.drag = None; } self.render_states.remove(&surface); + self.mux_titles.remove(surface); self.session.forget_surface(surface); if self.sidebar_plugin_surface == Some(surface) { self.session.invalidate_sidebar_plugin_sync(); @@ -6892,6 +6915,10 @@ mod tests { assert_eq!(app.tree.pane(2).unwrap().tabs[0].title, "title-9999"); assert_eq!(app.tree.pane(2).unwrap().tabs[1].title, ""); assert!(app.mux_titles.push(updated_surface, "next".to_string())); + let dirty = app.mux_titles.take_dirty(); + assert_eq!(dirty.len(), 1); + assert_eq!(dirty.get(&updated_surface).map(AsRef::as_ref), Some("next")); + assert_eq!(app.mux_titles.snapshot().len(), 2); } #[test] @@ -7035,11 +7062,13 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.replace_tree(notify_tree(42, false)); app.render_states.insert(42, RenderState::new().unwrap()); + app.mux_titles.push(42, "stale-title".to_string()); app.replace_authoritative_tree(TreeView::default(), 0); assert!(!app.render_states.contains_key(&42)); assert!(!app.tab_locations.contains_key(&42)); + assert!(!app.mux_titles.snapshot().contains_key(&42)); } #[test] From 9ae9379a74c312d114fe5b40d26b89562f05ff73 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:30:59 -0700 Subject: [PATCH 067/103] Bound TUI refresh and capture configuration --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 2 +- cmux-tui/crates/cmux-tui-core/src/lib.rs | 2 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 4 +- cmux-tui/crates/cmux-tui/src/app.rs | 56 ++++++++++++++++---- cmux-tui/crates/cmux-tui/src/config.rs | 23 ++++++-- cmux-tui/docs/configuration.md | 4 +- 6 files changed, 69 insertions(+), 22 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 12b67534e3b7..2eead6f9223e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -210,7 +210,7 @@ struct BrowserCaptureOptions { // Two megapixels leave headroom below the 16 MiB transport message cap even // for an incompressible RGBA PNG after base64 and JSON encoding. -const TRANSPORT_SAFE_CAPTURE_MEGAPIXELS: f64 = 2.0; +pub const TRANSPORT_SAFE_CAPTURE_MEGAPIXELS: f64 = 2.0; const DEFAULT_CAPTURE_MEGAPIXELS: f64 = TRANSPORT_SAFE_CAPTURE_MEGAPIXELS; const STALL_THRESHOLD: Duration = Duration::from_secs(2); const BROWSER_COMMAND_QUEUE_CAPACITY: usize = 64; diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 2d942bc855bb..12acae53bed9 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -19,7 +19,7 @@ pub mod layout; pub mod platform; pub mod server; -pub use browser::normalize_url; +pub use browser::{TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; pub use event_bus::{MuxEventBroadcaster, MuxEventReceiver}; pub use layout::{ LayoutResult, Rect, SplitEdge, SplitResize, directional_neighbor, layout_screen, diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 73096920935a..daf7ad15a4cf 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -55,7 +55,7 @@ pub struct SurfaceOptions { pub browser_ephemeral: bool, /// Maximum browser capture size before downscaling, in megapixels. pub browser_max_capture_megapixels: f64, - /// Optional fixed browser capture scale, where 1.0 captures at pane pixels. + /// Optional maximum browser capture scale, further reduced to honor the megapixel cap. pub browser_capture_scale: Option, } @@ -79,7 +79,7 @@ impl Default for SurfaceOptions { browser_mode: BrowserMode::Headful, browser_session_name: "default".to_string(), browser_ephemeral: false, - browser_max_capture_megapixels: 2.0, + browser_max_capture_megapixels: crate::browser::TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, browser_capture_scale: None, } } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 93be2086a3b5..fb739ac516fe 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -59,6 +59,7 @@ const DEFERRED_INPUT_FIXED_BYTES: usize = 64; const BRACKETED_PASTE_MARKER_BYTES: usize = 12; const MAX_DEFERRED_INPUT_BYTES: usize = 4 * 1024 * 1024; const ROUTING_REFRESH_RETRIES: u8 = 1; +const BACKGROUND_REFRESH_RETRIES: u8 = 6; const APP_EVENT_CAPACITY: usize = 4_096; const PTY_FAILURE_CAPACITY: usize = 512; @@ -2433,12 +2434,16 @@ impl App { } } - fn schedule_background_refresh_retry(&mut self) { - self.background_refresh_attempts = - self.background_refresh_attempts.saturating_add(1).min(6); + fn schedule_background_refresh_retry(&mut self) -> bool { + if self.background_refresh_attempts >= BACKGROUND_REFRESH_RETRIES { + self.background_refresh_retry_at = None; + return false; + } + self.background_refresh_attempts += 1; let delay_seconds = 1_u64 << u32::from(self.background_refresh_attempts.saturating_sub(1)); self.background_refresh_retry_at = Some(Instant::now() + Duration::from_secs(delay_seconds.min(30))); + true } fn retry_background_refresh_if_due(&mut self) { @@ -3087,8 +3092,14 @@ impl App { true } Err(error) => { - self.status_message = Some(format!("refresh remote tree failed: {error}")); - self.schedule_background_refresh_retry(); + let retrying = self.schedule_background_refresh_retry(); + self.status_message = Some(if retrying { + format!("refresh remote tree failed; retrying: {error}") + } else { + format!( + "refresh remote tree failed after {BACKGROUND_REFRESH_RETRIES} attempts; automatic retries stopped, reconnect to retry: {error}" + ) + }); let _ = self.session.take_background_refresh_dirty(); false } @@ -6208,12 +6219,12 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, AppEvent, DeferredInput, Drag, MuxTitleIngress, OrderedSession, PaneArea, - PendingSessionMutation, PendingSessionMutationState, PtyFailureIngress, RenderAction, - Selection, SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, - SidebarPluginSyncState, SurfaceResizeDecision, browser_content_size_for_rect, - browser_hover_forward_allowed, forward_mux_events, pane_parts_for_rect, - sidebar_plugin_status_settles_passive_claim, + App, AppEvent, BACKGROUND_REFRESH_RETRIES, DeferredInput, Drag, MuxTitleIngress, + OrderedSession, PaneArea, PendingSessionMutation, PendingSessionMutationState, + PtyFailureIngress, RenderAction, Selection, SessionCompletion, SessionCompletionAction, + SidebarPluginSyncClaim, SidebarPluginSyncState, SurfaceResizeDecision, + browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, + pane_parts_for_rect, sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -7443,6 +7454,29 @@ mod tests { assert_eq!(app.deferred_input.len(), 1); } + #[test] + fn background_tree_refresh_stops_after_retry_budget() { + let mux = Mux::new("background-refresh-budget-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + + for refresh_sequence in 1..=u64::from(BACKGROUND_REFRESH_RETRIES) + 1 { + app.handle(AppEvent::RemoteTreeUpdated { + refresh_sequence, + routing_generation: 0, + result: Err("offline".to_string()), + }) + .unwrap(); + } + + assert_eq!(app.background_refresh_attempts, BACKGROUND_REFRESH_RETRIES); + assert!(app.background_refresh_retry_at.is_none()); + assert!( + app.status_message + .as_deref() + .is_some_and(|message| message.contains("automatic retries stopped, reconnect")) + ); + } + #[test] fn exited_surface_tombstones_are_remote_only_and_pruned_authoritatively() { let mux = Mux::new("surface-tombstone-churn-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/config.rs b/cmux-tui/crates/cmux-tui/src/config.rs index 4bd71e633c75..149c2b654281 100644 --- a/cmux-tui/crates/cmux-tui/src/config.rs +++ b/cmux-tui/crates/cmux-tui/src/config.rs @@ -104,6 +104,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use cmux_tui_core::BrowserMode; use cmux_tui_core::SidebarPluginOptions; use cmux_tui_core::SurfaceOptions; +use cmux_tui_core::TRANSPORT_SAFE_CAPTURE_MEGAPIXELS; use cmux_tui_core::platform; use cmux_tui_core::{DefaultColors, Rgb}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -581,7 +582,7 @@ impl Default for Browser { discover_ports: vec![9222], user_data_dir: None, ephemeral: false, - max_capture_megapixels: 2.0, + max_capture_megapixels: TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, capture_scale: None, } } @@ -1154,11 +1155,14 @@ pub fn load() -> Config { config.browser.ephemeral = ephemeral; } if let Some(megapixels) = raw.browser.max_capture_megapixels { - if megapixels.is_finite() && megapixels > 0.0 { + if megapixels.is_finite() + && megapixels > 0.0 + && megapixels <= TRANSPORT_SAFE_CAPTURE_MEGAPIXELS + { config.browser.max_capture_megapixels = megapixels; } else { eprintln!( - "cmux-tui: ignoring browser.max_capture_megapixels={megapixels:?}; expected > 0" + "cmux-tui: ignoring browser.max_capture_megapixels={megapixels:?}; expected 0 < value <= {TRANSPORT_SAFE_CAPTURE_MEGAPIXELS}" ); } } @@ -1862,13 +1866,22 @@ mod tests { let path = dir.join("mux.json"); std::fs::write( &path, - r##"{"browser": {"max_capture_megapixels": 3.5, "capture_scale": 0.5}}"##, + r##"{"browser": {"max_capture_megapixels": 1.5, "capture_scale": 0.5}}"##, ) .unwrap(); // SAFETY: env mutation in tests is serialized by CONFIG_ENV_LOCK. unsafe { std::env::set_var("CMUX_MUX_CONFIG", &path) }; let config = load(); - assert_eq!(config.browser.max_capture_megapixels, 3.5); + assert_eq!(config.browser.max_capture_megapixels, 1.5); + assert_eq!(config.browser.capture_scale, Some(0.5)); + + std::fs::write( + &path, + r##"{"browser": {"max_capture_megapixels": 3.5, "capture_scale": 0.5}}"##, + ) + .unwrap(); + let config = load(); + assert_eq!(config.browser.max_capture_megapixels, TRANSPORT_SAFE_CAPTURE_MEGAPIXELS); assert_eq!(config.browser.capture_scale, Some(0.5)); std::fs::write( diff --git a/cmux-tui/docs/configuration.md b/cmux-tui/docs/configuration.md index 356c1fc1638f..e00f9d8df3b6 100644 --- a/cmux-tui/docs/configuration.md +++ b/cmux-tui/docs/configuration.md @@ -83,8 +83,8 @@ cmux-tui plugin disable | `browser.discover_ports` | integer array | `[9222]` | Local ports to probe for `/json/version` | | `browser.user_data_dir` | string | `null` | Persistent profile directory for launched Chrome | | `browser.ephemeral` | boolean | `false` | Use a temporary launched Chrome profile and delete it on shutdown | -| `browser.max_capture_megapixels` | number | `2.0` | Maximum browser capture size before downscaling | -| `browser.capture_scale` | number or null | `null` | Fixed capture scale from 0.0 through 1.0 | +| `browser.max_capture_megapixels` | number | `2.0` | Maximum browser capture size before downscaling, from 0.0 through 2.0 | +| `browser.capture_scale` | number or null | `null` | Maximum capture scale from 0.0 through 1.0, reduced further when needed to stay under the megapixel limit | When `browser.ephemeral` is true, it takes precedence over `browser.user_data_dir`: launched Chrome uses a fresh temporary profile, and the configured directory is not deleted. From 84aca8adb3eaea62a2f6c08184aac9282db73a56 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:42:40 -0700 Subject: [PATCH 068/103] Attribute TUI queue pressure to backlog owners --- cmux-tui/crates/cmux-tui-core/src/server.rs | 181 +++++++++++++++----- 1 file changed, 142 insertions(+), 39 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 3b54ce3cb30b..50ab13df36a6 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -426,11 +426,18 @@ const OUTBOUND_CONTROL_BYTE_RESERVE: usize = 16 * 1024 * 1024; struct OutboundStream { id: u64, open: Arc, + terminal_enqueued: Arc, + overflow_text: Arc, } impl OutboundStream { - fn new(id: u64) -> Self { - Self { id, open: Arc::new(AtomicBool::new(true)) } + fn new(id: u64, overflow_text: String) -> Self { + Self { + id, + open: Arc::new(AtomicBool::new(true)), + terminal_enqueued: Arc::new(AtomicBool::new(false)), + overflow_text: overflow_text.into(), + } } fn is_open(&self) -> bool { @@ -467,8 +474,11 @@ impl MessageWriter { } } - fn start_stream(&self) -> OutboundStream { - OutboundStream::new(self.next_stream_id.fetch_add(1, Ordering::Relaxed)) + fn start_stream(&self, overflow: &Value) -> std::io::Result { + Ok(OutboundStream::new( + self.next_stream_id.fetch_add(1, Ordering::Relaxed), + serde_json::to_string(overflow)?, + )) } fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { @@ -532,7 +542,7 @@ struct BoundedOutboundState { struct RegularOutbound { text: String, - stream_id: u64, + stream: OutboundStream, } impl BoundedOutbound { @@ -545,16 +555,40 @@ impl BoundedOutbound { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stream closed")); } let bytes = text.len(); - if state.regular.len() >= OUTBOUND_CAPACITY - || bytes > OUTBOUND_BYTE_CAPACITY.saturating_sub(state.regular_bytes) - { + if bytes > OUTBOUND_BYTE_CAPACITY { + Self::terminate_stream_locked(&mut state, stream)?; + self.changed.notify_one(); return Err(std::io::Error::new( std::io::ErrorKind::WouldBlock, "outbound queue overflowed", )); } + loop { + let byte_full = bytes > OUTBOUND_BYTE_CAPACITY.saturating_sub(state.regular_bytes); + let count_full = state.regular.len() >= OUTBOUND_CAPACITY; + if !byte_full && !count_full { + break; + } + let Some(victim) = Self::largest_stream(&state, byte_full) else { + Self::terminate_stream_locked(&mut state, stream)?; + self.changed.notify_one(); + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "outbound queue overflowed", + )); + }; + let incoming_terminated = victim.id == stream.id; + Self::terminate_stream_locked(&mut state, &victim)?; + if incoming_terminated { + self.changed.notify_one(); + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "outbound queue overflowed", + )); + } + } state.regular_bytes += bytes; - state.regular.push_back(RegularOutbound { text, stream_id: stream.id }); + state.regular.push_back(RegularOutbound { text, stream: stream.clone() }); self.changed.notify_one(); Ok(()) } @@ -567,11 +601,40 @@ impl BoundedOutbound { } fn push_terminal(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { - stream.close(); let mut state = self.state.lock().unwrap(); + stream.close(); + Self::purge_stream_locked(&mut state, stream.id); + if stream.terminal_enqueued.swap(true, Ordering::AcqRel) { + return Ok(()); + } + Self::push_control_locked(&mut state, text)?; + self.changed.notify_one(); + Ok(()) + } + + fn terminate_stream_locked( + state: &mut BoundedOutboundState, + stream: &OutboundStream, + ) -> std::io::Result<()> { + stream.close(); + Self::purge_stream_locked(state, stream.id); + if stream.terminal_enqueued.swap(true, Ordering::AcqRel) { + return Ok(()); + } + if let Err(error) = Self::push_control_locked(state, stream.overflow_text.to_string()) { + state.closed = true; + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + format!("could not report stream overflow: {error}"), + )); + } + Ok(()) + } + + fn purge_stream_locked(state: &mut BoundedOutboundState, stream_id: u64) { let mut removed_bytes = 0; state.regular.retain(|message| { - if message.stream_id == stream.id { + if message.stream.id == stream_id { removed_bytes += message.text.len(); false } else { @@ -579,9 +642,20 @@ impl BoundedOutbound { } }); state.regular_bytes -= removed_bytes; - Self::push_control_locked(&mut state, text)?; - self.changed.notify_one(); - Ok(()) + } + + fn largest_stream(state: &BoundedOutboundState, by_bytes: bool) -> Option { + let mut usage = HashMap::::new(); + for message in &state.regular { + let entry = + usage.entry(message.stream.id).or_insert_with(|| (0, 0, message.stream.clone())); + entry.0 += 1; + entry.1 += message.text.len(); + } + usage + .into_values() + .max_by_key(|(messages, bytes, _)| if by_bytes { *bytes } else { *messages }) + .map(|(_, _, stream)| stream) } fn push_control_locked(state: &mut BoundedOutboundState, text: String) -> std::io::Result<()> { @@ -1265,7 +1339,7 @@ fn spawn_attach_notification_stream( std::thread::Builder::new() .name("mux-attach-notifications".into()) .spawn(move || { - while writer.is_open() && !lifecycle.is_canceled() { + while writer.is_open() && outbound_stream.is_open() && !lifecycle.is_canceled() { let event = match events.recv_timeout(STREAM_DISCONNECT_POLL) { Ok(event) => event, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, @@ -1316,15 +1390,7 @@ fn report_attach_overflow( outbound_stream: &OutboundStream, ) { if lifecycle.claim_overflow_report() { - let _ = writer.send_terminal( - &json!({ - "event": "overflow", - "scope": "surface", - "surface": surface_id, - "error": "surface stream fell behind; reattach the surface", - }), - outbound_stream, - ); + let _ = writer.send_terminal(&attach_overflow_json(surface_id), outbound_stream); } } @@ -1830,10 +1896,10 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Command::Subscribe => { let events = mux.subscribe(); let writer = writer.clone(); - let outbound_stream = writer.start_stream(); + let outbound_stream = writer.start_stream(&subscription_overflow_json())?; std::thread::Builder::new().name("mux-events-out".into()).spawn(move || { let mut transport_overflow = false; - while writer.is_open() { + while writer.is_open() && outbound_stream.is_open() { let event = match events.recv_timeout(STREAM_DISCONNECT_POLL) { Ok(event) => event, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, @@ -1854,7 +1920,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho Command::AttachSurface { surface: surface_id } => { let surface = get_surface(mux, surface_id)?; let lifecycle = AttachLifecycle::default(); - let outbound_stream = writer.start_stream(); + let outbound_stream = writer.start_stream(&attach_overflow_json(surface_id))?; spawn_attach_notification_stream( mux.clone(), surface_id, @@ -1878,7 +1944,8 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho } let writer = writer.clone(); std::thread::Builder::new().name("mux-attach-out".into()).spawn(move || { - while writer.is_open() && !lifecycle.is_canceled() { + while writer.is_open() && outbound_stream.is_open() && !lifecycle.is_canceled() + { match frames.notify.recv_timeout(STREAM_DISCONNECT_POLL) { Ok(()) => {} Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, @@ -1939,7 +2006,10 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho } let writer = writer.clone(); std::thread::Builder::new().name("mux-attach-out".into()).spawn(move || { - while writer.is_open() && !attach.lifecycle.is_canceled() { + while writer.is_open() + && outbound_stream.is_open() + && !attach.lifecycle.is_canceled() + { let frame = match attach.stream.recv_timeout(STREAM_DISCONNECT_POLL) { Ok(frame) => frame, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, @@ -2026,6 +2096,15 @@ fn subscription_overflow_json() -> Value { }) } +fn attach_overflow_json(surface: SurfaceId) -> Value { + json!({ + "event": "overflow", + "scope": "surface", + "surface": surface, + "error": "surface stream fell behind; reattach the surface", + }) +} + /// Remove the socket file (call on clean shutdown). pub fn cleanup(path: &Path) { let _ = std::fs::remove_file(path); @@ -2050,35 +2129,57 @@ mod tests { fn bounded_writer_reserves_a_control_lane_for_responses_and_overflow() { let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); - let backlog = writer.start_stream(); + let backlog = writer.start_stream(&json!({"event": "overflow"})).unwrap(); - for sequence in 0..OUTBOUND_CAPACITY { + for sequence in 0..OUTBOUND_CAPACITY - 1 { writer .send_stream(&json!({"event": "output", "sequence": sequence}), &backlog) .unwrap(); } - let error = writer.send_stream(&json!({"event": "extra"}), &backlog).unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); - assert!(writer.is_open()); - let failed_stream = writer.start_stream(); + let failed_stream = writer.start_stream(&subscription_overflow_json()).unwrap(); writer.send_control(&json!({"id": 42, "ok": true, "data": {}})).unwrap(); writer.send_terminal(&subscription_overflow_json(), &failed_stream).unwrap(); let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(response["id"], 42); let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(terminal["event"], "overflow"); - let drained = (0..OUTBOUND_CAPACITY) + let drained = (0..OUTBOUND_CAPACITY - 1) .map(|_| outbound.try_pop().expect("accepted output")) .collect::>(); assert!(drained[0].contains("\"sequence\":0")); assert!(writer.is_open()); } + #[test] + fn global_pressure_terminates_the_stream_occupying_the_backlog() { + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); + let noisy = writer.start_stream(&json!({"event": "overflow", "stream": "noisy"})).unwrap(); + let quiet = writer.start_stream(&json!({"event": "overflow", "stream": "quiet"})).unwrap(); + + for sequence in 0..OUTBOUND_CAPACITY { + writer.send_stream(&json!({"event": "output", "sequence": sequence}), &noisy).unwrap(); + } + writer.send_stream(&json!({"event": "tree-changed"}), &quiet).unwrap(); + + let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(terminal["stream"], "noisy"); + let quiet_event: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(quiet_event["event"], "tree-changed"); + assert_eq!(outbound.try_pop(), None); + assert_eq!( + writer.send_stream(&json!({"event": "late"}), &noisy).unwrap_err().kind(), + std::io::ErrorKind::BrokenPipe + ); + assert!(quiet.is_open()); + assert!(writer.is_open()); + } + #[test] fn bounded_writer_rejects_payloads_beyond_each_byte_budget() { let outbound = BoundedOutbound::default(); - let stream = OutboundStream::new(1); + let stream = OutboundStream::new(1, r#"{"event":"overflow"}"#.to_string()); let regular = outbound.push_regular("x".repeat(OUTBOUND_BYTE_CAPACITY + 1), &stream).unwrap_err(); @@ -2086,6 +2187,8 @@ mod tests { let control = outbound.push_control("x".repeat(OUTBOUND_CONTROL_BYTE_RESERVE + 1)).unwrap_err(); assert_eq!(control.kind(), std::io::ErrorKind::WouldBlock); + let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(terminal["event"], "overflow"); assert_eq!(outbound.try_pop(), None); } @@ -2093,8 +2196,8 @@ mod tests { fn terminal_overflow_purges_only_its_stream_and_rejects_late_frames() { let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); - let stale = writer.start_stream(); - let unrelated = writer.start_stream(); + let stale = writer.start_stream(&subscription_overflow_json()).unwrap(); + let unrelated = writer.start_stream(&subscription_overflow_json()).unwrap(); writer.send_stream(&json!({"event": "output", "stream": "stale"}), &stale).unwrap(); writer.send_stream(&json!({"event": "output", "stream": "unrelated"}), &unrelated).unwrap(); From 85ec61fb60b733f5ccafdb50f880e758a7826f53 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:55:32 -0700 Subject: [PATCH 069/103] Bound TUI replay and title recovery state --- cmux-tui/crates/cmux-tui-core/src/lib.rs | 5 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 105 +++++++++++++++++-- cmux-tui/crates/cmux-tui/src/app.rs | 57 +++++++++- 3 files changed, 155 insertions(+), 12 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 12acae53bed9..9f1bccca4e69 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -33,8 +33,9 @@ pub use mux::{ }; pub use short_id::assign_short_ids; pub use surface::{ - AttachFrame, AttachStream, BrowserAttachState, BrowserFrame, BrowserFrameStream, BrowserSource, - BrowserStatus, DefaultColors, Surface, SurfaceKind, SurfaceOptions, + AttachFrame, AttachFrameReceiver, AttachStream, BrowserAttachState, BrowserFrame, + BrowserFrameStream, BrowserSource, BrowserStatus, DefaultColors, Surface, SurfaceKind, + SurfaceOptions, }; pub use cmux_tui_cdp::BrowserMode; diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index daf7ad15a4cf..52d5767e0680 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -6,10 +6,14 @@ //! VT operations. use std::io::{Read, Write}; +use std::mem::size_of; use std::ops::Deref; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{SyncSender, TrySendError}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{ + Receiver, RecvError, RecvTimeoutError, SyncSender, TryRecvError, TrySendError, +}; use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; use ghostty_vt::{Callbacks, RenderState, Rgb, Terminal}; use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; @@ -98,7 +102,7 @@ pub struct AttachStream { pub cols: u16, pub rows: u16, pub replay: Vec, - pub stream: std::sync::mpsc::Receiver, + pub stream: AttachFrameReceiver, pub(crate) lifecycle: AttachLifecycle, } @@ -109,6 +113,46 @@ pub enum AttachFrame { } const ATTACH_STREAM_CAPACITY: usize = 256; +const ATTACH_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; + +pub struct AttachFrameReceiver { + receiver: Receiver, + queued_bytes: Arc, +} + +impl AttachFrameReceiver { + fn account_received(&self, frame: &AttachFrame) { + self.queued_bytes.fetch_sub(frame.retained_bytes(), Ordering::AcqRel); + } + + pub fn recv(&self) -> Result { + let frame = self.receiver.recv()?; + self.account_received(&frame); + Ok(frame) + } + + pub fn recv_timeout(&self, timeout: Duration) -> Result { + let frame = self.receiver.recv_timeout(timeout)?; + self.account_received(&frame); + Ok(frame) + } + + pub fn try_recv(&self) -> Result { + let frame = self.receiver.try_recv()?; + self.account_received(&frame); + Ok(frame) + } +} + +impl AttachFrame { + fn retained_bytes(&self) -> usize { + size_of::() + + match self { + Self::Output(bytes) => bytes.capacity(), + Self::Resized { replay, .. } => replay.capacity(), + } + } +} #[derive(Clone, Default)] pub(crate) struct AttachLifecycle { @@ -153,6 +197,8 @@ impl AttachLifecycle { struct AttachTap { sender: SyncSender, lifecycle: AttachLifecycle, + queued_bytes: Arc, + max_queued_bytes: usize, } impl AttachTap { @@ -160,13 +206,26 @@ impl AttachTap { if self.lifecycle.is_canceled() { return false; } + let frame_bytes = frame.retained_bytes(); + if self + .queued_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |queued| { + queued.checked_add(frame_bytes).filter(|next| *next <= self.max_queued_bytes) + }) + .is_err() + { + self.lifecycle.mark_overflow(); + return false; + } match self.sender.try_send(frame) { Ok(()) => true, Err(TrySendError::Full(_)) => { + self.queued_bytes.fetch_sub(frame_bytes, Ordering::AcqRel); self.lifecycle.mark_overflow(); false } Err(TrySendError::Disconnected(_)) => { + self.queued_bytes.fetch_sub(frame_bytes, Ordering::AcqRel); self.lifecycle.cancel(); false } @@ -665,12 +724,24 @@ impl Surface { }; let mut term = pty.term.lock().unwrap(); let (tx, rx) = std::sync::mpsc::sync_channel(ATTACH_STREAM_CAPACITY); + let queued_bytes = Arc::new(AtomicUsize::new(0)); // Snapshot and tap registration under the same terminal lock: // the reader thread cannot apply bytes between the two. let replay = term.vt_replay()?; let (cols, rows) = (term.cols(), term.rows()); - pty.taps.lock().unwrap().push(AttachTap { sender: tx, lifecycle: lifecycle.clone() }); - Ok(AttachStream { cols, rows, replay, stream: rx, lifecycle }) + pty.taps.lock().unwrap().push(AttachTap { + sender: tx, + lifecycle: lifecycle.clone(), + queued_bytes: queued_bytes.clone(), + max_queued_bytes: ATTACH_STREAM_MAX_BYTES, + }); + Ok(AttachStream { + cols, + rows, + replay, + stream: AttachFrameReceiver { receiver: rx, queued_bytes }, + lifecycle, + }) } pub fn kill(&self) { @@ -901,7 +972,12 @@ mod tests { fn attach_tap_overflow_cancels_the_shared_lifecycle_once() { let lifecycle = AttachLifecycle::default(); let (sender, _receiver) = std::sync::mpsc::sync_channel(1); - let tap = AttachTap { sender, lifecycle: lifecycle.clone() }; + let tap = AttachTap { + sender, + lifecycle: lifecycle.clone(), + queued_bytes: Arc::new(AtomicUsize::new(0)), + max_queued_bytes: usize::MAX, + }; assert!(tap.try_send(AttachFrame::Output(vec![1]))); assert!(!tap.try_send(AttachFrame::Output(vec![2]))); @@ -910,4 +986,21 @@ mod tests { assert!(lifecycle.claim_overflow_report()); assert!(!lifecycle.claim_overflow_report()); } + + #[test] + fn attach_tap_overflow_is_bounded_by_retained_bytes() { + let lifecycle = AttachLifecycle::default(); + let (sender, _receiver) = std::sync::mpsc::sync_channel(4); + let frame_bytes = AttachFrame::Output(vec![1]).retained_bytes(); + let tap = AttachTap { + sender, + lifecycle: lifecycle.clone(), + queued_bytes: Arc::new(AtomicUsize::new(0)), + max_queued_bytes: frame_bytes, + }; + + assert!(tap.try_send(AttachFrame::Output(vec![1]))); + assert!(!tap.try_send(AttachFrame::Output(vec![2]))); + assert!(lifecycle.overflowed()); + } } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index fb739ac516fe..f44fec5dce5d 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -120,8 +120,20 @@ fn forward_mux_events( // during recovery are retained by the new mailbox. session_events = event_source.events(); let routing_generation = routing_mutation_committed.load(Ordering::Acquire); + let title_snapshot_epoch = mux_titles.current_epoch(); let recovered = event_source.refresh_tree().map_err(|error| error.to_string()); let recovery_succeeded = recovered.is_ok(); + if let Ok(tree) = &recovered { + let live_surfaces = tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .map(|tab| tab.surface) + .collect::>(); + mux_titles.reconcile_authoritative(&live_surfaces, title_snapshot_epoch); + } if tx .send(AppEvent::MuxSubscriptionRecovered { recovery_generation, @@ -172,15 +184,23 @@ fn forward_mux_event( #[derive(Default)] struct MuxTitleIngressState { wake_queued: bool, - titles: HashMap>, + epoch: u64, + titles: HashMap, dirty: HashSet, } +struct RetainedMuxTitle { + title: Arc, + epoch: u64, +} + impl MuxTitleIngress { /// Returns true when the app channel needs one wake event. fn push(&self, surface: SurfaceId, title: impl Into>) -> bool { let mut state = self.state.lock().unwrap(); - state.titles.insert(surface, title.into()); + state.epoch = state.epoch.saturating_add(1); + let epoch = state.epoch; + state.titles.insert(surface, RetainedMuxTitle { title: title.into(), epoch }); state.dirty.insert(surface); if state.wake_queued { false @@ -202,13 +222,28 @@ impl MuxTitleIngress { let dirty = std::mem::take(&mut state.dirty); dirty .into_iter() - .filter_map(|surface| state.titles.get(&surface).cloned().map(|title| (surface, title))) + .filter_map(|surface| { + state.titles.get(&surface).map(|retained| (surface, retained.title.clone())) + }) .collect() } fn snapshot(&self) -> HashMap> { let state = self.state.lock().unwrap(); - state.titles.clone() + state.titles.iter().map(|(surface, retained)| (*surface, retained.title.clone())).collect() + } + + fn current_epoch(&self) -> u64 { + self.state.lock().unwrap().epoch + } + + fn reconcile_authoritative(&self, live_surfaces: &HashSet, through_epoch: u64) { + let mut state = self.state.lock().unwrap(); + state.titles.retain(|surface, retained| { + live_surfaces.contains(surface) || retained.epoch > through_epoch + }); + let retained_surfaces = state.titles.keys().copied().collect::>(); + state.dirty.retain(|surface| retained_surfaces.contains(surface)); } } @@ -6951,6 +6986,20 @@ mod tests { assert_eq!(app.tree.pane(2).unwrap().tabs[0].title, "future-title"); } + #[test] + fn title_ingress_prunes_pre_snapshot_orphans_but_keeps_concurrent_updates() { + let titles = MuxTitleIngress::default(); + titles.push(41, "stale-title"); + let snapshot_epoch = titles.current_epoch(); + titles.push(42, "concurrent-title"); + + titles.reconcile_authoritative(&HashSet::new(), snapshot_epoch); + + let retained = titles.snapshot(); + assert!(!retained.contains_key(&41)); + assert_eq!(retained.get(&42).map(AsRef::as_ref), Some("concurrent-title")); + } + #[test] fn mux_forwarder_recovers_after_bounded_mailbox_overflow() { let mux = Mux::new("mux-forwarder-overflow-test", SurfaceOptions::default()); From d9741a4e0466230df0be9f44a91c42538b3295f6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:12:10 -0700 Subject: [PATCH 070/103] Preserve TUI release and socket lifecycles --- cmux-tui/crates/cmux-tui-core/src/server.rs | 56 ++++++++++++--------- cmux-tui/crates/cmux-tui/src/pty_input.rs | 30 +++++++++-- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 50ab13df36a6..563e0f56f8ef 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -681,6 +681,7 @@ impl BoundedOutbound { Self::pop_locked(&mut state) } + #[cfg(test)] fn recv(&self) -> Option { let mut state = self.state.lock().unwrap(); loop { @@ -870,40 +871,47 @@ fn sanitize_window_title(title: &str) -> String { } fn handle_connection(mux: Arc, stream: Box) { - let Ok(mut write_half) = stream.try_clone_box() else { return }; - if write_half.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)).is_err() { + if stream.set_read_timeout(Some(STREAM_DISCONNECT_POLL)).is_err() + || stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)).is_err() + { return; } let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); - let writer_outbound = outbound; - let Ok(writer_thread) = - std::thread::Builder::new().name("mux-line-out".into()).spawn(move || { - while let Some(text) = writer_outbound.recv() { - if write_half.write_all(text.as_bytes()).is_err() - || write_half.write_all(b"\n").is_err() - { - writer_outbound.close(); - break; - } + let mut reader = BufReader::new(stream); + let mut pending = Vec::new(); + 'connection: loop { + while let Some(text) = outbound.try_pop() { + let stream = reader.get_mut(); + if stream.write_all(text.as_bytes()).is_err() || stream.write_all(b"\n").is_err() { + break 'connection; } - }) - else { - writer.close(); - return; - }; - let reader = BufReader::new(stream); - for line in reader.lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { - continue; } - if !handle_message(&mux, &line, &writer) { + if !writer.is_open() { break; } + + match reader.read_until(b'\n', &mut pending) { + Ok(0) => break, + Ok(_) => { + if !pending.ends_with(b"\n") { + continue; + } + let Ok(line) = std::str::from_utf8(&pending) else { break }; + if !line.trim().is_empty() && !handle_message(&mux, line, &writer) { + break; + } + pending.clear(); + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(_) => break, + } } writer.close(); - let _ = writer_thread.join(); } fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<&str>) { diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 5629650df4b6..c0c4dfc92736 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -555,7 +555,7 @@ fn enqueue_bounded_with_evictions( fn worker(queue: Arc, on_failure: Arc) { loop { - let event = { + let mut event = { let mut state = queue.state.lock().unwrap(); while state.events.is_empty() && !state.closed { state = queue.changed.wait(state).unwrap(); @@ -573,7 +573,7 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc Date: Tue, 14 Jul 2026 19:32:01 -0700 Subject: [PATCH 071/103] Bound TUI release recovery and initial attach --- cmux-tui/crates/cmux-tui-core/src/server.rs | 123 +++++++++++++++++--- cmux-tui/crates/cmux-tui/src/pty_input.rs | 30 ++++- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 563e0f56f8ef..04be95704e86 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -450,6 +450,7 @@ impl OutboundStream { } trait MessageSink: Send + Sync { + fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; fn send_control(&self, value: &Value) -> std::io::Result<()>; fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; @@ -492,6 +493,17 @@ impl MessageWriter { result } + fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self.sink.send_initial(value, stream); + if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { + stream.close(); + } + result + } + fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); @@ -533,6 +545,7 @@ struct BoundedOutbound { #[derive(Default)] struct BoundedOutboundState { + initial: VecDeque, control: VecDeque, regular: VecDeque, control_bytes: usize, @@ -547,6 +560,19 @@ struct RegularOutbound { impl BoundedOutbound { fn push_regular(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { + self.push_regular_with_priority(text, stream, false) + } + + fn push_initial(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { + self.push_regular_with_priority(text, stream, true) + } + + fn push_regular_with_priority( + &self, + text: String, + stream: &OutboundStream, + initial: bool, + ) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); if state.closed { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); @@ -565,7 +591,7 @@ impl BoundedOutbound { } loop { let byte_full = bytes > OUTBOUND_BYTE_CAPACITY.saturating_sub(state.regular_bytes); - let count_full = state.regular.len() >= OUTBOUND_CAPACITY; + let count_full = state.initial.len() + state.regular.len() >= OUTBOUND_CAPACITY; if !byte_full && !count_full { break; } @@ -588,7 +614,12 @@ impl BoundedOutbound { } } state.regular_bytes += bytes; - state.regular.push_back(RegularOutbound { text, stream: stream.clone() }); + let message = RegularOutbound { text, stream: stream.clone() }; + if initial { + state.initial.push_back(message); + } else { + state.regular.push_back(message); + } self.changed.notify_one(); Ok(()) } @@ -633,6 +664,14 @@ impl BoundedOutbound { fn purge_stream_locked(state: &mut BoundedOutboundState, stream_id: u64) { let mut removed_bytes = 0; + state.initial.retain(|message| { + if message.stream.id == stream_id { + removed_bytes += message.text.len(); + false + } else { + true + } + }); state.regular.retain(|message| { if message.stream.id == stream_id { removed_bytes += message.text.len(); @@ -646,7 +685,7 @@ impl BoundedOutbound { fn largest_stream(state: &BoundedOutboundState, by_bytes: bool) -> Option { let mut usage = HashMap::::new(); - for message in &state.regular { + for message in state.initial.iter().chain(&state.regular) { let entry = usage.entry(message.stream.id).or_insert_with(|| (0, 0, message.stream.clone())); entry.0 += 1; @@ -696,6 +735,10 @@ impl BoundedOutbound { } fn pop_locked(state: &mut BoundedOutboundState) -> Option { + if let Some(message) = state.initial.pop_front() { + state.regular_bytes -= message.text.len(); + return Some(message.text); + } if let Some(text) = state.control.pop_front() { state.control_bytes -= text.len(); return Some(text); @@ -720,6 +763,11 @@ struct QueuedSink { } impl MessageSink for QueuedSink { + fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { + let text = serde_json::to_string(value)?; + self.outbound.push_initial(text, stream) + } + fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { let text = serde_json::to_string(value)?; self.outbound.push_regular(text, stream) @@ -1929,13 +1977,6 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho let surface = get_surface(mux, surface_id)?; let lifecycle = AttachLifecycle::default(); let outbound_stream = writer.start_stream(&attach_overflow_json(surface_id))?; - spawn_attach_notification_stream( - mux.clone(), - surface_id, - writer.clone(), - lifecycle.clone(), - outbound_stream.clone(), - )?; if surface.kind() == SurfaceKind::Browser { let (state, frames) = match surface.attach_frames() { Ok(attach) => attach, @@ -1944,12 +1985,19 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho return Err(error); } }; - if let Err(error) = - writer.send_control(&browser_state_json(surface_id, &state, true)) + if let Err(error) = writer + .send_initial(&browser_state_json(surface_id, &state, true), &outbound_stream) { handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } + spawn_attach_notification_stream( + mux.clone(), + surface_id, + writer.clone(), + lifecycle.clone(), + outbound_stream.clone(), + )?; let writer = writer.clone(); std::thread::Builder::new().name("mux-attach-out".into()).spawn(move || { while writer.is_open() && outbound_stream.is_open() && !lifecycle.is_canceled() @@ -2002,16 +2050,26 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho return Err(error.into()); } }; - if let Err(error) = writer.send_control(&json!({ - "event": "vt-state", - "surface": surface_id, - "cols": attach.cols, - "rows": attach.rows, - "data": base64::engine::general_purpose::STANDARD.encode(attach.replay), - })) { + if let Err(error) = writer.send_initial( + &json!({ + "event": "vt-state", + "surface": surface_id, + "cols": attach.cols, + "rows": attach.rows, + "data": base64::engine::general_purpose::STANDARD.encode(attach.replay), + }), + &outbound_stream, + ) { handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } + spawn_attach_notification_stream( + mux.clone(), + surface_id, + writer.clone(), + lifecycle, + outbound_stream.clone(), + )?; let writer = writer.clone(); std::thread::Builder::new().name("mux-attach-out".into()).spawn(move || { while writer.is_open() @@ -2159,6 +2217,33 @@ mod tests { assert!(writer.is_open()); } + #[test] + fn initial_stream_state_precedes_its_response_and_overflows_only_its_stream() { + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); + let stream = writer.start_stream(&attach_overflow_json(7)).unwrap(); + + writer.send_initial(&json!({"event": "vt-state", "surface": 7}), &stream).unwrap(); + writer.send_control(&json!({"id": 1, "ok": true})).unwrap(); + let initial: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(initial["event"], "vt-state"); + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["id"], 1); + + let oversized = writer.start_stream(&attach_overflow_json(8)).unwrap(); + let error = writer + .send_initial( + &json!({"event": "vt-state", "data": "x".repeat(OUTBOUND_BYTE_CAPACITY)}), + &oversized, + ) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + let overflow: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(overflow["event"], "overflow"); + assert_eq!(overflow["surface"], 8); + assert!(writer.is_open()); + } + #[test] fn global_pressure_terminates_the_stream_occupying_the_backlog() { let outbound = Arc::new(BoundedOutbound::default()); diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index c0c4dfc92736..c851ba81a155 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -18,6 +18,7 @@ use crate::session::{SurfaceHandle, is_remote_timeout, is_remote_transport_failu const QUEUE_CAPACITY: usize = 512; const MAX_QUEUED_BYTES: usize = 4 * 1024 * 1024; const RESERVED_RELEASE_BYTES: usize = 64; +const REMOTE_RELEASE_MAX_ATTEMPTS: u8 = 3; pub type PtyInputBytes = SmallVec<[u8; 64]>; @@ -55,6 +56,7 @@ pub struct PtyInputEvent { coalesce_key: Option<(&'static str, u64)>, remote: bool, reservation_id: Option, + remote_release_attempts: u8, } impl PtyInputEvent { @@ -76,6 +78,7 @@ impl PtyInputEvent { coalesce_key: None, remote, reservation_id: None, + remote_release_attempts: 0, } } @@ -118,6 +121,7 @@ impl PtyInputEvent { coalesce_key, remote, reservation_id: None, + remote_release_attempts: 0, } } } @@ -573,6 +577,9 @@ fn worker(queue: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc, on_failure: Arc Date: Tue, 14 Jul 2026 19:49:52 -0700 Subject: [PATCH 072/103] Scope TUI pointer routing barriers --- cmux-tui/crates/cmux-tui/src/app.rs | 299 ++++++++++++++++++---------- cmux-tui/docs/concepts.md | 2 +- cmux-tui/spec/frontends.md | 10 +- 3 files changed, 196 insertions(+), 115 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index f44fec5dce5d..6dad6e3292d8 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -78,7 +78,10 @@ pub enum AppEvent { BrowserResizeFailed(BrowserResizeFailure), PtyFailuresReady, PtyOperationFailed(PtyOperationFailure), - SessionMutationSettled(SessionMutationOutcome), + SessionMutationSettled { + outcome: SessionMutationOutcome, + routing: bool, + }, RemoteTreeUpdated { refresh_sequence: u64, routing_generation: u64, @@ -345,6 +348,8 @@ enum SessionCompletionAction { struct PendingSessionMutationState { events: SyncSender, pending_mutations: Arc, + pending_routing_mutations: Arc, + routing: bool, cancellation_pending: Arc, settled: AtomicBool, } @@ -355,7 +360,10 @@ struct PendingSessionMutation(Arc); impl PendingSessionMutation { fn settle(self, outcome: SessionMutationOutcome) { if !self.0.settled.swap(true, Ordering::AcqRel) { - let _ = self.0.events.send(AppEvent::SessionMutationSettled(outcome)); + let _ = self + .0 + .events + .send(AppEvent::SessionMutationSettled { outcome, routing: self.0.routing }); } } @@ -366,6 +374,13 @@ impl PendingSessionMutation { Ordering::Acquire, |pending| pending.checked_sub(1), ); + if self.0.routing { + let _ = self.0.pending_routing_mutations.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |pending| pending.checked_sub(1), + ); + } } } } @@ -373,10 +388,10 @@ impl PendingSessionMutation { impl Drop for PendingSessionMutationState { fn drop(&mut self) { if !self.settled.load(Ordering::Acquire) { - match self - .events - .try_send(AppEvent::SessionMutationSettled(SessionMutationOutcome::Canceled)) - { + match self.events.try_send(AppEvent::SessionMutationSettled { + outcome: SessionMutationOutcome::Canceled, + routing: self.routing, + }) { Ok(()) => {} Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => { let _ = self.pending_mutations.fetch_update( @@ -384,6 +399,13 @@ impl Drop for PendingSessionMutationState { Ordering::Acquire, |pending| pending.checked_sub(1), ); + if self.routing { + let _ = self.pending_routing_mutations.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |pending| pending.checked_sub(1), + ); + } self.cancellation_pending.store(true, Ordering::Release); } } @@ -526,6 +548,7 @@ pub struct OrderedSession { events: SyncSender, remote: bool, pending_mutations: Arc, + pending_routing_mutations: Arc, cancellation_pending: Arc, committed_mutation_generation: Arc, routing_mutation_started: Arc, @@ -552,6 +575,7 @@ impl OrderedSession { events, remote, pending_mutations: Arc::new(AtomicUsize::new(0)), + pending_routing_mutations: Arc::new(AtomicUsize::new(0)), cancellation_pending: Arc::new(AtomicBool::new(false)), committed_mutation_generation: Arc::new(AtomicU64::new(0)), routing_mutation_started: Arc::new(AtomicU64::new(0)), @@ -571,10 +595,19 @@ impl OrderedSession { } fn pending_mutation(&self) -> PendingSessionMutation { + self.pending_mutation_with_routing(false) + } + + fn pending_mutation_with_routing(&self, routing: bool) -> PendingSessionMutation { self.pending_mutations.fetch_add(1, Ordering::AcqRel); + if routing { + self.pending_routing_mutations.fetch_add(1, Ordering::AcqRel); + } PendingSessionMutation(Arc::new(PendingSessionMutationState { events: self.events.clone(), pending_mutations: self.pending_mutations.clone(), + pending_routing_mutations: self.pending_routing_mutations.clone(), + routing, cancellation_pending: self.cancellation_pending.clone(), settled: AtomicBool::new(false), })) @@ -726,6 +759,10 @@ impl OrderedSession { self.pending_mutations.load(Ordering::Acquire) > 0 } + fn has_pending_routing_mutations(&self) -> bool { + self.pending_routing_mutations.load(Ordering::Acquire) > 0 + } + fn routing_mutation_started(&self) -> u64 { self.routing_mutation_started.load(Ordering::Acquire) } @@ -734,12 +771,23 @@ impl OrderedSession { self.routing_mutation_committed.load(Ordering::Acquire) } - fn settle_pending_mutation(&self) { + fn settle_pending_mutation(&self, routing: bool) { let result = self.pending_mutations.fetch_update(Ordering::AcqRel, Ordering::Acquire, |pending| { pending.checked_sub(1) }); debug_assert!(result.is_ok(), "session mutation completion without a pending operation"); + if routing { + let result = self.pending_routing_mutations.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |pending| pending.checked_sub(1), + ); + debug_assert!( + result.is_ok(), + "routing mutation completion without a pending operation" + ); + } } fn take_cancellation_pending(&self) -> bool { @@ -873,7 +921,7 @@ impl OrderedSession { + 'static, ) { let session = self.inner.clone(); - let pending = self.pending_mutation(); + let pending = self.pending_mutation_with_routing(routing); let remote = self.remote; let committed_mutation_generation = self.committed_mutation_generation.clone(); let routing_token = @@ -2863,7 +2911,7 @@ impl App { return Ok(self.defer_input(input)); } AppEvent::Input(input @ Event::Mouse(_)) - if (self.session.has_pending_mutations() + if (self.session.has_pending_routing_mutations() || self.session.remote_tree_is_stale() || self.mux_recovery_generation.load(Ordering::Acquire) != 0 || self.routing_refresh_pending) @@ -2991,8 +3039,8 @@ impl App { } AppEvent::PtyFailuresReady => Ok(self.apply_pty_failures()), AppEvent::PtyOperationFailed(failure) => Ok(self.apply_pty_operation_failure(failure)), - AppEvent::SessionMutationSettled(outcome) => { - self.session.settle_pending_mutation(); + AppEvent::SessionMutationSettled { outcome, routing } => { + self.session.settle_pending_mutation(routing); match outcome { SessionMutationOutcome::Success { tree } => { if let Some(tree) = tree { @@ -6290,6 +6338,10 @@ mod tests { use crate::session::{Session, SidebarPluginSurface, SurfaceHandle, TreeView}; use crate::sidebar_files::FileBrowser; + fn settled(outcome: super::SessionMutationOutcome) -> AppEvent { + AppEvent::SessionMutationSettled { outcome, routing: false } + } + #[test] fn browser_omnibar_reduces_content_rect_for_graphics_and_input() { let rect = Rect { x: 10, y: 4, width: 80, height: 24 }; @@ -6711,7 +6763,7 @@ mod tests { assert_eq!(app.deferred_input.len(), 1); release_tx.send(()).unwrap(); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!(settled, AppEvent::SessionMutationSettled(_))); + assert!(matches!(settled, AppEvent::SessionMutationSettled { .. })); app.handle(settled).unwrap(); assert!(app.routing_refresh_pending); app.routing_refresh_pending = false; @@ -6725,11 +6777,14 @@ mod tests { let (events, receiver) = std::sync::mpsc::sync_channel(1); events.send(AppEvent::MuxTitlesReady).unwrap(); let pending_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(1)); + let pending_routing_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let cancellation_pending = Arc::new(AtomicBool::new(false)); drop(PendingSessionMutation(Arc::new(PendingSessionMutationState { events, pending_mutations: pending_mutations.clone(), + pending_routing_mutations, + routing: false, cancellation_pending: cancellation_pending.clone(), settled: AtomicBool::new(false), }))); @@ -6743,10 +6798,13 @@ mod tests { fn superseded_mutation_settles_without_canceling_session_input() { let (events, receiver) = std::sync::mpsc::sync_channel(1); let pending_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(1)); + let pending_routing_mutations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let cancellation_pending = Arc::new(AtomicBool::new(false)); let pending = PendingSessionMutation(Arc::new(PendingSessionMutationState { events, pending_mutations: pending_mutations.clone(), + pending_routing_mutations, + routing: false, cancellation_pending: cancellation_pending.clone(), settled: AtomicBool::new(false), })); @@ -6919,14 +6977,12 @@ mod tests { app.session.pending_mutations.store(1, Ordering::Release); app.session.remote_background_dirty.store(true, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree: TreeView::default(), - authoritative_generation: 0, - routing_generation: 0, - refresh_sequence: 1, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: TreeView::default(), + authoritative_generation: 0, + routing_generation: 0, + refresh_sequence: 1, + })) .unwrap(); assert!(!app.session.remote_background_dirty.load(Ordering::Acquire)); @@ -7235,11 +7291,14 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( settled, - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::SurfaceSyncFailed { - surface: 77, - operation: "attach", + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + .. + }, .. - }) + } )); app.handle(settled).unwrap(); assert!(!app.session.can_attach_surface(77)); @@ -7261,11 +7320,14 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( settled, - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::SurfaceSyncFailed { - surface: 88, - operation: "resize", + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 88, + operation: "resize", + .. + }, .. - }) + } )); app.handle(settled).unwrap(); assert!(matches!( @@ -7309,14 +7371,12 @@ mod tests { }); app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::SurfaceSyncFailed { - surface: 77, - operation: "attach", - error: "remote session did not respond".to_string(), - reconnect_required: true, - }, - )) + app.handle(settled(super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + error: "remote session did not respond".to_string(), + reconnect_required: true, + })) .unwrap(); assert!(app.deferred_input.is_empty()); @@ -7345,11 +7405,14 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( settled, - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::SurfaceSyncFailed { - surface: 77, - operation: "attach", + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + .. + }, .. - }) + } )); app.handle(settled).unwrap(); assert_eq!(app.deferred_input.len(), 1); @@ -7417,14 +7480,12 @@ mod tests { result: Ok(newer.clone()), }) .unwrap(); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree: older.clone(), - authoritative_generation: 0, - routing_generation: 0, - refresh_sequence: 1, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: older.clone(), + authoritative_generation: 0, + routing_generation: 0, + refresh_sequence: 1, + })) .unwrap(); assert_eq!(app.tree.workspaces[0].screens[0].panes[0].tabs[0].surface, 22); assert!(app.routing_refresh_pending); @@ -7432,14 +7493,12 @@ mod tests { app.routing_refresh_pending = false; app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree: newer, - authoritative_generation: 0, - routing_generation: 0, - refresh_sequence: 4, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: newer, + authoritative_generation: 0, + routing_generation: 0, + refresh_sequence: 4, + })) .unwrap(); app.handle(AppEvent::RemoteTreeUpdated { refresh_sequence: 3, @@ -7469,14 +7528,12 @@ mod tests { }) .unwrap(); app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree, - authoritative_generation: 4, - routing_generation: 0, - refresh_sequence: 1, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree, + authoritative_generation: 4, + routing_generation: 0, + refresh_sequence: 1, + })) .unwrap(); assert!(app.pending_session_completions.is_empty()); @@ -7864,7 +7921,10 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( settled, - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::MutationTimedOut(_)) + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::MutationTimedOut(_), + .. + } )); app.handle(settled).unwrap(); assert_eq!(app.deferred_input.len(), 1); @@ -7897,9 +7957,10 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( settled, - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::MutationTimedOut( - _ - )) + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::MutationTimedOut(_), + .. + } )); app.handle(settled).unwrap(); assert_eq!(app.deferred_input.len(), 1); @@ -7928,35 +7989,28 @@ mod tests { assert!(app.omnibar.is_none()); app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Success { - tree: None, - })) - .unwrap(); + app.handle(settled(super::SessionMutationOutcome::Success { tree: None })).unwrap(); assert_eq!(app.pending_session_completions.len(), 1); assert!(app.omnibar.is_none()); app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree: tree.clone(), - authoritative_generation: 3, - routing_generation: 0, - refresh_sequence: 2, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: tree.clone(), + authoritative_generation: 3, + routing_generation: 0, + refresh_sequence: 2, + })) .unwrap(); assert_eq!(app.pending_session_completions.len(), 1); assert!(app.omnibar.is_none()); app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree, - authoritative_generation: 4, - routing_generation: 0, - refresh_sequence: 3, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree, + authoritative_generation: 4, + routing_generation: 0, + refresh_sequence: 3, + })) .unwrap(); assert!(app.pending_session_completions.is_empty()); assert_eq!(app.omnibar.as_ref().map(|state| state.surface), Some(surface)); @@ -7975,14 +8029,12 @@ mod tests { }); app.session.pending_mutations.store(1, Ordering::Release); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::IdentityRefreshSucceeded { - tree: browser_completion_tree(created_surface, active_surface), - authoritative_generation: 4, - routing_generation: 0, - refresh_sequence: 1, - }, - )) + app.handle(settled(super::SessionMutationOutcome::IdentityRefreshSucceeded { + tree: browser_completion_tree(created_surface, active_surface), + authoritative_generation: 4, + routing_generation: 0, + refresh_sequence: 1, + })) .unwrap(); assert!(app.pending_session_completions.is_empty()); @@ -8089,12 +8141,14 @@ mod tests { let mut authoritative_snapshots = 0; for _ in 0..2 { match events.recv_timeout(Duration::from_secs(1)).unwrap() { - AppEvent::SessionMutationSettled(super::SessionMutationOutcome::Success { - tree: None, - }) => sample_without_tree += 1, - AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::AuthoritativeMutationSucceeded { .. }, - ) => authoritative_snapshots += 1, + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::Success { tree: None }, + .. + } => sample_without_tree += 1, + AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::AuthoritativeMutationSucceeded { .. }, + .. + } => authoritative_snapshots += 1, _ => panic!("unexpected split ratio settlement"), } } @@ -8109,7 +8163,7 @@ mod tests { let (mut app, events) = test_app_with_events(Session::Local(mux)); let (started_tx, started_rx) = std::sync::mpsc::channel(); let (release_tx, release_rx) = std::sync::mpsc::channel(); - app.session.enqueue("blocking mutation", move |_| { + app.session.enqueue_routing("blocking mutation", move |_| { started_tx.send(()).unwrap(); release_rx.recv().unwrap(); Ok(()) @@ -8134,6 +8188,35 @@ mod tests { app.handle(settled).unwrap(); } + #[test] + fn pointer_input_continues_during_non_routing_background_mutation() { + let mux = Mux::new("background-pointer-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session.enqueue("blocking background sync", move |_| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + app.handle(AppEvent::Input(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 9, + row: 3, + modifiers: KeyModifiers::NONE, + }))) + .unwrap(); + + assert_ne!( + app.status_message.as_deref(), + Some("Pointer input was discarded while the layout changed") + ); + release_tx.send(()).unwrap(); + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + #[test] fn browser_drag_release_bypasses_a_pending_focus_mutation() { let mux = Mux::new("browser-release-barrier-test", SurfaceOptions::default()); @@ -8256,12 +8339,10 @@ mod tests { sidebar_focus_intent: false, }); - app.handle(AppEvent::SessionMutationSettled( - super::SessionMutationOutcome::CommittedTreeStale { - error: Some("refresh unavailable".to_string()), - completion: None, - }, - )) + app.handle(settled(super::SessionMutationOutcome::CommittedTreeStale { + error: Some("refresh unavailable".to_string()), + completion: None, + })) .unwrap(); assert_eq!(app.deferred_input.len(), 1); diff --git a/cmux-tui/docs/concepts.md b/cmux-tui/docs/concepts.md index 0ab1e835e155..a9583fa354c3 100644 --- a/cmux-tui/docs/concepts.md +++ b/cmux-tui/docs/concepts.md @@ -46,4 +46,4 @@ Closing a pane closes all tabs in that pane. Closing a screen closes every pane A PTY surface parses child-process output with libghostty-vt. Frontends render snapshots of that terminal state. Attach clients receive a VT replay first, then a base64 stream of subsequent PTY bytes, plus ordered resize frames when the surface geometry changes. -A browser surface is a local Chrome/Chromium target controlled through the Chrome DevTools Protocol. The local TUI draws browser frames with kitty graphics and forwards keyboard, mouse, and wheel input over CDP. Browser surfaces are listed in the tree, but `attach-surface` does not stream browser pixels as of protocol v7. +A browser surface is a local Chrome/Chromium target controlled through the Chrome DevTools Protocol. The local TUI draws browser frames with kitty graphics and forwards keyboard, mouse, and wheel input over CDP. Protocol-v7 attach clients receive an initial `browser-state` event with the latest optional frame, followed by updated `browser-state` and base64 PNG `frame` events. diff --git a/cmux-tui/spec/frontends.md b/cmux-tui/spec/frontends.md index 1124433cfb16..a740790a3699 100644 --- a/cmux-tui/spec/frontends.md +++ b/cmux-tui/spec/frontends.md @@ -18,11 +18,11 @@ Only after that preamble should the client send protocol requests. See [`transpo ## 2. Identify the Server -Send [`identify`](commands.md#identify) immediately after connecting. Verify `data.app == "cmux-tui"` and `data.protocol == 6` before enabling protocol-v6 behavior. Preserve request `id` values and route every non-event response back to the pending request with that id. +Send [`identify`](commands.md#identify) immediately after connecting. Verify `data.app == "cmux-tui"` and `data.protocol == 7` before enabling protocol-v7 behavior. Preserve request `id` values and route every non-event response back to the pending request with that id. ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","protocol":6,"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","protocol":7,"session":"main","pid":12345}} ``` ## 3. Load and Track the Workspace Tree @@ -42,7 +42,7 @@ Treat `tree-changed` as an instruction to call `list-workspaces`, `layout-change For a PTY tab, send [`attach-surface`](commands.md#attach-surface) with its numeric surface id. The stream begins with a `vt-state` event containing `cols`, `rows`, and a standard-base64 `data` field. Decode `data` to bytes and feed the VT replay into a fresh terminal emulator, such as xterm.js. -After the replay, apply each base64-decoded `output.data` byte chunk in arrival order. On `resized`, resize the emulator, discard its old parser state, and replace it from the event's fresh base64 replay before applying later output. `scroll-changed` updates viewport state. `detached` ends the surface stream. Protocol v6 guarantees this order: +After the replay, apply each base64-decoded `output.data` byte chunk in arrival order. On `resized`, resize the emulator, discard its old parser state, and replace it from the event's fresh base64 replay before applying later output. `scroll-changed` updates viewport state. `detached` ends the surface stream. Protocol v7 guarantees this order: ```text vt-state -> (resized | output | scroll-changed)* -> detached @@ -56,7 +56,7 @@ Browser surfaces use the browser attach events documented in [`events.md`](event The workspace tree carries per-surface notification state for initial rendering. A subscribed frontend also receives `notification` events with title, body, level, and optional surface. Show the notification and mark the referenced surface as needing attention until the user views it; then use the relevant selection/read path described in [`commands.md`](commands.md). -Call [`list-agents`](commands.md#list-agents) to read current agent records, optionally filtered by surface or state. Agent producers report state through [`report-agent`](commands.md#report-agent); a presentation-only frontend normally reads and displays these records rather than inventing its own agent state. There is no dedicated agent-change event in protocol v6, so re-fetch after a frontend reports state and when tree or surface lifecycle events make the presentation stale. +Call [`list-agents`](commands.md#list-agents) to read current agent records, optionally filtered by surface or state. Agent producers report state through [`report-agent`](commands.md#report-agent); a presentation-only frontend normally reads and displays these records rather than inventing its own agent state. There is no dedicated agent-change event in protocol v7, so re-fetch after a frontend reports state and when tree or surface lifecycle events make the presentation stale. ## End-to-End WebSocket Transcript @@ -65,7 +65,7 @@ Each line below is one WebSocket text frame. `C>` is client-to-server and `S>` i ```text C> {"auth":{"token":"secret"}} C> {"id":1,"cmd":"identify"} -S> {"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","protocol":6,"session":"main","pid":12345}} +S> {"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","protocol":7,"session":"main","pid":12345}} C> {"id":2,"cmd":"subscribe"} S> {"id":2,"ok":true,"data":{}} C> {"id":3,"cmd":"list-workspaces"} From 7bf4e326a03a80847800365417dd7bcde46a24f3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:05:21 -0700 Subject: [PATCH 073/103] Wake TUI streams and cap resize recovery --- cmux-tui/crates/cmux-tui-core/src/server.rs | 145 ++++++++++++++------ cmux-tui/crates/cmux-tui/src/app.rs | 13 +- 2 files changed, 108 insertions(+), 50 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 04be95704e86..0d31e8aed876 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -33,6 +33,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tungstenite::protocol::CloseFrame; +use tungstenite::protocol::Role; use tungstenite::protocol::frame::coding::CloseCode; use tungstenite::{Error as WebSocketError, Message, accept}; @@ -417,6 +418,7 @@ struct Response { const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_SERVER_CONNECTIONS: usize = 256; const OUTBOUND_CAPACITY: usize = 256; const OUTBOUND_CONTROL_RESERVE: usize = 256; const OUTBOUND_BYTE_CAPACITY: usize = 16 * 1024 * 1024; @@ -558,6 +560,23 @@ struct RegularOutbound { stream: OutboundStream, } +struct ConnectionPermit(Arc); + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +fn claim_connection(active: &Arc) -> Option { + active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + (count < MAX_SERVER_CONNECTIONS as u64).then_some(count + 1) + }) + .ok() + .map(|_| ConnectionPermit(active.clone())) +} + impl BoundedOutbound { fn push_regular(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { self.push_regular_with_priority(text, stream, false) @@ -715,12 +734,12 @@ impl BoundedOutbound { Ok(()) } + #[cfg(test)] fn try_pop(&self) -> Option { let mut state = self.state.lock().unwrap(); Self::pop_locked(&mut state) } - #[cfg(test)] fn recv(&self) -> Option { let mut state = self.state.lock().unwrap(); loop { @@ -811,14 +830,17 @@ pub fn serve(mux: Arc, path: Option) -> anyhow::Result { } let listener = transport::listen(&path)?; platform::restrict_file(&path)?; + let active_connections = Arc::new(AtomicU64::new(0)); std::thread::Builder::new().name("mux-server".into()).spawn(move || { loop { let Ok(stream) = listener.accept() else { continue }; + let Some(permit) = claim_connection(&active_connections) else { continue }; let mux = mux.clone(); - let _ = std::thread::Builder::new() - .name("mux-conn".into()) - .spawn(move || handle_connection(mux, stream)); + let _ = std::thread::Builder::new().name("mux-conn".into()).spawn(move || { + let _permit = permit; + handle_connection(mux, stream); + }); } })?; Ok(path) @@ -868,6 +890,7 @@ pub fn serve_websocket( let shutdown = Arc::new(AtomicBool::new(false)); let connections = Arc::new(Mutex::new(HashMap::new())); let next_connection = Arc::new(AtomicU64::new(1)); + let active_connections = Arc::new(AtomicU64::new(0)); let thread_shutdown = shutdown.clone(); let thread_connections = connections.clone(); let thread = std::thread::Builder::new().name("mux-ws-server".into()).spawn(move || { @@ -887,6 +910,7 @@ pub fn serve_websocket( if thread_shutdown.load(Ordering::Acquire) { break; } + let Some(permit) = claim_connection(&active_connections) else { continue }; let id = next_connection.fetch_add(1, Ordering::Relaxed); if let Ok(tracked) = stream.try_clone() { thread_connections.lock().unwrap().insert(id, tracked); @@ -894,10 +918,18 @@ pub fn serve_websocket( let mux = mux.clone(); let token = token.clone(); let connections = thread_connections.clone(); - let _ = std::thread::Builder::new().name("mux-ws-conn".into()).spawn(move || { - handle_websocket_connection(mux, stream, token.as_deref()); - connections.lock().unwrap().remove(&id); - }); + let cleanup_connections = thread_connections.clone(); + if std::thread::Builder::new() + .name("mux-ws-conn".into()) + .spawn(move || { + let _permit = permit; + handle_websocket_connection(mux, stream, token.as_deref()); + connections.lock().unwrap().remove(&id); + }) + .is_err() + { + cleanup_connections.lock().unwrap().remove(&id); + } } })?; Ok(WebSocketServer { local_addr, shutdown, connections, thread: Some(thread) }) @@ -919,47 +951,40 @@ fn sanitize_window_title(title: &str) -> String { } fn handle_connection(mux: Arc, stream: Box) { - if stream.set_read_timeout(Some(STREAM_DISCONNECT_POLL)).is_err() - || stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)).is_err() - { + let Ok(mut write_half) = stream.try_clone_box() else { return }; + if write_half.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)).is_err() { return; } let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); - let mut reader = BufReader::new(stream); - let mut pending = Vec::new(); - 'connection: loop { - while let Some(text) = outbound.try_pop() { - let stream = reader.get_mut(); - if stream.write_all(text.as_bytes()).is_err() || stream.write_all(b"\n").is_err() { - break 'connection; - } - } - if !writer.is_open() { - break; - } - - match reader.read_until(b'\n', &mut pending) { - Ok(0) => break, - Ok(_) => { - if !pending.ends_with(b"\n") { - continue; - } - let Ok(line) = std::str::from_utf8(&pending) else { break }; - if !line.trim().is_empty() && !handle_message(&mux, line, &writer) { + let writer_outbound = outbound; + let Ok(writer_thread) = + std::thread::Builder::new().name("mux-line-out".into()).spawn(move || { + while let Some(text) = writer_outbound.recv() { + if write_half.write_all(text.as_bytes()).is_err() + || write_half.write_all(b"\n").is_err() + { + writer_outbound.close(); break; } - pending.clear(); } - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => {} - Err(_) => break, + }) + else { + writer.close(); + return; + }; + let reader = BufReader::new(stream); + for line in reader.lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + if !handle_message(&mux, &line, &writer) { + break; } } writer.close(); + let _ = writer_thread.join(); } fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<&str>) { @@ -980,15 +1005,31 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); let _ = websocket.get_mut().set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); + let Ok(writer_stream) = websocket.get_ref().try_clone() else { return }; + let _ = writer_stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone() }); - - 'connection: loop { - while let Some(text) = outbound.try_pop() { - if websocket.send(Message::Text(text.into())).is_err() { - break 'connection; + let writer_outbound = outbound; + let write_lock = Arc::new(Mutex::new(())); + let writer_lock = write_lock.clone(); + let Ok(writer_thread) = + std::thread::Builder::new().name("mux-ws-out".into()).spawn(move || { + let mut websocket = + tungstenite::WebSocket::from_raw_socket(writer_stream, Role::Server, None); + while let Some(text) = writer_outbound.recv() { + let _guard = writer_lock.lock().unwrap(); + if websocket.send(Message::Text(text.into())).is_err() { + writer_outbound.close(); + break; + } } - } + }) + else { + writer.close(); + return; + }; + + loop { if !writer.is_open() { break; } @@ -1000,6 +1041,7 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => { + let _guard = write_lock.lock().unwrap(); let _ = websocket.flush(); } Ok(Message::Close(_)) => break, @@ -1013,6 +1055,8 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } writer.close(); + let _ = writer_thread.join(); + let _guard = write_lock.lock().unwrap(); let _ = websocket.close(None); } @@ -2244,6 +2288,17 @@ mod tests { assert!(writer.is_open()); } + #[test] + fn server_connection_permits_enforce_and_release_the_cap() { + let active = Arc::new(AtomicU64::new(MAX_SERVER_CONNECTIONS as u64)); + assert!(claim_connection(&active).is_none()); + active.store(MAX_SERVER_CONNECTIONS as u64 - 1, Ordering::Release); + let permit = claim_connection(&active).expect("last connection slot"); + assert_eq!(active.load(Ordering::Acquire), MAX_SERVER_CONNECTIONS as u64); + drop(permit); + assert_eq!(active.load(Ordering::Acquire), MAX_SERVER_CONNECTIONS as u64 - 1); + } + #[test] fn global_pressure_terminates_the_stream_occupying_the_backlog() { let outbound = Arc::new(BoundedOutbound::default()); diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 6dad6e3292d8..a728ba66bbc2 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -469,8 +469,9 @@ fn next_surface_sync_failure( let delay_seconds = 1_u64 << u32::from(attempts.saturating_sub(1)); SurfaceSyncFailureState { attempts, - retry_after: Some(Instant::now() + Duration::from_secs(delay_seconds.min(30))), - sticky_until_reconnect, + retry_after: (attempts < 6) + .then(|| Instant::now() + Duration::from_secs(delay_seconds.min(30))), + sticky_until_reconnect: sticky_until_reconnect || attempts >= 6, } } @@ -1053,7 +1054,7 @@ impl OrderedSession { surface: surface_id, operation: "resize", error: error.to_string(), - reconnect_required: false, + reconnect_required: state.sticky_until_reconnect, }); if transient { Err(error) } else { Ok(()) } } @@ -7443,7 +7444,7 @@ mod tests { } #[test] - fn transient_surface_sync_failures_retry_with_bounded_backoff() { + fn transient_surface_sync_failures_stop_after_bounded_backoff() { let first = super::next_surface_sync_failure(None, true, false); assert_eq!(first.attempts, 1); assert!(super::surface_sync_failure_blocks(first)); @@ -7457,7 +7458,9 @@ mod tests { let capped = (0..10) .fold(elapsed, |state, _| super::next_surface_sync_failure(Some(state), true, false)); assert_eq!(capped.attempts, 6); - assert!(capped.retry_after.unwrap() <= Instant::now() + Duration::from_secs(30)); + assert!(capped.retry_after.is_none()); + assert!(capped.sticky_until_reconnect); + assert!(super::surface_sync_failure_blocks(capped)); } #[test] From c266bb7d941df43463b4a3dc5e4870fb07ec4180 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:12:54 -0700 Subject: [PATCH 074/103] Time out stalled WebSocket clients --- cmux-tui/crates/cmux-tui-core/src/server.rs | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 0d31e8aed876..624486b93787 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -418,6 +418,10 @@ struct Response { const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(not(test))] +const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(test)] +const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(100); const MAX_SERVER_CONNECTIONS: usize = 256; const OUTBOUND_CAPACITY: usize = 256; const OUTBOUND_CONTROL_RESERVE: usize = 256; @@ -988,6 +992,11 @@ fn handle_connection(mux: Arc, stream: Box) { } fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<&str>) { + if stream.set_read_timeout(Some(WEBSOCKET_HANDSHAKE_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(WEBSOCKET_HANDSHAKE_TIMEOUT)).is_err() + { + return; + } let Ok(mut websocket) = accept(stream) else { return }; if let Some(expected) = token { @@ -2299,6 +2308,43 @@ mod tests { assert_eq!(active.load(Ordering::Acquire), MAX_SERVER_CONNECTIONS as u64 - 1); } + #[test] + fn stalled_websocket_handshake_times_out() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + let (done, finished) = std::sync::mpsc::channel(); + let handler = std::thread::spawn(move || { + handle_websocket_connection(test_mux(), server, None); + done.send(()).unwrap(); + }); + + finished + .recv_timeout(Duration::from_secs(1)) + .expect("stalled handshake must not occupy a connection slot indefinitely"); + drop(client); + handler.join().unwrap(); + } + + #[test] + fn stalled_websocket_authentication_times_out() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + let (done, finished) = std::sync::mpsc::channel(); + let handler = std::thread::spawn(move || { + handle_websocket_connection(test_mux(), server, Some("secret")); + done.send(()).unwrap(); + }); + let (client, _) = tungstenite::client("ws://localhost/", client_stream).unwrap(); + + finished + .recv_timeout(Duration::from_secs(1)) + .expect("stalled authentication must not occupy a connection slot indefinitely"); + drop(client); + handler.join().unwrap(); + } + #[test] fn global_pressure_terminates_the_stream_occupying_the_backlog() { let outbound = Arc::new(BoundedOutbound::default()); From d17250e7f997fa869708b4023acf36f1ee4ffd39 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:20:07 -0700 Subject: [PATCH 075/103] Complete browser resize recovery --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 85 ++++++++++++++----- cmux-tui/crates/cmux-tui-core/src/platform.rs | 9 ++ cmux-tui/crates/cmux-tui-core/src/server.rs | 25 ++++++ cmux-tui/crates/cmux-tui-core/src/surface.rs | 6 +- 4 files changed, 99 insertions(+), 26 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 2eead6f9223e..67a879230926 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -107,6 +107,7 @@ struct BrowserState { pane_pixels: (u32, u32), capture_pixels: (u32, u32), capture_scale: f64, + pending_reconfigure: Option<(u32, u32)>, page_viewport: Option<(u32, u32)>, status: BrowserStatus, source: Option, @@ -389,6 +390,7 @@ pub(crate) fn new_surface( pane_pixels: (pixel_w, pixel_h), capture_pixels, capture_scale, + pending_reconfigure: None, page_viewport: None, status: BrowserStatus::Starting, source: None, @@ -754,6 +756,10 @@ fn run_browser_worker_command( failures: &mut BrowserWorkerErrorState, ) { let is_input = command.is_input(); + let reconfigure = match &command { + BrowserCommand::Reconfigure { width, height } => Some((*width, *height)), + _ => None, + }; let result = { let Some(browser) = surface.as_browser() else { return; @@ -790,6 +796,12 @@ fn run_browser_worker_command( } } }; + if result.is_ok() + && let Some((width, height)) = reconfigure + && let Some(browser) = surface.as_browser() + { + browser.confirm_reconfigure(width, height); + } record_browser_worker_result(surface, mux, id, is_input, result, failures); } @@ -935,6 +947,16 @@ impl BrowserSurface { } } + pub(crate) fn resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { + let before = self.size(); + let Some((width, height)) = self.update_resize_state(cols, rows) else { + return Ok(false); + }; + self.reconfigure_blocking(width, height)?; + self.confirm_reconfigure(width, height); + Ok(self.size() != before) + } + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { { let mut cell = self.cell_pixels.lock().unwrap(); @@ -953,28 +975,33 @@ impl BrowserSurface { let cell = *self.cell_pixels.lock().unwrap(); let pixel_w = cols as u32 * cell.0.max(1) as u32; let pixel_h = rows as u32 * cell.1.max(1) as u32; - let (unchanged, capture_w, capture_h) = { - let mut state = self.state.lock().unwrap(); - let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); - let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); - let unchanged = state.size == (cols, rows) - && state.pane_pixels == (pixel_w, pixel_h) - && state.capture_pixels == capture_pixels; - state.size = (cols, rows); - state.pane_pixels = (pixel_w, pixel_h); - state.capture_pixels = capture_pixels; - state.capture_scale = capture_scale; - if !unchanged { - state.live_since = Some(Instant::now()); - state.last_frame_at = None; - state.stall_nudged = false; - } - (unchanged, capture_pixels.0, capture_pixels.1) - }; + let mut state = self.state.lock().unwrap(); + let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); + let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); + let unchanged = state.size == (cols, rows) + && state.pane_pixels == (pixel_w, pixel_h) + && state.capture_pixels == capture_pixels; + state.size = (cols, rows); + state.pane_pixels = (pixel_w, pixel_h); + state.capture_pixels = capture_pixels; + state.capture_scale = capture_scale; + if !unchanged { + state.live_since = Some(Instant::now()); + state.last_frame_at = None; + state.stall_nudged = false; + } if unchanged { - return None; + return state.pending_reconfigure; + } + state.pending_reconfigure = Some(capture_pixels); + Some(capture_pixels) + } + + fn confirm_reconfigure(&self, width: u32, height: u32) { + let mut state = self.state.lock().unwrap(); + if state.pending_reconfigure == Some((width, height)) { + state.pending_reconfigure = None; } - Some((capture_w, capture_h)) } fn reconfigure_blocking(&self, width: u32, height: u32) -> anyhow::Result<()> { @@ -993,7 +1020,8 @@ impl BrowserSurface { let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); let state = self.state.lock().unwrap(); - state.size != (cols, rows) + state.pending_reconfigure.is_some() + || state.size != (cols, rows) || state.pane_pixels != (pixel_w, pixel_h) || state.capture_pixels != capture_pixels } @@ -2341,6 +2369,21 @@ mod tests { assert!(browser.resize_needed(10, 5)); } + #[test] + fn browser_resize_stays_retryable_until_reconfigure_completes() { + let opts = SurfaceOptions::default(); + let surface = + new_surface(1, "https://example.test".into(), (10, 5), (8, 16), &opts, Weak::new()); + let browser = surface.as_browser().expect("browser surface"); + *browser.cell_pixels.lock().unwrap() = (9, 16); + + let (width, height) = browser.update_resize_state(10, 5).expect("changed geometry"); + assert!(browser.resize_needed(10, 5)); + + browser.confirm_reconfigure(width, height); + assert!(!browser.resize_needed(10, 5)); + } + #[test] fn attach_frames_are_latest_wins_and_close_detaches() { let surface = test_surface(); diff --git a/cmux-tui/crates/cmux-tui-core/src/platform.rs b/cmux-tui/crates/cmux-tui-core/src/platform.rs index 629b3a704a48..f22ae7441af4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/platform.rs +++ b/cmux-tui/crates/cmux-tui-core/src/platform.rs @@ -11,6 +11,7 @@ pub mod transport { fn try_clone_box(&self) -> io::Result>; fn set_read_timeout(&self, timeout: Option) -> io::Result<()>; fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn shutdown(&self) -> io::Result<()>; } pub struct Listener { @@ -71,6 +72,10 @@ pub mod transport { fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_write_timeout(self, timeout) } + + fn shutdown(&self) -> io::Result<()> { + UnixStream::shutdown(self, std::net::Shutdown::Both) + } } } @@ -114,6 +119,10 @@ pub mod transport { fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { UnixStream::set_write_timeout(self, timeout) } + + fn shutdown(&self) -> io::Result<()> { + UnixStream::shutdown(self, std::net::Shutdown::Both) + } } } } diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 624486b93787..c6d07d3903bd 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -969,6 +969,7 @@ fn handle_connection(mux: Arc, stream: Box) { || write_half.write_all(b"\n").is_err() { writer_outbound.close(); + let _ = write_half.shutdown(); break; } } @@ -2308,6 +2309,30 @@ mod tests { assert_eq!(active.load(Ordering::Acquire), MAX_SERVER_CONNECTIONS as u64 - 1); } + #[test] + fn shutting_down_a_writer_clone_unblocks_the_reader() { + let path = std::env::temp_dir().join(format!( + "cmux-tui-shutdown-{}-{}.sock", + std::process::id(), + Instant::now().elapsed().as_nanos() + )); + let _ = std::fs::remove_file(&path); + let listener = transport::listen(&path).unwrap(); + let _client = transport::connect(&path).unwrap(); + let mut reader = listener.accept().unwrap(); + let writer = reader.try_clone_box().unwrap(); + let (done, finished) = std::sync::mpsc::channel(); + let read_thread = std::thread::spawn(move || { + let mut byte = [0_u8; 1]; + done.send(reader.read(&mut byte)).unwrap(); + }); + + writer.shutdown().unwrap(); + assert_eq!(finished.recv_timeout(Duration::from_secs(1)).unwrap().unwrap(), 0); + read_thread.join().unwrap(); + let _ = std::fs::remove_file(path); + } + #[test] fn stalled_websocket_handshake_times_out() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 52d5767e0680..953ffb67d3c0 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -643,11 +643,7 @@ impl Surface { pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { match self { Surface::Pty(pty) => Ok(pty.resize(cols, rows)), - Surface::Browser(browser) => { - let before = browser.size(); - browser.resize(cols, rows); - Ok(browser.size() != before) - } + Surface::Browser(browser) => browser.resize_blocking(cols, rows), } } From 785345a574343cb1eafd6eac8fc2eab26b85aa09 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:29:57 -0700 Subject: [PATCH 076/103] Order browser resize completion --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 69 +++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 67a879230926..aabe6c91f9ba 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -147,8 +147,9 @@ enum BrowserCommand { Reload, Activate, Reconfigure { - width: u32, - height: u32, + cols: u16, + rows: u16, + completion: Option>>, }, } @@ -750,14 +751,14 @@ fn coalesce_worker_mouse_moves(batch: &mut Vec) { fn run_browser_worker_command( surface: &Surface, - command: BrowserCommand, + mut command: BrowserCommand, mux: &Weak, id: SurfaceId, failures: &mut BrowserWorkerErrorState, ) { let is_input = command.is_input(); - let reconfigure = match &command { - BrowserCommand::Reconfigure { width, height } => Some((*width, *height)), + let completion = match &mut command { + BrowserCommand::Reconfigure { completion, .. } => completion.take(), _ => None, }; let result = { @@ -791,16 +792,14 @@ fn run_browser_worker_command( BrowserCommand::Forward => browser.forward_blocking(), BrowserCommand::Reload => browser.reload_blocking(), BrowserCommand::Activate => browser.activate_blocking(), - BrowserCommand::Reconfigure { width, height } => { - browser.reconfigure_blocking(width, height) + BrowserCommand::Reconfigure { cols, rows, completion: _ } => { + browser.reconfigure_resize_blocking(cols, rows) } } }; - if result.is_ok() - && let Some((width, height)) = reconfigure - && let Some(browser) = surface.as_browser() - { - browser.confirm_reconfigure(width, height); + if let Some(completion) = completion { + let outcome = result.as_ref().map(|_| ()).map_err(ToString::to_string); + let _ = completion.send(outcome); } record_browser_worker_result(surface, mux, id, is_input, result, failures); } @@ -937,24 +936,41 @@ impl BrowserSurface { } pub fn resize(&self, cols: u16, rows: u16) { - let Some((width, height)) = self.update_resize_state(cols, rows) else { + let (cols, rows) = (cols.max(1), rows.max(1)); + if !self.resize_needed(cols, rows) { return; - }; - if let Err(e) = - self.enqueue_latest_reconfigure(BrowserCommand::Reconfigure { width, height }) - { + } + if let Err(e) = self.enqueue_latest_reconfigure(BrowserCommand::Reconfigure { + cols, + rows, + completion: None, + }) { eprintln!("cmux-tui: browser resize failed for surface {}: {e}", self.meta.id); } } pub(crate) fn resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { let before = self.size(); + let (completion, finished) = sync_channel(1); + self.enqueue_control(BrowserCommand::Reconfigure { + cols: cols.max(1), + rows: rows.max(1), + completion: Some(completion), + })?; + finished + .recv() + .map_err(|_| anyhow::anyhow!("browser command worker closed"))? + .map_err(anyhow::Error::msg)?; + Ok(self.size() != before) + } + + fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result<()> { let Some((width, height)) = self.update_resize_state(cols, rows) else { - return Ok(false); + return Ok(()); }; self.reconfigure_blocking(width, height)?; self.confirm_reconfigure(width, height); - Ok(self.size() != before) + Ok(()) } pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { @@ -2032,16 +2048,19 @@ mod tests { #[test] fn latest_reconfigure_and_nav_slots_do_not_clobber_each_other() { - let latest_reconfigure = - Arc::new(Mutex::new(Some(BrowserCommand::Reconfigure { width: 111, height: 222 }))); + let latest_reconfigure = Arc::new(Mutex::new(Some(BrowserCommand::Reconfigure { + cols: 111, + rows: 222, + completion: None, + }))); let latest_nav = Arc::new(Mutex::new(Some(BrowserCommand::Navigate("https://next.test".to_string())))); let commands = take_latest_worker_commands(&latest_reconfigure, &latest_nav); assert_eq!(commands.len(), 2); match &commands[0] { - BrowserCommand::Reconfigure { width, height } => { - assert_eq!((*width, *height), (111, 222)); + BrowserCommand::Reconfigure { cols, rows, completion: None } => { + assert_eq!((*cols, *rows), (111, 222)); } _ => panic!("reconfigure must drain before nav"), } @@ -2342,7 +2361,7 @@ mod tests { } assert!(browser.frames_stalled_at(now)); - browser.resize(10, 5); + browser.resize_blocking(10, 5).unwrap(); { let state = browser.state.lock().unwrap(); assert_eq!(state.last_frame_at, Some(now - Duration::from_secs(3))); @@ -2350,7 +2369,7 @@ mod tests { } assert!(browser.frames_stalled_at(now)); - browser.resize(11, 5); + browser.resize_blocking(11, 5).unwrap(); let state = browser.state.lock().unwrap(); assert_eq!(state.last_frame_at, None); assert!(!state.stall_nudged); From 7c4b4d506e54238e172ba6041586102ea4e1b690 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:36:04 -0700 Subject: [PATCH 077/103] Publish successful browser resize retries --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index aabe6c91f9ba..57a0094cebf8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -950,7 +950,7 @@ impl BrowserSurface { } pub(crate) fn resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { - let before = self.size(); + let needed = self.resize_needed(cols, rows); let (completion, finished) = sync_channel(1); self.enqueue_control(BrowserCommand::Reconfigure { cols: cols.max(1), @@ -961,7 +961,7 @@ impl BrowserSurface { .recv() .map_err(|_| anyhow::anyhow!("browser command worker closed"))? .map_err(anyhow::Error::msg)?; - Ok(self.size() != before) + Ok(needed) } fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result<()> { @@ -2396,10 +2396,10 @@ mod tests { let browser = surface.as_browser().expect("browser surface"); *browser.cell_pixels.lock().unwrap() = (9, 16); - let (width, height) = browser.update_resize_state(10, 5).expect("changed geometry"); + let _ = browser.update_resize_state(10, 5).expect("changed geometry"); assert!(browser.resize_needed(10, 5)); - browser.confirm_reconfigure(width, height); + assert!(browser.resize_blocking(10, 5).unwrap()); assert!(!browser.resize_needed(10, 5)); } From 3fe196a57329e156792763a6d9a8ced8bc819477 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:44:44 -0700 Subject: [PATCH 078/103] Update web frontend for protocol 7 --- cmux-tui/frontends/web/README.ja.md | 2 +- cmux-tui/frontends/web/README.md | 2 +- cmux-tui/frontends/web/src/hooks/useCmuxClient.ts | 3 ++- cmux-tui/frontends/web/src/i18n.ts | 4 ++-- cmux-tui/frontends/web/src/lib/protocol.ts | 5 +++++ cmux-tui/frontends/web/test/protocol.test.ts | 11 +++++++++++ 6 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 cmux-tui/frontends/web/src/lib/protocol.ts create mode 100644 cmux-tui/frontends/web/test/protocol.test.ts diff --git a/cmux-tui/frontends/web/README.ja.md b/cmux-tui/frontends/web/README.ja.md index 3203d509cc74..c4bf2d1edcf6 100644 --- a/cmux-tui/frontends/web/README.ja.md +++ b/cmux-tui/frontends/web/README.ja.md @@ -2,7 +2,7 @@ [English](README.md) -プロトコルv6のWebSocket APIとTypeScript SDKのブラウザ向けエントリだけで、 +プロトコルv7のWebSocket APIとTypeScript SDKのブラウザ向けエントリだけで、 自然なcmuxクライアントを構築できることを示す小規模なサードパーティー形式の フロントエンドです。信頼できるワークスペースツリーの表示、アクティブなPTY サーフェスへのxterm.jsの接続、キーボード入力の転送、ターミナルセル単位の diff --git a/cmux-tui/frontends/web/README.md b/cmux-tui/frontends/web/README.md index ed69b6e9e5ba..9e58ec36e464 100644 --- a/cmux-tui/frontends/web/README.md +++ b/cmux-tui/frontends/web/README.md @@ -2,7 +2,7 @@ [日本語](README.ja.md) -A small third-party-style frontend that proves the protocol-v6 WebSocket API +A small third-party-style frontend that proves the protocol-v7 WebSocket API and the browser entry of the TypeScript SDK are enough to build a natural cmux client. It renders the authoritative workspace tree, attaches xterm.js to the active PTY surface, forwards keyboard input, resizes from terminal cells, and diff --git a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts index b891c3ae1773..89a8cdaf6dc3 100644 --- a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts +++ b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts @@ -9,6 +9,7 @@ import { type Tree, } from "cmux/browser"; import { reconnectTransition, type ReconnectState } from "../lib/reconnect"; +import { supportsProtocol } from "../lib/protocol"; import { activeScreen, treeToViewModel } from "../lib/tree"; import { t } from "../i18n"; @@ -87,7 +88,7 @@ export function useCmuxClient() { try { const info = await client.identify(); if (info.app !== "cmux-tui") throw new Error(t("wrongApp", { app: info.app })); - if (info.protocol !== 6) throw new Error(t("wrongProtocol", { protocol: info.protocol })); + if (!supportsProtocol(info.protocol)) throw new Error(t("wrongProtocol", { protocol: info.protocol })); const events = await client.subscribe(); const tree = await client.listWorkspaces(); if (cancelled) return; diff --git a/cmux-tui/frontends/web/src/i18n.ts b/cmux-tui/frontends/web/src/i18n.ts index 53605e5b6940..114beb3b6462 100644 --- a/cmux-tui/frontends/web/src/i18n.ts +++ b/cmux-tui/frontends/web/src/i18n.ts @@ -23,7 +23,7 @@ const messages = { tab: "Tab {number}", unknownError: "Unable to connect.", wrongApp: "Expected a cmux-tui server, but received {app}.", - wrongProtocol: "Protocol 6 is required; the server reported protocol {protocol}.", + wrongProtocol: "Protocol 7 is required; the server reported protocol {protocol}.", openWorkspaces: "Open workspaces", closeWorkspaces: "Close workspaces", extraKeys: "Terminal extra keys", @@ -60,7 +60,7 @@ const messages = { tab: "タブ {number}", unknownError: "接続できませんでした。", wrongApp: "cmux-tui サーバーが必要ですが、{app} を受信しました。", - wrongProtocol: "プロトコル6が必要ですが、サーバーはプロトコル{protocol}を返しました。", + wrongProtocol: "プロトコル7が必要ですが、サーバーはプロトコル{protocol}を返しました。", openWorkspaces: "ワークスペースを開く", closeWorkspaces: "ワークスペースを閉じる", extraKeys: "ターミナル追加キー", diff --git a/cmux-tui/frontends/web/src/lib/protocol.ts b/cmux-tui/frontends/web/src/lib/protocol.ts new file mode 100644 index 000000000000..cfc025bf6275 --- /dev/null +++ b/cmux-tui/frontends/web/src/lib/protocol.ts @@ -0,0 +1,5 @@ +export const SUPPORTED_PROTOCOL = 7; + +export function supportsProtocol(protocol: number): boolean { + return protocol === SUPPORTED_PROTOCOL; +} diff --git a/cmux-tui/frontends/web/test/protocol.test.ts b/cmux-tui/frontends/web/test/protocol.test.ts new file mode 100644 index 000000000000..5170df4b1e65 --- /dev/null +++ b/cmux-tui/frontends/web/test/protocol.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { SUPPORTED_PROTOCOL, supportsProtocol } from "../src/lib/protocol"; + +describe("protocol compatibility", () => { + it("accepts protocol 7 and rejects incompatible versions", () => { + expect(SUPPORTED_PROTOCOL).toBe(7); + expect(supportsProtocol(7)).toBe(true); + expect(supportsProtocol(6)).toBe(false); + expect(supportsProtocol(8)).toBe(false); + }); +}); From bc5b89f6655cb53ab92d5c210b88dd96793dfdb3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:55:12 -0700 Subject: [PATCH 079/103] Bound ordered TUI transport waits --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 78 +++++++++++++++++-- cmux-tui/crates/cmux-tui-core/src/lib.rs | 2 +- cmux-tui/crates/cmux-tui/src/app.rs | 10 ++- .../crates/cmux-tui/src/session/remote.rs | 32 +++++++- 4 files changed, 108 insertions(+), 14 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 57a0094cebf8..b1074ae8d005 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -151,6 +151,11 @@ enum BrowserCommand { rows: u16, completion: Option>>, }, + #[cfg(test)] + Hold { + entered: Sender<()>, + release: Receiver<()>, + }, } impl BrowserCommand { @@ -217,6 +222,21 @@ const DEFAULT_CAPTURE_MEGAPIXELS: f64 = TRANSPORT_SAFE_CAPTURE_MEGAPIXELS; const STALL_THRESHOLD: Duration = Duration::from_secs(2); const BROWSER_COMMAND_QUEUE_CAPACITY: usize = 64; const BROWSER_NOT_RESPONDING_MESSAGE: &str = "browser is not responding"; +#[cfg(not(test))] +const BROWSER_RESIZE_COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(test)] +const BROWSER_RESIZE_COMPLETION_TIMEOUT: Duration = Duration::from_millis(100); + +#[derive(Debug)] +pub struct BrowserResizeTimeout; + +impl std::fmt::Display for BrowserResizeTimeout { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("browser resize completion timed out") + } +} + +impl std::error::Error for BrowserResizeTimeout {} impl BrowserRuntime { pub fn connect(opts: &SurfaceOptions) -> anyhow::Result> { @@ -757,6 +777,7 @@ fn run_browser_worker_command( failures: &mut BrowserWorkerErrorState, ) { let is_input = command.is_input(); + let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); let completion = match &mut command { BrowserCommand::Reconfigure { completion, .. } => completion.take(), _ => None, @@ -795,11 +816,25 @@ fn run_browser_worker_command( BrowserCommand::Reconfigure { cols, rows, completion: _ } => { browser.reconfigure_resize_blocking(cols, rows) } + #[cfg(test)] + BrowserCommand::Hold { entered, release } => { + let _ = entered.send(()); + release.recv().map_err(anyhow::Error::msg) + } } }; + let mut completion_abandoned = false; if let Some(completion) = completion { let outcome = result.as_ref().map(|_| ()).map_err(ToString::to_string); - let _ = completion.send(outcome); + completion_abandoned = completion.send(outcome).is_err(); + } + if completion_abandoned + && is_reconfigure + && result.is_ok() + && let Some(mux) = mux.upgrade() + { + let (cols, rows) = surface.size(); + mux.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); } record_browser_worker_result(surface, mux, id, is_input, result, failures); } @@ -957,10 +992,15 @@ impl BrowserSurface { rows: rows.max(1), completion: Some(completion), })?; - finished - .recv() - .map_err(|_| anyhow::anyhow!("browser command worker closed"))? - .map_err(anyhow::Error::msg)?; + match finished.recv_timeout(BROWSER_RESIZE_COMPLETION_TIMEOUT) { + Ok(outcome) => outcome.map_err(anyhow::Error::msg)?, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + return Err(BrowserResizeTimeout.into()); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("browser command worker closed"); + } + } Ok(needed) } @@ -1676,9 +1716,9 @@ fn percent_encode_query(input: &str) -> String { #[cfg(test)] mod tests { use super::{ - BrowserCaptureOptions, BrowserCommand, BrowserFrame, BrowserSource, BrowserStatus, - capture_scale_for, new_surface, normalize_url, runtime_endpoint, scaled_pixels, - take_latest_worker_commands, + BrowserCaptureOptions, BrowserCommand, BrowserFrame, BrowserResizeTimeout, BrowserSource, + BrowserStatus, capture_scale_for, new_surface, normalize_url, runtime_endpoint, + scaled_pixels, take_latest_worker_commands, }; use crate::{Mux, Surface, SurfaceOptions}; use serde_json::{Value, json}; @@ -2083,6 +2123,28 @@ mod tests { done.recv_timeout(Duration::from_secs(1)).expect("browser worker exited after kill"); } + #[test] + fn browser_resize_completion_wait_is_bounded() { + let surface = test_surface(); + let browser = surface.as_browser().expect("browser surface"); + let done = browser.take_worker_done_for_test(); + let (entered, started) = mpsc::channel(); + let (release, held) = mpsc::channel(); + browser + .command_sender() + .unwrap() + .send(BrowserCommand::Hold { entered, release: held }) + .unwrap(); + started.recv_timeout(Duration::from_secs(1)).unwrap(); + + let error = browser.resize_blocking(11, 5).unwrap_err(); + assert!(error.downcast_ref::().is_some()); + + release.send(()).unwrap(); + browser.kill(); + done.recv_timeout(Duration::from_secs(1)).expect("browser worker exited after release"); + } + #[test] fn timeout_failed_status_notice_is_emitted_once_per_stall_episode() { let surface = test_surface(); diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 9f1bccca4e69..dd62e9188385 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -19,7 +19,7 @@ pub mod layout; pub mod platform; pub mod server; -pub use browser::{TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; +pub use browser::{BrowserResizeTimeout, TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; pub use event_bus::{MuxEventBroadcaster, MuxEventReceiver}; pub use layout::{ LayoutResult, Rect, SplitEdge, SplitResize, directional_neighbor, layout_screen, diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index a728ba66bbc2..5fa2fbaa5968 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -16,8 +16,9 @@ use std::time::{Duration, Instant}; use base64::Engine; use cmux_tui_core::{ - BrowserSource, BrowserStatus, MuxEvent, PaneId, Rect, SplitDir, SplitEdge, SurfaceId, - SurfaceKind, WorkspaceId, layout_screen, split_for_pane_edge, split_sides, + BrowserResizeTimeout, BrowserSource, BrowserStatus, MuxEvent, PaneId, Rect, SplitDir, + SplitEdge, SurfaceId, SurfaceKind, WorkspaceId, layout_screen, split_for_pane_edge, + split_sides, }; use crossterm::ExecutableCommand; use crossterm::event::{ @@ -1040,8 +1041,9 @@ impl OrderedSession { Ok(()) } Err(error) => { - let transient = - is_remote_timeout(&error) || is_remote_transport_failure(&error); + let transient = is_remote_timeout(&error) + || is_remote_transport_failure(&error) + || error.downcast_ref::().is_some(); let mut failures = failures.lock().unwrap(); let previous = failures.get(&surface_id).map(|failure| failure.state); let state = next_surface_sync_failure(previous, transient, false); diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index cd6d2602f973..48d35fc72986 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -22,6 +22,10 @@ use serde_json::{Value, json}; use super::tree::{TreeView, parse_tree}; const SUPPORTED_PROTOCOL_VERSION: u64 = 7; +#[cfg(not(test))] +const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(test)] +const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_millis(100); #[derive(Debug)] pub(crate) enum RemoteRequestError { @@ -314,6 +318,9 @@ impl RemoteSession { let stream = transport::connect(path).map_err(|e| { anyhow::anyhow!("cannot connect to session socket {}: {e}", path.display()) })?; + stream.set_write_timeout(Some(REMOTE_WRITE_TIMEOUT)).map_err(|error| { + anyhow::anyhow!("cannot configure session socket write timeout: {error}") + })?; let read_half = stream.try_clone_box()?; let session = Arc::new(RemoteSession { writer: Mutex::new(stream), @@ -642,10 +649,14 @@ impl RemoteSession { let (tx, rx) = channel(); self.pending.lock().unwrap().insert(id, tx); - if let Err(err) = self.writer.lock().unwrap().write_all(&line) { + let mut writer = self.writer.lock().unwrap(); + if let Err(err) = writer.write_all(&line) { + let _ = writer.shutdown(); + drop(writer); self.pending.lock().unwrap().remove(&id); return Err(RemoteRequestError::Transport(err).into()); } + drop(writer); if self.shutdown.load(Ordering::Acquire) { self.pending.lock().unwrap().remove(&id); @@ -943,6 +954,7 @@ mod tests { #[cfg(unix)] fn socket_test_session(stream: UnixStream) -> Arc { + stream.set_write_timeout(Some(REMOTE_WRITE_TIMEOUT)).unwrap(); Arc::new(RemoteSession { writer: Mutex::new(Box::new(stream)), pending: Mutex::new(HashMap::new()), @@ -989,6 +1001,24 @@ mod tests { assert!(release["id"].as_u64().unwrap() > first["id"].as_u64().unwrap()); } + #[cfg(unix)] + #[test] + fn stalled_remote_write_times_out_and_closes_the_transport() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let payload = vec![b'x'; 4 * 1024 * 1024]; + + let error = session.send_bytes(7, &payload).unwrap_err(); + + assert!(error.downcast_ref::().is_some_and(|error| { + matches!(error, RemoteRequestError::Transport(io_error) if matches!( + io_error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + )) + })); + assert!(session.pending.lock().unwrap().is_empty()); + } + #[cfg(unix)] #[test] fn background_refresh_failure_does_not_mark_identity_stale() { From 49deb96096a5842a660867c8ec909bf9e596d349 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:01:57 -0700 Subject: [PATCH 080/103] Recover web terminal attachment overflow --- .../web/src/hooks/useAttachedTerminal.ts | 88 ++++++++++++++----- cmux-tui/frontends/web/src/i18n.ts | 2 + .../frontends/web/src/lib/attachRecovery.ts | 5 ++ .../web/test/attach-recovery.test.ts | 8 ++ 4 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 cmux-tui/frontends/web/src/lib/attachRecovery.ts create mode 100644 cmux-tui/frontends/web/test/attach-recovery.test.ts diff --git a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts index 52f7c7a8f017..611d0289af5b 100644 --- a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts +++ b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts @@ -8,8 +8,11 @@ import type { DecodedResizedEvent, DecodedVtStateEvent, Id, + OverflowEvent, } from "cmux/browser"; +import { attachRecoveryDelay } from "../lib/attachRecovery"; import { debounce } from "../lib/debounce"; +import { t } from "../i18n"; interface AttachedTerminalOptions { client: CmuxClient | null; @@ -28,6 +31,7 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin const terminal = new Terminal({ cursorBlink: true, convertEol: false, + disableStdin: true, fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace', fontSize: 13, lineHeight: 1.15, @@ -65,37 +69,71 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin sendResize(); const input = terminal.onData((text) => void client.send(surface, { text }).catch(onError)); let stream: Awaited> | null = null; + let retryTimer: ReturnType | undefined; + let wakeRetry: (() => void) | null = null; + + const waitForRetry = (delayMs: number) => + new Promise((resolve) => { + wakeRetry = resolve; + retryTimer = setTimeout(() => { + retryTimer = undefined; + wakeRetry = null; + resolve(); + }, delayMs); + }); void (async () => { try { - stream = await client.attachSurface(surface); - // Cleanup may have raced the attach round-trip; close the stream we - // just opened or its buffered events leak for the surface's lifetime. - if (cancelled) return; + let recoveryAttempt = 0; for (;;) { - let event; - try { - event = await stream.next(); - } catch (error) { + stream = await client.attachSurface(surface); + // Cleanup may have raced the attach round-trip; close the stream we + // just opened or its buffered events leak for the surface's lifetime. + if (cancelled) return; + let overflowed = false; + for (;;) { + let event; + try { + event = await stream.next(); + } catch (error) { + if (cancelled) return; + // Idle terminals produce no output within the SDK's per-read + // timeout; keep reading. Anything else ends the attachment. + if (error instanceof CmuxTimeoutError) continue; + throw error; + } if (cancelled) return; - // Idle terminals produce no output within the SDK's per-read - // timeout; keep reading. Anything else ends the attachment. - if (error instanceof CmuxTimeoutError) continue; - throw error; + if (event.event === "vt-state") { + const replay = event as DecodedVtStateEvent; + terminal.reset(); + terminal.resize(replay.cols, replay.rows); + terminal.write(replay.data); + terminal.options.disableStdin = false; + } else if (event.event === "output") { + terminal.write((event as DecodedOutputEvent).data); + } else if (event.event === "resized") { + const resized = event as DecodedResizedEvent; + terminal.reset(); + terminal.resize(resized.cols, resized.rows); + terminal.write(resized.data); + } else if (event.event === "overflow") { + const overflow = event as OverflowEvent; + if (overflow.scope === "surface" && overflow.surface === surface) { + terminal.options.disableStdin = true; + overflowed = true; + break; + } + } } - if (cancelled) return; - if (event.event === "vt-state") { - const replay = event as DecodedVtStateEvent; - terminal.resize(replay.cols, replay.rows); - terminal.write(replay.data); - } else if (event.event === "output") { - terminal.write((event as DecodedOutputEvent).data); - } else if (event.event === "resized") { - const resized = event as DecodedResizedEvent; - terminal.reset(); - terminal.resize(resized.cols, resized.rows); - terminal.write(resized.data); + stream.close(); + stream = null; + if (!overflowed) return; + const delayMs = attachRecoveryDelay(recoveryAttempt++); + if (delayMs === null) { + throw new Error(t("attachOverflowRecoveryFailed")); } + await waitForRetry(delayMs); + if (cancelled) return; } } catch (error) { if (!cancelled) onError(error instanceof Error ? error : new Error(String(error))); @@ -114,6 +152,8 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin host.removeEventListener("touchend", focusOnTouch); sendResize.cancel(); input.dispose(); + if (retryTimer !== undefined) clearTimeout(retryTimer); + wakeRetry?.(); stream?.close(); terminal.dispose(); setFocused(false); diff --git a/cmux-tui/frontends/web/src/i18n.ts b/cmux-tui/frontends/web/src/i18n.ts index 114beb3b6462..f668a79fdff1 100644 --- a/cmux-tui/frontends/web/src/i18n.ts +++ b/cmux-tui/frontends/web/src/i18n.ts @@ -24,6 +24,7 @@ const messages = { unknownError: "Unable to connect.", wrongApp: "Expected a cmux-tui server, but received {app}.", wrongProtocol: "Protocol 7 is required; the server reported protocol {protocol}.", + attachOverflowRecoveryFailed: "Terminal output overflowed repeatedly. Reconnect to resume safely.", openWorkspaces: "Open workspaces", closeWorkspaces: "Close workspaces", extraKeys: "Terminal extra keys", @@ -61,6 +62,7 @@ const messages = { unknownError: "接続できませんでした。", wrongApp: "cmux-tui サーバーが必要ですが、{app} を受信しました。", wrongProtocol: "プロトコル7が必要ですが、サーバーはプロトコル{protocol}を返しました。", + attachOverflowRecoveryFailed: "ターミナル出力が繰り返しオーバーフローしました。安全に再開するには再接続してください。", openWorkspaces: "ワークスペースを開く", closeWorkspaces: "ワークスペースを閉じる", extraKeys: "ターミナル追加キー", diff --git a/cmux-tui/frontends/web/src/lib/attachRecovery.ts b/cmux-tui/frontends/web/src/lib/attachRecovery.ts new file mode 100644 index 000000000000..202db374407f --- /dev/null +++ b/cmux-tui/frontends/web/src/lib/attachRecovery.ts @@ -0,0 +1,5 @@ +const ATTACH_RECOVERY_DELAYS_MS = [100, 250, 500] as const; + +export function attachRecoveryDelay(attempt: number): number | null { + return ATTACH_RECOVERY_DELAYS_MS[attempt] ?? null; +} diff --git a/cmux-tui/frontends/web/test/attach-recovery.test.ts b/cmux-tui/frontends/web/test/attach-recovery.test.ts new file mode 100644 index 000000000000..3ecc7194d993 --- /dev/null +++ b/cmux-tui/frontends/web/test/attach-recovery.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { attachRecoveryDelay } from "../src/lib/attachRecovery"; + +describe("surface attach recovery", () => { + it("uses bounded backoff", () => { + expect([0, 1, 2, 3].map(attachRecoveryDelay)).toEqual([100, 250, 500, null]); + }); +}); From 437440bca94e8517d9fb64feac6506f22a86787f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:19:11 -0700 Subject: [PATCH 081/103] Preserve recovery routes and outcomes --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 26 +++++---- cmux-tui/crates/cmux-tui/src/app.rs | 57 ++++++++++++++----- .../web/src/hooks/useAttachedTerminal.ts | 1 + 3 files changed, 57 insertions(+), 27 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index b1074ae8d005..f0648134e21b 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -149,7 +149,7 @@ enum BrowserCommand { Reconfigure { cols: u16, rows: u16, - completion: Option>>, + completion: Option>>, }, #[cfg(test)] Hold { @@ -778,6 +778,7 @@ fn run_browser_worker_command( ) { let is_input = command.is_input(); let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); + let mut resize_changed = false; let completion = match &mut command { BrowserCommand::Reconfigure { completion, .. } => completion.take(), _ => None, @@ -814,7 +815,9 @@ fn run_browser_worker_command( BrowserCommand::Reload => browser.reload_blocking(), BrowserCommand::Activate => browser.activate_blocking(), BrowserCommand::Reconfigure { cols, rows, completion: _ } => { - browser.reconfigure_resize_blocking(cols, rows) + browser.reconfigure_resize_blocking(cols, rows).map(|changed| { + resize_changed = changed; + }) } #[cfg(test)] BrowserCommand::Hold { entered, release } => { @@ -825,11 +828,12 @@ fn run_browser_worker_command( }; let mut completion_abandoned = false; if let Some(completion) = completion { - let outcome = result.as_ref().map(|_| ()).map_err(ToString::to_string); + let outcome = result.as_ref().map(|_| resize_changed).map_err(ToString::to_string); completion_abandoned = completion.send(outcome).is_err(); } if completion_abandoned && is_reconfigure + && resize_changed && result.is_ok() && let Some(mux) = mux.upgrade() { @@ -985,7 +989,6 @@ impl BrowserSurface { } pub(crate) fn resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { - let needed = self.resize_needed(cols, rows); let (completion, finished) = sync_channel(1); self.enqueue_control(BrowserCommand::Reconfigure { cols: cols.max(1), @@ -993,24 +996,22 @@ impl BrowserSurface { completion: Some(completion), })?; match finished.recv_timeout(BROWSER_RESIZE_COMPLETION_TIMEOUT) { - Ok(outcome) => outcome.map_err(anyhow::Error::msg)?, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - return Err(BrowserResizeTimeout.into()); - } + Ok(outcome) => outcome.map_err(anyhow::Error::msg), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(BrowserResizeTimeout.into()), Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { anyhow::bail!("browser command worker closed"); } } - Ok(needed) } - fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result<()> { + fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { + let needed = self.resize_needed(cols, rows); let Some((width, height)) = self.update_resize_state(cols, rows) else { - return Ok(()); + return Ok(false); }; self.reconfigure_blocking(width, height)?; self.confirm_reconfigure(width, height); - Ok(()) + Ok(needed) } pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { @@ -2463,6 +2464,7 @@ mod tests { assert!(browser.resize_blocking(10, 5).unwrap()); assert!(!browser.resize_needed(10, 5)); + assert!(!browser.resize_blocking(10, 5).unwrap()); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5fa2fbaa5968..617f50101503 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1665,6 +1665,7 @@ enum Drag { /// Mouse reporting owned by the PTY application in this pane. PtyMouse { surface: SurfaceId, + handle: Option, reservation_id: u64, release_bytes: PtyInputBytes, content: Rect, @@ -4808,8 +4809,17 @@ impl App { } self.sidebar_focused = false; self.selection = None; - let (release_capture, forwarded) = - self.prepare_pty_mouse_press(area.surface, area.content, x, y, button, modifiers); + let Some(handle) = self.session.surface(area.surface) else { + return PtyMousePressResult::Consumed; + }; + let (release_capture, forwarded) = self.prepare_pty_mouse_press( + (area.surface, handle.clone()), + area.content, + x, + y, + button, + modifiers, + ); if matches!(release_capture, PtyMouseReleaseCapture::Failed) { return PtyMousePressResult::Consumed; } @@ -4825,6 +4835,7 @@ impl App { }; self.drag = Some(Drag::PtyMouse { surface: area.surface, + handle: Some(handle), reservation_id, release_bytes, content: area.content, @@ -4837,17 +4848,15 @@ impl App { fn prepare_pty_mouse_press( &mut self, - surface_id: SurfaceId, + route: (SurfaceId, SurfaceHandle), content: Rect, x: u16, y: u16, button: MouseButton, modifiers: KeyModifiers, ) -> (PtyMouseReleaseCapture, PtyInputForwardResult) { + let (surface_id, surface) = route; let failed = || PtyInputForwardResult { accepted: false, reservation_id: None }; - let Some(surface) = self.session.surface(surface_id) else { - return (PtyMouseReleaseCapture::Failed, failed()); - }; let cell_width = u32::from(self.cell_pixels.0.max(1)); let cell_height = u32::from(self.cell_pixels.1.max(1)); let position = ( @@ -4946,13 +4955,19 @@ impl App { modifiers: KeyModifiers, ) -> bool { let Some(Drag::PtyMouse { - surface, reservation_id, release_bytes, content, button, .. + surface, + handle, + reservation_id, + release_bytes, + content, + button, + .. }) = &self.drag else { return false; }; - let (surface, reservation_id, fallback, content, button) = - (*surface, *reservation_id, release_bytes.clone(), *content, *button); + let (surface, handle, reservation_id, fallback, content, button) = + (*surface, handle.clone(), *reservation_id, release_bytes.clone(), *content, *button); if reported_button != button { return true; } @@ -4961,12 +4976,12 @@ impl App { self.drag = None; match release { PtyMouseReleaseCapture::Bytes(bytes) => { - if !self.enqueue_pty_release(surface, reservation_id, bytes) { + if !self.enqueue_pty_release(surface, handle, reservation_id, bytes) { self.pty_input.cancel_release_reservation(reservation_id); } } PtyMouseReleaseCapture::Failed => { - if !self.enqueue_pty_release(surface, reservation_id, fallback) { + if !self.enqueue_pty_release(surface, handle, reservation_id, fallback) { self.pty_input.cancel_release_reservation(reservation_id); } } @@ -5024,10 +5039,13 @@ impl App { fn enqueue_pty_release( &mut self, surface_id: SurfaceId, + retained: Option, reservation_id: u64, bytes: PtyInputBytes, ) -> bool { - let Some(surface) = self.session.surface(surface_id) else { return false }; + let Some(surface) = retained.or_else(|| self.session.surface(surface_id)) else { + return false; + }; self.encode_buf.clear(); self.encode_buf.extend_from_slice(bytes.as_ref()); let (result, _) = self.pty_input.enqueue_with_reservation(PtyInputEvent::release( @@ -5042,6 +5060,7 @@ impl App { fn cancel_pty_mouse_drag(&mut self) { let Some(Drag::PtyMouse { surface, + handle, reservation_id, release_bytes, content, @@ -5052,8 +5071,9 @@ impl App { else { return; }; - let (surface, reservation_id, fallback, content, button, position, modifiers) = ( + let (surface, handle, reservation_id, fallback, content, button, position, modifiers) = ( *surface, + handle.clone(), *reservation_id, release_bytes.clone(), *content, @@ -5067,12 +5087,12 @@ impl App { self.drag = None; match release { PtyMouseReleaseCapture::Bytes(bytes) => { - if !self.enqueue_pty_release(surface, reservation_id, bytes) { + if !self.enqueue_pty_release(surface, handle, reservation_id, bytes) { self.pty_input.cancel_release_reservation(reservation_id); } } PtyMouseReleaseCapture::Failed => { - if !self.enqueue_pty_release(surface, reservation_id, fallback) { + if !self.enqueue_pty_release(surface, handle, reservation_id, fallback) { self.pty_input.cancel_release_reservation(reservation_id); } } @@ -6543,6 +6563,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: surface.id, + handle: None, reservation_id: 41, release_bytes: PtyInputBytes::from_slice(b"fallback-release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, @@ -6567,6 +6588,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + handle: None, reservation_id: 1, release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, @@ -6655,6 +6677,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + handle: None, reservation_id: 1, release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, @@ -6683,6 +6706,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + handle: None, reservation_id: 7, release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, @@ -6711,6 +6735,7 @@ mod tests { let mut app = test_app(Session::Local(mux)); app.drag = Some(Drag::PtyMouse { surface: 42, + handle: None, reservation_id: 9, release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, @@ -7666,6 +7691,7 @@ mod tests { app.sidebar_plugin_retry_at = Some(Instant::now() - Duration::from_millis(1)); app.drag = Some(Drag::PtyMouse { surface: 42, + handle: None, reservation_id: 7, release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 1, y: 1, width: 20, height: 8 }, @@ -8287,6 +8313,7 @@ mod tests { app.session.pending_mutations.store(1, Ordering::Release); app.drag = Some(Drag::PtyMouse { surface: 42, + handle: None, reservation_id: 1, release_bytes: PtyInputBytes::from_slice(b"release"), content: Rect { x: 2, y: 3, width: 20, height: 8 }, diff --git a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts index 611d0289af5b..713b34c969bd 100644 --- a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts +++ b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts @@ -109,6 +109,7 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin terminal.resize(replay.cols, replay.rows); terminal.write(replay.data); terminal.options.disableStdin = false; + recoveryAttempt = 0; } else if (event.event === "output") { terminal.write((event as DecodedOutputEvent).data); } else if (event.event === "resized") { From 70ec0c836079c46bbbc36646c42bad325b0b2547 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:31:45 -0700 Subject: [PATCH 082/103] Bound attach overflow recovery --- cmux-tui/crates/cmux-tui-core/src/server.rs | 9 +- cmux-tui/crates/cmux-tui/src/app.rs | 8 ++ cmux-tui/crates/cmux-tui/src/session/mod.rs | 14 +++ .../crates/cmux-tui/src/session/remote.rs | 107 +++++++++++++++++- .../web/src/hooks/useAttachedTerminal.ts | 14 ++- .../frontends/web/src/lib/attachRecovery.ts | 1 + .../web/test/attach-recovery.test.ts | 3 +- 7 files changed, 147 insertions(+), 9 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index c6d07d3903bd..871d3b5bc917 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -417,6 +417,7 @@ struct Response { } const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); +const WEBSOCKET_READ_POLL: Duration = Duration::from_millis(10); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(not(test))] const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); @@ -1013,7 +1014,7 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& return; } } - let _ = websocket.get_mut().set_read_timeout(Some(STREAM_DISCONNECT_POLL)); + let _ = websocket.get_mut().set_read_timeout(Some(WEBSOCKET_READ_POLL)); let _ = websocket.get_mut().set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); let Ok(writer_stream) = websocket.get_ref().try_clone() else { return }; let _ = writer_stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); @@ -1044,7 +1045,11 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& break; } - match websocket.read() { + let incoming = { + let _guard = write_lock.lock().unwrap(); + websocket.read() + }; + match incoming { Ok(Message::Text(text)) => { if !handle_message(&mux, &text, &writer) { break; diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 617f50101503..d597ddfb8c99 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -627,6 +627,10 @@ impl OrderedSession { self.inner.has_surface(id) } + fn surface_overflow_retry_due(&self) -> bool { + self.inner.surface_overflow_retry_due() + } + fn forget_surface(&self, id: SurfaceId) { if self.remote { self.exited_surfaces.lock().unwrap().insert(id); @@ -731,6 +735,7 @@ impl OrderedSession { fn can_attach_surface(&self, id: SurfaceId) -> bool { self.inner.cached_surface(id).is_none() + && self.inner.can_attach_after_overflow(id) && !self.exited_surfaces.lock().unwrap().contains(&id) && !self .surface_attach_failures @@ -2191,6 +2196,9 @@ impl App { } self.retry_sidebar_plugin_if_due(); self.retry_background_refresh_if_due(); + if self.session.surface_overflow_retry_due() { + action = action.merge(RenderAction::Draw); + } self.render_action(terminal, action)?; if self.routing_refresh_pending { self.routing_refresh_pending = false; diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 51fa90fc07c4..980d160cf01c 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -274,6 +274,20 @@ impl Session { } } + pub fn can_attach_after_overflow(&self, id: SurfaceId) -> bool { + match self { + Session::Local(_) => true, + Session::Remote(remote) => remote.can_attach_after_overflow(id), + } + } + + pub fn surface_overflow_retry_due(&self) -> bool { + match self { + Session::Local(_) => false, + Session::Remote(remote) => remote.surface_overflow_retry_due(), + } + } + /// Like [`Session::surface`], but passes the render size for remote /// mirrors created on first use (the server surface is resized before /// the attach replay, so the replay arrives at final geometry). diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 48d35fc72986..59c51e9ae3b9 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -22,6 +22,9 @@ use serde_json::{Value, json}; use super::tree::{TreeView, parse_tree}; const SUPPORTED_PROTOCOL_VERSION: u64 = 7; +const SURFACE_OVERFLOW_RETRY_DELAYS: [Duration; 3] = + [Duration::from_millis(250), Duration::from_millis(500), Duration::from_secs(1)]; +const SURFACE_OVERFLOW_STABLE: Duration = Duration::from_secs(5); #[cfg(not(test))] const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(test)] @@ -100,6 +103,14 @@ struct RemoteTreeCache { title_updates: HashMap, } +#[derive(Clone, Copy)] +struct SurfaceOverflowRecovery { + attempts: u8, + retry_after: Option, + attached_at: Option, + stopped: bool, +} + struct TitleUpdate { generation: u64, title: String, @@ -303,6 +314,7 @@ pub struct RemoteSession { subscription_recovery_in_flight: AtomicBool, subscribers: MuxEventBroadcaster, frame_logs: Mutex>>, + surface_overflow_recovery: Mutex>, } impl RemoteSession { @@ -335,6 +347,7 @@ impl RemoteSession { subscription_recovery_in_flight: AtomicBool::new(false), subscribers: MuxEventBroadcaster::default(), frame_logs: Mutex::new(HashMap::new()), + surface_overflow_recovery: Mutex::new(HashMap::new()), }); let reader_session = Arc::downgrade(&session); @@ -499,6 +512,7 @@ impl RemoteSession { } Some("surface-exited") => { if let Some(id) = surface_id() { + self.surface_overflow_recovery.lock().unwrap().remove(&id); self.tree_stale.store(true, Ordering::Release); self.emit(MuxEvent::SurfaceExited(id)); } @@ -552,12 +566,19 @@ impl RemoteSession { let surface_id = surface_id(); if let Some(surface_id) = surface_id { self.surfaces.lock().unwrap().remove(&surface_id); + let (delay, stopped) = self.record_surface_overflow(surface_id); self.emit(MuxEvent::SurfaceOutput(surface_id)); + self.emit(MuxEvent::Status(if stopped { + format!( + "surface {surface_id} event stream repeatedly overflowed; detach and reconnect to recover" + ) + } else { + format!( + "surface {surface_id} event stream overflowed; retrying in {} ms", + delay.unwrap_or_default().as_millis() + ) + })); } - let surface = surface_id.map_or_else(String::new, |id| format!(" {id}")); - self.emit(MuxEvent::Status(format!( - "surface{surface} event stream overflowed; mirror will reattach" - ))); return; } self.tree_stale.store(true, Ordering::Release); @@ -724,6 +745,43 @@ impl RemoteSession { true } + fn record_surface_overflow(&self, id: SurfaceId) -> (Option, bool) { + let now = Instant::now(); + let mut recoveries = self.surface_overflow_recovery.lock().unwrap(); + let recovery = recoveries.entry(id).or_insert(SurfaceOverflowRecovery { + attempts: 0, + retry_after: None, + attached_at: None, + stopped: false, + }); + if recovery + .attached_at + .is_some_and(|attached| now.duration_since(attached) >= SURFACE_OVERFLOW_STABLE) + { + recovery.attempts = 0; + } + recovery.attached_at = None; + let delay = SURFACE_OVERFLOW_RETRY_DELAYS.get(usize::from(recovery.attempts)).copied(); + recovery.attempts = recovery.attempts.saturating_add(1); + recovery.stopped = delay.is_none(); + recovery.retry_after = delay.map(|delay| now + delay); + (delay, recovery.stopped) + } + + pub fn can_attach_after_overflow(&self, id: SurfaceId) -> bool { + self.surface_overflow_recovery.lock().unwrap().get(&id).is_none_or(|recovery| { + !recovery.stopped + && recovery.retry_after.is_none_or(|retry_after| Instant::now() >= retry_after) + }) + } + + pub fn surface_overflow_retry_due(&self) -> bool { + self.surface_overflow_recovery.lock().unwrap().values().any(|recovery| { + !recovery.stopped + && recovery.retry_after.is_some_and(|retry_after| Instant::now() >= retry_after) + }) + } + /// Mirror for a surface, attaching on first use. When a size is /// provided, the caller's immediately following `resize` sends the /// server resize after the attach tap is installed, so the resize @@ -749,6 +807,9 @@ impl RemoteSession { if self.exited_surfaces.lock().unwrap().contains(&id) { return Ok(None); } + if !self.can_attach_after_overflow(id) { + return Ok(None); + } if let Some(surface) = self.surfaces.lock().unwrap().get(&id) { return Ok(Some(surface.clone())); } @@ -774,11 +835,16 @@ impl RemoteSession { self.surfaces.lock().unwrap().remove(&id); return Err(error); } + if let Some(recovery) = self.surface_overflow_recovery.lock().unwrap().get_mut(&id) { + recovery.attached_at = Some(Instant::now()); + recovery.retry_after = None; + } Ok(Some(surface)) } pub fn drop_surface(&self, id: SurfaceId) { self.surfaces.lock().unwrap().remove(&id); + self.surface_overflow_recovery.lock().unwrap().remove(&id); self.exited_surfaces.lock().unwrap().insert(id); } @@ -968,6 +1034,7 @@ mod tests { subscription_recovery_in_flight: AtomicBool::new(false), subscribers: MuxEventBroadcaster::default(), frame_logs: Mutex::new(HashMap::new()), + surface_overflow_recovery: Mutex::new(HashMap::new()), }) } @@ -1019,6 +1086,38 @@ mod tests { assert!(session.pending.lock().unwrap().is_empty()); } + #[cfg(unix)] + #[test] + fn repeated_surface_overflow_stops_until_reconnect() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + + for _ in 0..SURFACE_OVERFLOW_RETRY_DELAYS.len() { + let (delay, stopped) = session.record_surface_overflow(7); + assert!(delay.is_some()); + assert!(!stopped); + let mut recoveries = session.surface_overflow_recovery.lock().unwrap(); + let recovery = recoveries.get_mut(&7).unwrap(); + recovery.retry_after = Some(Instant::now() - Duration::from_millis(1)); + recovery.attached_at = Some(Instant::now()); + drop(recoveries); + assert!(session.can_attach_after_overflow(7)); + } + + let (delay, stopped) = session.record_surface_overflow(7); + assert!(delay.is_none()); + assert!(stopped); + assert!(!session.can_attach_after_overflow(7)); + + let mut recoveries = session.surface_overflow_recovery.lock().unwrap(); + let recovery = recoveries.get_mut(&7).unwrap(); + recovery.attached_at = Some(Instant::now() - SURFACE_OVERFLOW_STABLE); + drop(recoveries); + let (delay, stopped) = session.record_surface_overflow(7); + assert_eq!(delay, Some(SURFACE_OVERFLOW_RETRY_DELAYS[0])); + assert!(!stopped); + } + #[cfg(unix)] #[test] fn background_refresh_failure_does_not_mark_identity_stale() { diff --git a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts index 713b34c969bd..00dd1ce4939a 100644 --- a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts +++ b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts @@ -10,7 +10,7 @@ import type { Id, OverflowEvent, } from "cmux/browser"; -import { attachRecoveryDelay } from "../lib/attachRecovery"; +import { ATTACH_RECOVERY_STABLE_MS, attachRecoveryDelay } from "../lib/attachRecovery"; import { debounce } from "../lib/debounce"; import { t } from "../i18n"; @@ -70,6 +70,7 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin const input = terminal.onData((text) => void client.send(surface, { text }).catch(onError)); let stream: Awaited> | null = null; let retryTimer: ReturnType | undefined; + let stableTimer: ReturnType | undefined; let wakeRetry: (() => void) | null = null; const waitForRetry = (delayMs: number) => @@ -109,7 +110,11 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin terminal.resize(replay.cols, replay.rows); terminal.write(replay.data); terminal.options.disableStdin = false; - recoveryAttempt = 0; + if (stableTimer !== undefined) clearTimeout(stableTimer); + stableTimer = setTimeout(() => { + stableTimer = undefined; + recoveryAttempt = 0; + }, ATTACH_RECOVERY_STABLE_MS); } else if (event.event === "output") { terminal.write((event as DecodedOutputEvent).data); } else if (event.event === "resized") { @@ -121,6 +126,10 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin const overflow = event as OverflowEvent; if (overflow.scope === "surface" && overflow.surface === surface) { terminal.options.disableStdin = true; + if (stableTimer !== undefined) { + clearTimeout(stableTimer); + stableTimer = undefined; + } overflowed = true; break; } @@ -154,6 +163,7 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin sendResize.cancel(); input.dispose(); if (retryTimer !== undefined) clearTimeout(retryTimer); + if (stableTimer !== undefined) clearTimeout(stableTimer); wakeRetry?.(); stream?.close(); terminal.dispose(); diff --git a/cmux-tui/frontends/web/src/lib/attachRecovery.ts b/cmux-tui/frontends/web/src/lib/attachRecovery.ts index 202db374407f..8912ead5f07b 100644 --- a/cmux-tui/frontends/web/src/lib/attachRecovery.ts +++ b/cmux-tui/frontends/web/src/lib/attachRecovery.ts @@ -1,4 +1,5 @@ const ATTACH_RECOVERY_DELAYS_MS = [100, 250, 500] as const; +export const ATTACH_RECOVERY_STABLE_MS = 5_000; export function attachRecoveryDelay(attempt: number): number | null { return ATTACH_RECOVERY_DELAYS_MS[attempt] ?? null; diff --git a/cmux-tui/frontends/web/test/attach-recovery.test.ts b/cmux-tui/frontends/web/test/attach-recovery.test.ts index 3ecc7194d993..74d9e8857099 100644 --- a/cmux-tui/frontends/web/test/attach-recovery.test.ts +++ b/cmux-tui/frontends/web/test/attach-recovery.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; -import { attachRecoveryDelay } from "../src/lib/attachRecovery"; +import { ATTACH_RECOVERY_STABLE_MS, attachRecoveryDelay } from "../src/lib/attachRecovery"; describe("surface attach recovery", () => { it("uses bounded backoff", () => { expect([0, 1, 2, 3].map(attachRecoveryDelay)).toEqual([100, 250, 500, null]); + expect(ATTACH_RECOVERY_STABLE_MS).toBe(5_000); }); }); From 6f4458ec9c4a3bcad5ca27ffeb1a784d31e295fb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:50:41 -0700 Subject: [PATCH 083/103] Test bounded terminal replay --- cmux-tui/crates/ghostty-vt/src/terminal.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 771d802e95d5..f8664df65bb9 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -659,4 +659,24 @@ mod tests { assert_ne!(first.instance_id(), second.instance_id()); } + + #[test] + fn bounded_vt_replay_keeps_the_latest_screen_after_large_history() { + let mut source = Terminal::new(80, 24, 2 * 1024 * 1024, Callbacks::default()).unwrap(); + let wide_line = "x".repeat(2048); + for index in 0..500 { + source.vt_write(format!("history-{index:04}-{wide_line}\r\n").as_bytes()); + } + source.vt_write(b"LATEST-VISIBLE-CONTENT"); + + let full = source.vt_replay().unwrap(); + assert!(full.len() > 32 * 1024); + + let bounded = source.vt_replay_bounded(32 * 1024).unwrap(); + assert!(bounded.len() <= 32 * 1024); + + let mut restored = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + restored.vt_write(&bounded); + assert!(restored.viewport_text().unwrap().contains("LATEST-VISIBLE-CONTENT")); + } } From 727f3c3b8009d1473e9b763e7667243292c0379d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:52:13 -0700 Subject: [PATCH 084/103] Bound terminal state replays --- cmux-tui/crates/cmux-tui-core/src/server.rs | 3 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 5 +- cmux-tui/crates/ghostty-vt/src/terminal.rs | 112 ++++++++++++++++--- 3 files changed, 101 insertions(+), 19 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 871d3b5bc917..b9bab4923b0d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -1735,7 +1735,8 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &MessageWriter) -> anyho let surface = get_surface(mux, surface)?; require_pty(&surface)?; let (cols, rows, replay) = surface.try_with_terminal(|t| { - t.vt_replay().map(|replay| (t.cols(), t.rows(), replay)) + t.vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .map(|replay| (t.cols(), t.rows(), replay)) })??; Ok(json!({ "cols": cols, diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 953ffb67d3c0..2bec411261f8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -114,6 +114,7 @@ pub enum AttachFrame { const ATTACH_STREAM_CAPACITY: usize = 256; const ATTACH_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const VT_REPLAY_MAX_BYTES: usize = 8 * 1024 * 1024; pub struct AttachFrameReceiver { receiver: Receiver, @@ -723,7 +724,7 @@ impl Surface { let queued_bytes = Arc::new(AtomicUsize::new(0)); // Snapshot and tap registration under the same terminal lock: // the reader thread cannot apply bytes between the two. - let replay = term.vt_replay()?; + let replay = term.vt_replay_bounded(VT_REPLAY_MAX_BYTES)?; let (cols, rows) = (term.cols(), term.rows()); pty.taps.lock().unwrap().push(AttachTap { sender: tx, @@ -947,7 +948,7 @@ impl PtySurface { }); // Nominal cell metrics; only pixel size reports observe these. let _ = term.resize(cols, rows, 8, 16); - let replay = term.vt_replay().unwrap_or_default(); + let replay = term.vt_replay_bounded(VT_REPLAY_MAX_BYTES).unwrap_or_default(); self.broadcast_attach_frame(AttachFrame::Resized { cols, rows, replay }); true } diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index f8664df65bb9..e34b919f8740 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -502,23 +502,10 @@ impl Terminal { unwrap_lines: bool, trim: bool, ) -> Option { - let grid_ref = |x: u16, y: u64| -> Option { - let y = u32::try_from(y).ok()?; - let point = sys::GhosttyPoint { - tag, - value: sys::GhosttyPointValue { coordinate: sys::GhosttyPointCoordinate { x, y } }, - }; - let mut out = sys::GhosttyGridRef { - size: size_of::(), - ..Default::default() - }; - let result = unsafe { sys::ghostty_terminal_grid_ref(self.raw, point, &mut out) }; - (result == sys::GHOSTTY_SUCCESS).then_some(out) - }; let selection = sys::GhosttySelection { size: size_of::(), - start: grid_ref(start.0, start.1)?, - end: grid_ref(end.0, end.1)?, + start: self.grid_ref(tag, start.0, start.1)?, + end: self.grid_ref(tag, end.0, end.1)?, rectangle: false, }; let opts = sys::GhosttyFormatterTerminalOptions { @@ -559,6 +546,82 @@ impl Terminal { /// state, charsets, and tabstops. This is the attach primitive: a new /// frontend replays this, then follows the live pty stream. pub fn vt_replay(&mut self) -> Result> { + self.vt_replay_with_selection(None) + } + + /// VT replay bounded to `max_bytes`, retaining the newest complete rows. + /// + /// Full history is preserved when it fits. Oversized history is reduced + /// until the active screen and as much recent scrollback as possible fit. + /// A pathological screen whose newest row alone exceeds the budget falls + /// back to a terminal reset so callers can still attach and receive live + /// output instead of entering a permanent overflow loop. + pub fn vt_replay_bounded(&mut self, max_bytes: usize) -> Result> { + let full = self.vt_replay()?; + if full.len() <= max_bytes { + return Ok(full); + } + + let Some(scrollbar) = self.scrollbar() else { + return Ok(minimal_vt_replay(max_bytes)); + }; + if scrollbar.total == 0 { + return Ok(minimal_vt_replay(max_bytes)); + } + + let screen_rows = scrollbar.len.min(scrollbar.total).max(1); + let proportional_rows = ((u128::from(scrollbar.total) * max_bytes as u128) + / full.len() as u128) + .min(u128::from(scrollbar.total)) as u64; + let mut tail_rows = proportional_rows.max(screen_rows); + + loop { + if let Ok(replay) = self.vt_replay_screen_tail(tail_rows) + && replay.len() <= max_bytes + { + return Ok(replay); + } + + let next = if tail_rows > screen_rows { + screen_rows + (tail_rows - screen_rows) / 2 + } else if tail_rows > 1 { + tail_rows / 2 + } else { + break; + }; + tail_rows = if next == tail_rows { screen_rows } else { next }; + } + + Ok(minimal_vt_replay(max_bytes)) + } + + fn vt_replay_screen_tail(&mut self, rows: u64) -> Result> { + let scrollbar = self.scrollbar().ok_or(crate::Error::InvalidValue)?; + let cols = self.cols(); + if cols == 0 || scrollbar.total == 0 || rows == 0 { + return Err(crate::Error::InvalidValue); + } + let selection = sys::GhosttySelection { + size: size_of::(), + start: self + .grid_ref(sys::GHOSTTY_POINT_TAG_SCREEN, 0, scrollbar.total.saturating_sub(rows)) + .ok_or(crate::Error::InvalidValue)?, + end: self + .grid_ref( + sys::GHOSTTY_POINT_TAG_SCREEN, + cols.saturating_sub(1), + scrollbar.total - 1, + ) + .ok_or(crate::Error::InvalidValue)?, + rectangle: false, + }; + self.vt_replay_with_selection(Some(&selection)) + } + + fn vt_replay_with_selection( + &mut self, + selection: Option<&sys::GhosttySelection>, + ) -> Result> { let opts = sys::GhosttyFormatterTerminalOptions { size: size_of::(), emit: sys::GHOSTTY_FORMATTER_FORMAT_VT, @@ -582,11 +645,23 @@ impl Terminal { charsets: true, }, }, - selection: ptr::null(), + selection: selection.map_or(ptr::null(), |value| value), }; self.format(opts) } + fn grid_ref(&self, tag: sys::GhosttyPointTag, x: u16, y: u64) -> Option { + let y = u32::try_from(y).ok()?; + let point = sys::GhosttyPoint { + tag, + value: sys::GhosttyPointValue { coordinate: sys::GhosttyPointCoordinate { x, y } }, + }; + let mut out = + sys::GhosttyGridRef { size: size_of::(), ..Default::default() }; + let result = unsafe { sys::ghostty_terminal_grid_ref(self.raw, point, &mut out) }; + (result == sys::GHOSTTY_SUCCESS).then_some(out) + } + fn format(&mut self, opts: sys::GhosttyFormatterTerminalOptions) -> Result> { let mut formatter: sys::GhosttyFormatter = ptr::null_mut(); check(unsafe { @@ -618,6 +693,11 @@ impl Terminal { } } +fn minimal_vt_replay(max_bytes: usize) -> Vec { + const RESET: &[u8] = b"\x1bc"; + if max_bytes >= RESET.len() { RESET.to_vec() } else { Vec::new() } +} + impl Drop for Terminal { fn drop(&mut self) { unsafe { From d3c10af24bf9eaa2ea463f601d39b0514d53e0b5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:22:59 -0700 Subject: [PATCH 085/103] Document PTY failure wake ownership --- cmux-tui/crates/cmux-tui/src/app.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index c5f04ecfc5cd..130e5dafd8e2 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1946,6 +1946,9 @@ pub fn run( let failure_ingress = pty_failures.clone(); let pty_input = PtyInputDispatcher::spawn(move |failure| { if failure_ingress.push(failure) { + // This is a latency hint, not the ownership path. The event loop + // drains the ingress after every batch and timeout, including + // when this bounded app channel is currently full. let _ = failure_tx.try_send(AppEvent::PtyFailuresReady); } })?; @@ -2168,6 +2171,8 @@ impl App { Err(_) => break, } } + // Always drain retained failures. PtyFailuresReady only shortens + // the idle wait, so a failed try_send cannot create a lost wakeup. action = action.merge(self.apply_pty_failures()); if self.session.take_cancellation_pending() { if self.session.has_pending_mutations() { @@ -6871,7 +6876,7 @@ mod tests { } #[test] - fn pty_failure_ingress_stays_bounded_and_does_not_block_on_a_full_app_channel() { + fn pty_failure_ingress_rearms_after_a_full_app_channel_loses_its_wake() { let ingress = PtyFailureIngress::default(); let (events, receiver) = std::sync::mpsc::sync_channel(1); events.send(AppEvent::MuxTitlesReady).unwrap(); @@ -6897,6 +6902,16 @@ mod tests { assert_eq!(failures.len(), 1); assert_eq!(failures.front().unwrap().error, "motion 999"); assert!(matches!(receiver.try_recv(), Ok(AppEvent::MuxTitlesReady))); + + assert!(ingress.push(PtyOperationFailure { + surface_id: Some(42), + kind: Some(PtyInputKind::Motion), + reservation_id: None, + label: "PTY input", + error: "motion after drain".into(), + lane_failed: false, + delivery: PtyOperationDelivery::KnownNotDelivered, + })); } #[test] From 929cd84ca2537e8cfdd61e207c60f379e6b7b4ab Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:35:38 -0700 Subject: [PATCH 086/103] Remove polling from bounded transports --- cmux-tui/crates/cmux-tui-core/src/server.rs | 78 ++++++++++++++++----- cmux-tui/crates/ghostty-vt/src/terminal.rs | 43 +++++++----- 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 55c923671a2c..6d287be842b6 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -19,7 +19,7 @@ //! ``` use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -35,7 +35,7 @@ use serde_json::{Value, json}; use tungstenite::protocol::CloseFrame; use tungstenite::protocol::Role; use tungstenite::protocol::frame::coding::CloseCode; -use tungstenite::{Error as WebSocketError, Message, accept}; +use tungstenite::{Message, accept}; use crate::model::{Screen, State}; use crate::platform::{self, transport}; @@ -432,7 +432,6 @@ struct Response { } const STREAM_DISCONNECT_POLL: Duration = Duration::from_millis(100); -const WEBSOCKET_READ_POLL: Duration = Duration::from_millis(10); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(not(test))] const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); @@ -825,6 +824,55 @@ enum SinkControl { WebSocket(TcpStream), } +/// Cloned TCP streams share one write boundary so independent Tungstenite +/// reader and writer contexts cannot interleave frame bytes. Reads remain +/// fully blocking and are interrupted by shutting down a clone. +struct SynchronizedTcpStream { + stream: TcpStream, + write_lock: Arc>, +} + +impl SynchronizedTcpStream { + fn new(stream: TcpStream) -> Self { + Self { stream, write_lock: Arc::new(Mutex::new(())) } + } + + fn try_clone(&self) -> std::io::Result { + Ok(Self { stream: self.stream.try_clone()?, write_lock: self.write_lock.clone() }) + } + + fn try_clone_raw(&self) -> std::io::Result { + self.stream.try_clone() + } + + fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()> { + self.stream.set_read_timeout(timeout) + } + + fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()> { + self.stream.set_write_timeout(timeout) + } +} + +impl Read for SynchronizedTcpStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.stream.read(buf) + } +} + +impl Write for SynchronizedTcpStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let _guard = self.write_lock.lock().unwrap(); + self.stream.write_all(buf)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + let _guard = self.write_lock.lock().unwrap(); + self.stream.flush() + } +} + impl SinkControl { fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()> { match self { @@ -1213,6 +1261,7 @@ fn handle_connection(mux: Arc, stream: Box) { } fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<&str>) { + let stream = SynchronizedTcpStream::new(stream); if stream.set_read_timeout(Some(WEBSOCKET_HANDSHAKE_TIMEOUT)).is_err() || stream.set_write_timeout(Some(WEBSOCKET_HANDSHAKE_TIMEOUT)).is_err() { @@ -1233,10 +1282,11 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& return; } } - let _ = websocket.get_mut().set_read_timeout(Some(WEBSOCKET_READ_POLL)); + let _ = websocket.get_mut().set_read_timeout(None); let _ = websocket.get_mut().set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); let Ok(writer_stream) = websocket.get_ref().try_clone() else { return }; - let Ok(control) = writer_stream.try_clone() else { return }; + let Ok(writer_shutdown) = writer_stream.try_clone_raw() else { return }; + let Ok(control) = writer_stream.try_clone_raw() else { return }; let _ = writer_stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { @@ -1244,19 +1294,19 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& control: Some(SinkControl::WebSocket(control)), }); let writer_outbound = outbound; - let write_lock = Arc::new(Mutex::new(())); - let writer_lock = write_lock.clone(); let Ok(writer_thread) = std::thread::Builder::new().name("mux-ws-out".into()).spawn(move || { let mut websocket = tungstenite::WebSocket::from_raw_socket(writer_stream, Role::Server, None); while let Some(text) = writer_outbound.recv() { - let _guard = writer_lock.lock().unwrap(); if websocket.send(Message::Text(text.into())).is_err() { writer_outbound.close(); break; } } + let _ = websocket.close(None); + let _ = websocket.flush(); + let _ = writer_shutdown.shutdown(Shutdown::Both); }) else { writer.close(); @@ -1269,10 +1319,7 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& break; } - let incoming = { - let _guard = write_lock.lock().unwrap(); - websocket.read() - }; + let incoming = websocket.read(); match incoming { Ok(Message::Text(text)) => { if !handle_message(&mux, client, &text, &writer) { @@ -1280,22 +1327,15 @@ fn handle_websocket_connection(mux: Arc, stream: TcpStream, token: Option<& } } Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => { - let _guard = write_lock.lock().unwrap(); let _ = websocket.flush(); } Ok(Message::Close(_)) => break, Ok(_) => break, - Err(WebSocketError::Io(error)) - if matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => {} Err(_) => break, } } disconnect_client(&mux, client, false); let _ = writer_thread.join(); - let _guard = write_lock.lock().unwrap(); let _ = websocket.close(None); } diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index d2a95cdce1af..6010042f882a 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -8,6 +8,7 @@ use crate::render::CursorShape; use crate::{Result, check}; static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(1); +const VT_REPLAY_ESTIMATED_BYTES_PER_CELL: u64 = 32; /// RGB color triple. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -725,16 +726,12 @@ impl Terminal { /// VT replay bounded to `max_bytes`, retaining the newest complete rows. /// /// Full history is preserved when it fits. Oversized history is reduced - /// until the active screen and as much recent scrollback as possible fit. + /// to a recent row window derived from the budget before formatting, then + /// reduced further until the active screen fits. /// A pathological screen whose newest row alone exceeds the budget falls /// back to a terminal reset so callers can still attach and receive live /// output instead of entering a permanent overflow loop. pub fn vt_replay_bounded(&mut self, max_bytes: usize) -> Result> { - let full = self.vt_replay()?; - if full.len() <= max_bytes { - return Ok(full); - } - let Some(scrollbar) = self.scrollbar() else { return Ok(minimal_vt_replay(max_bytes)); }; @@ -743,10 +740,8 @@ impl Terminal { } let screen_rows = scrollbar.len.min(scrollbar.total).max(1); - let proportional_rows = ((u128::from(scrollbar.total) * max_bytes as u128) - / full.len() as u128) - .min(u128::from(scrollbar.total)) as u64; - let mut tail_rows = proportional_rows.max(screen_rows); + let mut tail_rows = + vt_replay_row_window(scrollbar.total, screen_rows, self.cols(), max_bytes); loop { if let Ok(replay) = self.vt_replay_screen_tail(tail_rows) @@ -754,15 +749,14 @@ impl Terminal { { return Ok(replay); } - - let next = if tail_rows > screen_rows { - screen_rows + (tail_rows - screen_rows) / 2 - } else if tail_rows > 1 { - tail_rows / 2 - } else { + if tail_rows <= 1 { break; + } + tail_rows = if tail_rows > screen_rows { + screen_rows.max(tail_rows / 2) + } else { + tail_rows / 2 }; - tail_rows = if next == tail_rows { screen_rows } else { next }; } Ok(minimal_vt_replay(max_bytes)) @@ -871,6 +865,12 @@ fn minimal_vt_replay(max_bytes: usize) -> Vec { if max_bytes >= RESET.len() { RESET.to_vec() } else { Vec::new() } } +fn vt_replay_row_window(total_rows: u64, screen_rows: u64, cols: u16, max_bytes: usize) -> u64 { + let estimated_row_bytes = u64::from(cols.max(1)) * VT_REPLAY_ESTIMATED_BYTES_PER_CELL; + let budget_rows = u64::try_from(max_bytes).unwrap_or(u64::MAX) / estimated_row_bytes; + budget_rows.max(screen_rows).min(total_rows) +} + impl Drop for Terminal { fn drop(&mut self) { unsafe { @@ -890,7 +890,7 @@ impl Drop for Terminal { #[cfg(test)] mod tests { - use super::{Callbacks, MouseModeScan, Terminal}; + use super::{Callbacks, MouseModeScan, Terminal, vt_replay_row_window}; #[test] fn mouse_mode_scan_tracks_split_private_mode_sequences() { @@ -932,4 +932,11 @@ mod tests { restored.vt_write(&bounded); assert!(restored.viewport_text().unwrap().contains("LATEST-VISIBLE-CONTENT")); } + + #[test] + fn bounded_vt_replay_limits_rows_before_formatting_large_history() { + let rows = vt_replay_row_window(1_000_000, 24, 80, 8 * 1024 * 1024); + + assert_eq!(rows, 3_276); + } } From 0a9a82262e86800171737df123e850c1ef80f85f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:51:11 -0700 Subject: [PATCH 087/103] Coordinate overflow and resize recovery --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 101 ++++++++++++++--- cmux-tui/crates/cmux-tui/src/app.rs | 107 ++++++++++++++----- 2 files changed, 165 insertions(+), 43 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index f0648134e21b..5b8120143809 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Condvar, Mutex, Weak}; use std::time::{Duration, Instant}; use cmux_tui_cdp::{ @@ -149,7 +149,7 @@ enum BrowserCommand { Reconfigure { cols: u16, rows: u16, - completion: Option>>, + completion: Option>, }, #[cfg(test)] Hold { @@ -179,6 +179,42 @@ struct BrowserWorkerErrorState { consecutive_timeouts: u8, } +struct BrowserResizeOperation { + desired: (u16, u16), + result: Mutex>>, + changed: Condvar, + timed_out: AtomicBool, +} + +impl BrowserResizeOperation { + fn new(desired: (u16, u16)) -> Self { + Self { + desired, + result: Mutex::new(None), + changed: Condvar::new(), + timed_out: AtomicBool::new(false), + } + } + + fn wait(&self, timeout: Duration) -> Option> { + let result = self.result.lock().unwrap(); + if result.is_some() { + return result.clone(); + } + let (result, _) = self.changed.wait_timeout(result, timeout).unwrap(); + if result.is_none() { + self.timed_out.store(true, Ordering::Release); + } + result.clone() + } + + fn complete(&self, result: Result) -> bool { + *self.result.lock().unwrap() = Some(result); + self.changed.notify_all(); + self.timed_out.load(Ordering::Acquire) + } +} + pub struct BrowserRuntime { client: CdpClient, chrome: Option, @@ -205,6 +241,7 @@ pub struct BrowserSurface { command_tx: Mutex>>, latest_reconfigure: Arc>>, latest_nav: Arc>>, + resize_operation: Mutex>>, #[cfg(test)] worker_done: Mutex>>, } @@ -428,6 +465,7 @@ pub(crate) fn new_surface( command_tx: Mutex::new(Some(command_tx)), latest_reconfigure: latest_reconfigure.clone(), latest_nav: latest_nav.clone(), + resize_operation: Mutex::new(None), #[cfg(test)] worker_done: Mutex::new(Some(worker_done_rx)), })); @@ -829,7 +867,9 @@ fn run_browser_worker_command( let mut completion_abandoned = false; if let Some(completion) = completion { let outcome = result.as_ref().map(|_| resize_changed).map_err(ToString::to_string); - completion_abandoned = completion.send(outcome).is_err(); + if let Some(browser) = surface.as_browser() { + completion_abandoned = browser.finish_resize_operation(&completion, outcome); + } } if completion_abandoned && is_reconfigure @@ -989,21 +1029,48 @@ impl BrowserSurface { } pub(crate) fn resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { - let (completion, finished) = sync_channel(1); - self.enqueue_control(BrowserCommand::Reconfigure { - cols: cols.max(1), - rows: rows.max(1), - completion: Some(completion), - })?; - match finished.recv_timeout(BROWSER_RESIZE_COMPLETION_TIMEOUT) { - Ok(outcome) => outcome.map_err(anyhow::Error::msg), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(BrowserResizeTimeout.into()), - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - anyhow::bail!("browser command worker closed"); + let desired = (cols.max(1), rows.max(1)); + let (operation, enqueue) = { + let mut slot = self.resize_operation.lock().unwrap(); + match slot.as_ref() { + Some(operation) if operation.desired == desired => (operation.clone(), false), + Some(_) => return Err(BrowserResizeTimeout.into()), + None => { + let operation = Arc::new(BrowserResizeOperation::new(desired)); + *slot = Some(operation.clone()); + (operation, true) + } } + }; + if enqueue + && let Err(error) = self.enqueue_control(BrowserCommand::Reconfigure { + cols: desired.0, + rows: desired.1, + completion: Some(operation.clone()), + }) + { + self.finish_resize_operation(&operation, Err(error.to_string())); + return Err(error); + } + match operation.wait(BROWSER_RESIZE_COMPLETION_TIMEOUT) { + Some(outcome) => outcome.map_err(anyhow::Error::msg), + None => Err(BrowserResizeTimeout.into()), } } + fn finish_resize_operation( + &self, + operation: &Arc, + outcome: Result, + ) -> bool { + let timed_out = operation.complete(outcome); + let mut slot = self.resize_operation.lock().unwrap(); + if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, operation)) { + *slot = None; + } + timed_out + } + fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { let needed = self.resize_needed(cols, rows); let Some((width, height)) = self.update_resize_state(cols, rows) else { @@ -2140,6 +2207,12 @@ mod tests { let error = browser.resize_blocking(11, 5).unwrap_err(); assert!(error.downcast_ref::().is_some()); + let first_operation = browser.resize_operation.lock().unwrap().clone().unwrap(); + + let error = browser.resize_blocking(11, 5).unwrap_err(); + assert!(error.downcast_ref::().is_some()); + let joined_operation = browser.resize_operation.lock().unwrap().clone().unwrap(); + assert!(Arc::ptr_eq(&first_operation, &joined_operation)); release.send(()).unwrap(); browser.kill(); diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 130e5dafd8e2..26058c2fd04e 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -109,13 +109,26 @@ fn forward_mux_events( ) { let mut next_recovery_generation = 0_u64; loop { - while let Ok(event) = session_events.recv() { - if !forward_mux_event(event, &tx, &mux_titles) { - return; + let needs_recovery = if session_events.overflowed() { + true + } else { + match session_events.recv() { + Ok(event) => match forward_mux_event(event, &tx, &mux_titles) { + ForwardMuxOutcome::Continue => false, + ForwardMuxOutcome::Recover => true, + ForwardMuxOutcome::Stop => return, + }, + Err(_) => { + if session_events.overflowed() { + true + } else { + return; + } + } } - } - if !session_events.overflowed() { - return; + }; + if !needs_recovery { + continue; } next_recovery_generation = next_recovery_generation.wrapping_add(1).max(1); let recovery_generation = next_recovery_generation; @@ -151,12 +164,18 @@ fn forward_mux_events( if !recovery_succeeded { continue; } + let mut app_channel_full = false; for event in session_events.try_iter() { - if !forward_mux_event(event, &tx, &mux_titles) { - return; + match forward_mux_event(event, &tx, &mux_titles) { + ForwardMuxOutcome::Continue => {} + ForwardMuxOutcome::Recover => { + app_channel_full = true; + break; + } + ForwardMuxOutcome::Stop => return, } } - if session_events.overflowed() { + if app_channel_full || session_events.overflowed() { continue; } // A prior title wake may precede the authoritative snapshot. Queue a @@ -169,20 +188,38 @@ fn forward_mux_events( } } +enum ForwardMuxOutcome { + Continue, + Recover, + Stop, +} + fn forward_mux_event( event: MuxEvent, tx: &SyncSender, mux_titles: &MuxTitleIngress, -) -> bool { +) -> ForwardMuxOutcome { match event { MuxEvent::TitleChanged { surface, title } => { - return !mux_titles.push(surface, title) || tx.send(AppEvent::MuxTitlesReady).is_ok(); + if !mux_titles.push(surface, title) { + return ForwardMuxOutcome::Continue; + } + return match tx.try_send(AppEvent::MuxTitlesReady) { + Ok(()) => ForwardMuxOutcome::Continue, + Err(TrySendError::Full(_)) => ForwardMuxOutcome::Recover, + Err(TrySendError::Disconnected(_)) => ForwardMuxOutcome::Stop, + }; } MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), _ => {} } let terminal = matches!(event, MuxEvent::Empty); - tx.send(AppEvent::Mux(event)).is_ok() && !terminal + match tx.try_send(AppEvent::Mux(event)) { + Ok(()) if terminal => ForwardMuxOutcome::Stop, + Ok(()) => ForwardMuxOutcome::Continue, + Err(TrySendError::Full(_)) => ForwardMuxOutcome::Recover, + Err(TrySendError::Disconnected(_)) => ForwardMuxOutcome::Stop, + } } #[derive(Default)] @@ -7149,24 +7186,36 @@ mod tests { mux.emit(MuxEvent::Bell(surface)); } - let mut recovered = false; - for _ in 0..5_000 { - let event = rx.recv_timeout(Duration::from_secs(1)).unwrap(); - if matches!(event, AppEvent::MuxSubscriptionRecovered { result: Ok(_), .. }) { - recovered = true; - break; - } + let first = rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while recovery_generation.load(Ordering::Acquire) == 0 && Instant::now() < deadline { + std::thread::yield_now(); } - assert!(recovered); - assert_eq!(recovery_generation.load(Ordering::Acquire), 1); - assert!(matches!( - rx.recv_timeout(Duration::from_secs(1)).unwrap(), - AppEvent::MuxTitlesReady - )); - assert!(matches!( - rx.recv_timeout(Duration::from_secs(1)).unwrap(), - AppEvent::MuxRecoveryComplete { recovery_generation: 1 } - )); + assert!( + recovery_generation.load(Ordering::Acquire) > 0, + "the routing barrier must rise before the stale app backlog is drained" + ); + + let mut next = Some(first); + let mut recovered_generations = HashSet::new(); + let final_generation = loop { + let event = + next.take().unwrap_or_else(|| rx.recv_timeout(Duration::from_secs(1)).unwrap()); + match event { + AppEvent::MuxSubscriptionRecovered { + recovery_generation, result: Ok(_), .. + } => { + recovered_generations.insert(recovery_generation); + } + AppEvent::MuxRecoveryComplete { recovery_generation } + if recovered_generations.contains(&recovery_generation) => + { + break recovery_generation; + } + _ => {} + } + }; + assert_eq!(recovery_generation.load(Ordering::Acquire), final_generation); mux.emit(MuxEvent::Bell(9_999)); assert!(matches!( From fd5d6e414e069a225189a81308304f098b44dc60 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:04:58 -0700 Subject: [PATCH 088/103] Resolve ambiguous mouse and title recovery --- cmux-tui/crates/cmux-tui/src/app.rs | 44 ++++++++++++++++------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 26058c2fd04e..428cd5eeb418 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -140,16 +140,8 @@ fn forward_mux_events( let title_snapshot_epoch = mux_titles.current_epoch(); let recovered = event_source.refresh_tree().map_err(|error| error.to_string()); let recovery_succeeded = recovered.is_ok(); - if let Ok(tree) = &recovered { - let live_surfaces = tree - .workspaces - .iter() - .flat_map(|workspace| workspace.screens.iter()) - .flat_map(|screen| screen.panes.iter()) - .flat_map(|pane| pane.tabs.iter()) - .map(|tab| tab.surface) - .collect::>(); - mux_titles.reconcile_authoritative(&live_surfaces, title_snapshot_epoch); + if recovered.is_ok() { + mux_titles.reconcile_authoritative(title_snapshot_epoch); } if tx .send(AppEvent::MuxSubscriptionRecovered { @@ -278,11 +270,9 @@ impl MuxTitleIngress { self.state.lock().unwrap().epoch } - fn reconcile_authoritative(&self, live_surfaces: &HashSet, through_epoch: u64) { + fn reconcile_authoritative(&self, through_epoch: u64) { let mut state = self.state.lock().unwrap(); - state.titles.retain(|surface, retained| { - live_surfaces.contains(surface) || retained.epoch > through_epoch - }); + state.titles.retain(|_, retained| retained.epoch > through_epoch); let retained_surfaces = state.titles.keys().copied().collect::>(); state.dirty.retain(|surface| retained_surfaces.contains(surface)); } @@ -1815,6 +1805,7 @@ pub struct App { pty_failures: Arc, mux_recovery_generation: Arc, drag: Option, + ignored_pty_mouse_buttons: HashSet, encoder: KeyEncoder, mouse_encoder: MouseEncoder, encode_buf: Vec, @@ -2123,6 +2114,7 @@ pub fn run( pty_failures, mux_recovery_generation, drag: None, + ignored_pty_mouse_buttons: HashSet::new(), encoder, mouse_encoder, encode_buf: Vec::with_capacity(64), @@ -4723,9 +4715,12 @@ impl App { // This TUI tracks one active pointer button. Ignore additional presses // until its release so a second button cannot orphan the inner app's // pressed state. - if matches!(mouse.kind, MouseEventKind::Down(_)) - && matches!(self.drag, Some(Drag::PtyMouse { .. })) + if let MouseEventKind::Down(button) = mouse.kind + && let Some(Drag::PtyMouse { button: active, .. }) = &self.drag { + if button != *active { + self.ignored_pty_mouse_buttons.insert(button); + } return Ok(RenderAction::None); } match mouse.kind { @@ -4909,6 +4904,7 @@ impl App { position: (x, y), modifiers, }); + self.ignored_pty_mouse_buttons.clear(); PtyMousePressResult::Started } @@ -5034,12 +5030,13 @@ impl App { }; let (surface, handle, reservation_id, fallback, content, button) = (*surface, handle.clone(), *reservation_id, release_bytes.clone(), *content, *button); - if reported_button != button { + if reported_button != button && self.ignored_pty_mouse_buttons.remove(&reported_button) { return true; } let content = self.current_pty_content(surface).unwrap_or(content); let release = self.capture_pty_mouse_release(surface, content, x, y, button, modifiers); self.drag = None; + self.ignored_pty_mouse_buttons.clear(); match release { PtyMouseReleaseCapture::Bytes(bytes) => { if !self.enqueue_pty_release(surface, handle, reservation_id, bytes) { @@ -5151,6 +5148,7 @@ impl App { let release = self .capture_pty_mouse_release(surface, content, position.0, position.1, button, modifiers); self.drag = None; + self.ignored_pty_mouse_buttons.clear(); match release { PtyMouseReleaseCapture::Bytes(bytes) => { if !self.enqueue_pty_release(surface, handle, reservation_id, bytes) { @@ -6557,6 +6555,13 @@ mod tests { assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); + assert!(app.drag.is_none()); + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) .unwrap(); app.pane_areas[0].content.x += 3; @@ -7148,13 +7153,13 @@ mod tests { } #[test] - fn title_ingress_prunes_pre_snapshot_orphans_but_keeps_concurrent_updates() { + fn title_ingress_prunes_all_pre_snapshot_titles_but_keeps_concurrent_updates() { let titles = MuxTitleIngress::default(); titles.push(41, "stale-title"); let snapshot_epoch = titles.current_epoch(); titles.push(42, "concurrent-title"); - titles.reconcile_authoritative(&HashSet::new(), snapshot_epoch); + titles.reconcile_authoritative(snapshot_epoch); let retained = titles.snapshot(); assert!(!retained.contains_key(&41)); @@ -8879,6 +8884,7 @@ mod tests { pty_failures: Arc::new(PtyFailureIngress::default()), mux_recovery_generation: Arc::new(AtomicU64::new(0)), drag: None, + ignored_pty_mouse_buttons: HashSet::new(), encoder: KeyEncoder::new().unwrap(), mouse_encoder: MouseEncoder::new().unwrap(), encode_buf: Vec::new(), From 20260600329bc19e2323c4440690f157d80d38f4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:14:58 -0700 Subject: [PATCH 089/103] Preserve bounded replay and title locality --- cmux-tui/crates/ghostty-vt/src/terminal.rs | 91 ++++++++++++++++--- .../frontends/web/src/hooks/useCmuxClient.ts | 37 +++++++- cmux-tui/frontends/web/src/lib/tree.ts | 30 ++++++ cmux-tui/frontends/web/test/tree.test.ts | 15 ++- 4 files changed, 159 insertions(+), 14 deletions(-) diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 6010042f882a..d7e19388d875 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -725,13 +725,17 @@ impl Terminal { /// VT replay bounded to `max_bytes`, retaining the newest complete rows. /// - /// Full history is preserved when it fits. Oversized history is reduced - /// to a recent row window derived from the budget before formatting, then - /// reduced further until the active screen fits. + /// The formatter first measures the complete replay without allocating its + /// output, preserving full history when it fits. Oversized history is then + /// reduced to a recent row window derived from the budget and reduced + /// further until the active screen fits. /// A pathological screen whose newest row alone exceeds the budget falls /// back to a terminal reset so callers can still attach and receive live /// output instead of entering a permanent overflow loop. pub fn vt_replay_bounded(&mut self, max_bytes: usize) -> Result> { + if let Some(replay) = self.vt_replay_with_selection_bounded(None, max_bytes)? { + return Ok(replay); + } let Some(scrollbar) = self.scrollbar() else { return Ok(minimal_vt_replay(max_bytes)); }; @@ -744,9 +748,7 @@ impl Terminal { vt_replay_row_window(scrollbar.total, screen_rows, self.cols(), max_bytes); loop { - if let Ok(replay) = self.vt_replay_screen_tail(tail_rows) - && replay.len() <= max_bytes - { + if let Some(replay) = self.vt_replay_screen_tail_bounded(tail_rows, max_bytes)? { return Ok(replay); } if tail_rows <= 1 { @@ -762,7 +764,11 @@ impl Terminal { Ok(minimal_vt_replay(max_bytes)) } - fn vt_replay_screen_tail(&mut self, rows: u64) -> Result> { + fn vt_replay_screen_tail_bounded( + &mut self, + rows: u64, + max_bytes: usize, + ) -> Result>> { let scrollbar = self.scrollbar().ok_or(crate::Error::InvalidValue)?; let cols = self.cols(); if cols == 0 || scrollbar.total == 0 || rows == 0 { @@ -782,14 +788,28 @@ impl Terminal { .ok_or(crate::Error::InvalidValue)?, rectangle: false, }; - self.vt_replay_with_selection(Some(&selection)) + self.vt_replay_with_selection_bounded(Some(&selection), max_bytes) } fn vt_replay_with_selection( &mut self, selection: Option<&sys::GhosttySelection>, ) -> Result> { - let opts = sys::GhosttyFormatterTerminalOptions { + self.format(Self::vt_replay_options(selection)) + } + + fn vt_replay_with_selection_bounded( + &mut self, + selection: Option<&sys::GhosttySelection>, + max_bytes: usize, + ) -> Result>> { + self.format_bounded(Self::vt_replay_options(selection), max_bytes) + } + + fn vt_replay_options( + selection: Option<&sys::GhosttySelection>, + ) -> sys::GhosttyFormatterTerminalOptions { + sys::GhosttyFormatterTerminalOptions { size: size_of::(), emit: sys::GHOSTTY_FORMATTER_FORMAT_VT, unwrap: false, @@ -813,8 +833,7 @@ impl Terminal { }, }, selection: selection.map_or(ptr::null(), |value| value), - }; - self.format(opts) + } } fn grid_ref(&self, tag: sys::GhosttyPointTag, x: u16, y: u64) -> Option { @@ -858,6 +877,43 @@ impl Terminal { unsafe { sys::ghostty_formatter_free(formatter) }; result } + + fn format_bounded( + &mut self, + opts: sys::GhosttyFormatterTerminalOptions, + max_bytes: usize, + ) -> Result>> { + let mut formatter: sys::GhosttyFormatter = ptr::null_mut(); + check(unsafe { + sys::ghostty_formatter_terminal_new(ptr::null(), &mut formatter, self.raw, opts) + })?; + let result = (|| { + let mut needed: usize = 0; + let query = unsafe { + sys::ghostty_formatter_format_buf(formatter, ptr::null_mut(), 0, &mut needed) + }; + if query != sys::GHOSTTY_OUT_OF_SPACE && query != sys::GHOSTTY_SUCCESS { + check(query)?; + } + if needed > max_bytes { + return Ok(None); + } + let mut buf = vec![0u8; needed.max(1)]; + let mut written: usize = 0; + check(unsafe { + sys::ghostty_formatter_format_buf( + formatter, + buf.as_mut_ptr(), + buf.len(), + &mut written, + ) + })?; + buf.truncate(written); + Ok(Some(buf)) + })(); + unsafe { sys::ghostty_formatter_free(formatter) }; + result + } } fn minimal_vt_replay(max_bytes: usize) -> Vec { @@ -933,6 +989,19 @@ mod tests { assert!(restored.viewport_text().unwrap().contains("LATEST-VISIBLE-CONTENT")); } + #[test] + fn bounded_vt_replay_preserves_complete_history_when_it_fits() { + let mut source = Terminal::new(80, 24, 4 * 1024 * 1024, Callbacks::default()).unwrap(); + for index in 0..10_000 { + source.vt_write(format!("plain-history-{index:05}\r\n").as_bytes()); + } + source.vt_write(b"LATEST-VISIBLE-CONTENT"); + + let full = source.vt_replay().unwrap(); + assert!(full.len() < 8 * 1024 * 1024); + assert_eq!(source.vt_replay_bounded(8 * 1024 * 1024).unwrap(), full); + } + #[test] fn bounded_vt_replay_limits_rows_before_formatting_large_history() { let rows = vt_replay_row_window(1_000_000, 24, 80, 8 * 1024 * 1024); diff --git a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts index 54a54ac304bd..2f1032119402 100644 --- a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts +++ b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts @@ -8,6 +8,7 @@ import { type Id, type IdentifyResult, type NotificationEvent, + type TitleChangedEvent, type Tree, } from "cmux/browser"; import { browserClientName } from "../lib/clientName"; @@ -18,7 +19,7 @@ import { } from "../lib/localSelection"; import { reconnectTransition, type ReconnectState } from "../lib/reconnect"; import { supportsProtocol } from "../lib/protocol"; -import { activeScreen, locateSurface, treeToViewModel } from "../lib/tree"; +import { activeScreen, applySurfaceTitles, locateSurface, treeToViewModel } from "../lib/tree"; import { t } from "../i18n"; export interface ConnectionConfig { @@ -64,6 +65,27 @@ export function useCmuxClient() { let cancelled = false; let activeClient: CmuxClient | null = null; let retryTimer: ReturnType | undefined; + let titleFlushTimer: ReturnType | undefined; + const pendingSurfaceTitles = new Map(); + + const discardPendingSurfaceTitles = () => { + if (titleFlushTimer !== undefined) clearTimeout(titleFlushTimer); + titleFlushTimer = undefined; + pendingSurfaceTitles.clear(); + }; + const flushSurfaceTitles = () => { + titleFlushTimer = undefined; + if (cancelled || pendingSurfaceTitles.size === 0) return; + const updates = new Map(pendingSurfaceTitles); + pendingSurfaceTitles.clear(); + setState((current) => current.tree === null + ? current + : { ...current, tree: applySurfaceTitles(current.tree, updates) }); + }; + const queueSurfaceTitle = (surface: Id, title: string) => { + pendingSurfaceTitles.set(surface, title); + titleFlushTimer ??= setTimeout(flushSurfaceTitles, 0); + }; const refresh = async () => { if (!activeClient) return; @@ -148,7 +170,17 @@ export function useCmuxClient() { setUnread((current) => new Set(current).add(notification.surface!)); } } - if (["tree-changed", "layout-changed", "surface-resized", "surface-exited", "title-changed"].includes(event.event)) { + if (event.event === "title-changed") { + const changed = event as TitleChangedEvent; + if (changed.title === undefined) { + discardPendingSurfaceTitles(); + await refresh(); + } else { + queueSurfaceTitle(changed.surface, changed.title); + } + } + if (["tree-changed", "layout-changed", "surface-resized", "surface-exited"].includes(event.event)) { + discardPendingSurfaceTitles(); await refresh(); } if ( @@ -193,6 +225,7 @@ export function useCmuxClient() { return () => { cancelled = true; if (retryTimer !== undefined) clearTimeout(retryTimer); + discardPendingSurfaceTitles(); refreshRef.current = null; void activeClient?.close(); }; diff --git a/cmux-tui/frontends/web/src/lib/tree.ts b/cmux-tui/frontends/web/src/lib/tree.ts index 653d03f62fee..6841fccbacf6 100644 --- a/cmux-tui/frontends/web/src/lib/tree.ts +++ b/cmux-tui/frontends/web/src/lib/tree.ts @@ -104,3 +104,33 @@ export function locateSurface(tree: Tree, surface: Id): { workspaceId: Id; scree } return null; } + +export function applySurfaceTitles(tree: Tree, titles: ReadonlyMap): Tree { + let treeChanged = false; + const workspaces = tree.workspaces.map((workspace) => { + let workspaceChanged = false; + const screens = workspace.screens.map((screen) => { + let screenChanged = false; + const panes = screen.panes.map((pane) => { + if (!("tabs" in pane)) return pane; + let paneChanged = false; + const tabs = pane.tabs.map((tab) => { + const title = titles.get(tab.surface); + if (title === undefined || title === tab.title) return tab; + paneChanged = true; + return { ...tab, title }; + }); + if (!paneChanged) return pane; + screenChanged = true; + return { ...pane, tabs }; + }); + if (!screenChanged) return screen; + workspaceChanged = true; + return { ...screen, panes }; + }); + if (!workspaceChanged) return workspace; + treeChanged = true; + return { ...workspace, screens }; + }); + return treeChanged ? { workspaces } : tree; +} diff --git a/cmux-tui/frontends/web/test/tree.test.ts b/cmux-tui/frontends/web/test/tree.test.ts index 6f743a28ce89..44768e40b5b7 100644 --- a/cmux-tui/frontends/web/test/tree.test.ts +++ b/cmux-tui/frontends/web/test/tree.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Tree } from "cmux/browser"; import { initialLocalSelectionState } from "../src/lib/localSelection"; -import { activeScreen, screenSelection, treeToViewModel } from "../src/lib/tree"; +import { activeScreen, applySurfaceTitles, screenSelection, treeToViewModel } from "../src/lib/tree"; const tree: Tree = { workspaces: [{ @@ -90,6 +90,19 @@ describe("treeToViewModel", () => { }); }); +describe("applySurfaceTitles", () => { + it("coalesces authoritative titles into matching tabs with structural sharing", () => { + const updated = applySurfaceTitles(tree, new Map([[4, "editor"], [5, "logs"]])); + + expect(updated).not.toBe(tree); + expect(updated.workspaces[0]?.screens[0]?.panes[0]).not.toBe(tree.workspaces[0]?.screens[0]?.panes[0]); + expect("tabs" in updated.workspaces[0]!.screens[0]!.panes[0]! + ? updated.workspaces[0]!.screens[0]!.panes[0]!.tabs.map(({ title }) => title) + : []).toEqual(["editor", "logs"]); + expect(applySurfaceTitles(tree, new Map([[99, "missing"]]))).toBe(tree); + }); +}); + import { locateSurface } from "../src/lib/tree"; describe("locateSurface", () => { From 4a758a7119223d067c9762d2053558a8a410cb64 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:28:34 -0700 Subject: [PATCH 090/103] Reconcile remote size and title ordering --- .../crates/cmux-tui/src/session/remote.rs | 34 ++++++++++++ .../frontends/web/src/hooks/useCmuxClient.ts | 26 +++++---- cmux-tui/frontends/web/src/lib/tree.ts | 55 +++++++++++++++++++ cmux-tui/frontends/web/test/tree.test.ts | 31 ++++++++++- 4 files changed, 134 insertions(+), 12 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 13338849bd0b..ac3d48b63718 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -433,6 +433,9 @@ impl RemoteSession { let Some(id) = surface_id() else { return }; let cols = value.get("cols").and_then(|v| v.as_u64()).unwrap_or(80) as u16; let rows = value.get("rows").and_then(|v| v.as_u64()).unwrap_or(24) as u16; + if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { + surface.set_server_size(cols, rows); + } self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); } Some("output") => { @@ -1411,6 +1414,37 @@ mod tests { assert_eq!(surface.term.lock().unwrap().plain_text().unwrap(), expected); } + #[cfg(unix)] + #[test] + fn surface_resized_event_reconciles_remote_browser_server_size() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + let surface = Arc::new(RemoteSurface { + id: 7, + kind: SurfaceKind::Browser, + term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), + dirty: AtomicBool::new(false), + server_size: Mutex::new((12, 4)), + asserted_size: Mutex::new(Some((12, 4))), + browser: Mutex::new(RemoteBrowserState::default()), + }); + session.surfaces.lock().unwrap().insert(7, surface.clone()); + + session.handle_line(json!({ + "event": "surface-resized", + "surface": 7, + "cols": 90, + "rows": 31, + })); + + assert_eq!(surface.server_size(), (90, 31)); + assert!(events.try_iter().any(|event| matches!( + event, + MuxEvent::SurfaceResized { surface: 7, cols: 90, rows: 31 } + ))); + } + #[cfg(unix)] #[test] fn notification_event_preserves_payload_without_invalidating_tree() { diff --git a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts index 2f1032119402..2dc1ce7e8e42 100644 --- a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts +++ b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts @@ -19,7 +19,7 @@ import { } from "../lib/localSelection"; import { reconnectTransition, type ReconnectState } from "../lib/reconnect"; import { supportsProtocol } from "../lib/protocol"; -import { activeScreen, applySurfaceTitles, locateSurface, treeToViewModel } from "../lib/tree"; +import { activeScreen, locateSurface, SurfaceTitleReconciler, treeToViewModel } from "../lib/tree"; import { t } from "../i18n"; export interface ConnectionConfig { @@ -57,7 +57,7 @@ export function useCmuxClient() { const [unread, setUnread] = useState>(() => new Set()); const [toasts, setToasts] = useState([]); const [selection, dispatchSelection] = useReducer(localSelectionReducer, initialLocalSelectionState); - const refreshRef = useRef<(() => Promise) | null>(null); + const refreshRef = useRef<(() => Promise) | null>(null); const localToastId = useRef(-1); useEffect(() => { @@ -67,6 +67,7 @@ export function useCmuxClient() { let retryTimer: ReturnType | undefined; let titleFlushTimer: ReturnType | undefined; const pendingSurfaceTitles = new Map(); + const titleReconciler = new SurfaceTitleReconciler(); const discardPendingSurfaceTitles = () => { if (titleFlushTimer !== undefined) clearTimeout(titleFlushTimer); @@ -76,24 +77,28 @@ export function useCmuxClient() { const flushSurfaceTitles = () => { titleFlushTimer = undefined; if (cancelled || pendingSurfaceTitles.size === 0) return; - const updates = new Map(pendingSurfaceTitles); pendingSurfaceTitles.clear(); setState((current) => current.tree === null ? current - : { ...current, tree: applySurfaceTitles(current.tree, updates) }); + : { ...current, tree: titleReconciler.apply(current.tree) }); }; const queueSurfaceTitle = (surface: Id, title: string) => { + titleReconciler.record(surface, title); pendingSurfaceTitles.set(surface, title); titleFlushTimer ??= setTimeout(flushSurfaceTitles, 0); }; const refresh = async () => { - if (!activeClient) return; + if (!activeClient) return null; + discardPendingSurfaceTitles(); + const token = titleReconciler.beginRefresh(); const tree = await activeClient.listWorkspaces(); - if (!cancelled) { - setState((current) => ({ ...current, tree })); - dispatchSelection({ type: "tree-updated", snapshot: selectionSnapshot(tree) }); + const committed = titleReconciler.commit(tree, token); + if (!cancelled && committed.applied) { + setState((current) => ({ ...current, tree: committed.tree })); + dispatchSelection({ type: "tree-updated", snapshot: selectionSnapshot(committed.tree) }); } + return committed.tree; }; const refreshClients = async () => { if (!activeClient) return; @@ -286,9 +291,8 @@ export function useCmuxClient() { (create: (client: CmuxClient) => Promise<{ surface: Id }>) => runMutation(async (client) => { const created = await create(client); - const tree = await client.listWorkspaces(); - setState((current) => ({ ...current, tree })); - dispatchSelection({ type: "tree-updated", snapshot: selectionSnapshot(tree) }); + const tree = await refreshRef.current?.(); + if (!tree) return; const target = locateSurface(tree, created.surface); if (target) { dispatchSelection({ type: "navigate", workspaceId: target.workspaceId, screenId: target.screenId }); diff --git a/cmux-tui/frontends/web/src/lib/tree.ts b/cmux-tui/frontends/web/src/lib/tree.ts index 6841fccbacf6..169561800814 100644 --- a/cmux-tui/frontends/web/src/lib/tree.ts +++ b/cmux-tui/frontends/web/src/lib/tree.ts @@ -134,3 +134,58 @@ export function applySurfaceTitles(tree: Tree, titles: ReadonlyMap): }); return treeChanged ? { workspaces } : tree; } + +export interface TreeRefreshToken { + requestSequence: number; + titleGeneration: number; +} + +interface GeneratedTitle { + generation: number; + title: string; +} + +export interface TreeRefreshCommit { + tree: Tree; + applied: boolean; +} + +export class SurfaceTitleReconciler { + private titleGeneration = 0; + private requestSequence = 0; + private appliedRequestSequence = 0; + private latestTree: Tree | null = null; + private readonly titles = new Map(); + + record(surface: Id, title: string): void { + this.titleGeneration += 1; + this.titles.set(surface, { generation: this.titleGeneration, title }); + } + + beginRefresh(): TreeRefreshToken { + this.requestSequence += 1; + return { + requestSequence: this.requestSequence, + titleGeneration: this.titleGeneration, + }; + } + + apply(tree: Tree): Tree { + return applySurfaceTitles( + tree, + new Map([...this.titles].map(([surface, update]) => [surface, update.title])), + ); + } + + commit(tree: Tree, token: TreeRefreshToken): TreeRefreshCommit { + if (token.requestSequence <= this.appliedRequestSequence && this.latestTree !== null) { + return { tree: this.latestTree, applied: false }; + } + this.appliedRequestSequence = token.requestSequence; + for (const [surface, update] of this.titles) { + if (update.generation <= token.titleGeneration) this.titles.delete(surface); + } + this.latestTree = this.apply(tree); + return { tree: this.latestTree, applied: true }; + } +} diff --git a/cmux-tui/frontends/web/test/tree.test.ts b/cmux-tui/frontends/web/test/tree.test.ts index 44768e40b5b7..a386bcdc5c43 100644 --- a/cmux-tui/frontends/web/test/tree.test.ts +++ b/cmux-tui/frontends/web/test/tree.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import type { Tree } from "cmux/browser"; import { initialLocalSelectionState } from "../src/lib/localSelection"; -import { activeScreen, applySurfaceTitles, screenSelection, treeToViewModel } from "../src/lib/tree"; +import { + activeScreen, + applySurfaceTitles, + screenSelection, + SurfaceTitleReconciler, + treeToViewModel, +} from "../src/lib/tree"; const tree: Tree = { workspaces: [{ @@ -101,6 +107,29 @@ describe("applySurfaceTitles", () => { : []).toEqual(["editor", "logs"]); expect(applySurfaceTitles(tree, new Map([[99, "missing"]]))).toBe(tree); }); + + it("keeps later title events across overlapping tree refreshes", () => { + const reconciler = new SurfaceTitleReconciler(); + const olderRefresh = reconciler.beginRefresh(); + reconciler.record(4, "newest"); + const newerRefresh = reconciler.beginRefresh(); + + const newerTree = structuredClone(tree); + const committed = reconciler.commit(newerTree, newerRefresh); + expect(committed).toEqual({ tree: newerTree, applied: true }); + + const olderTree = structuredClone(tree); + expect(reconciler.commit(olderTree, olderRefresh)).toEqual({ tree: newerTree, applied: false }); + + const replay = new SurfaceTitleReconciler(); + const inFlight = replay.beginRefresh(); + replay.record(4, "newest"); + const staleTree = structuredClone(tree); + const recovered = replay.commit(staleTree, inFlight).tree; + expect("tabs" in recovered.workspaces[0]!.screens[0]!.panes[0]! + ? recovered.workspaces[0]!.screens[0]!.panes[0]!.tabs[0]!.title + : null).toBe("newest"); + }); }); import { locateSurface } from "../src/lib/tree"; From c89c094e2540f30383fb5092015aa7c805d00178 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:43:15 -0700 Subject: [PATCH 091/103] Queue browser resize and bound replay scans --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 171 +++---------------- cmux-tui/crates/cmux-tui-core/src/lib.rs | 2 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 7 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 5 +- cmux-tui/crates/cmux-tui/src/app.rs | 10 +- cmux-tui/crates/ghostty-vt/src/terminal.rs | 26 ++- 6 files changed, 58 insertions(+), 163 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 5b8120143809..6bb7027d6e0e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Condvar, Mutex, Weak}; +use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, Instant}; use cmux_tui_cdp::{ @@ -149,7 +149,6 @@ enum BrowserCommand { Reconfigure { cols: u16, rows: u16, - completion: Option>, }, #[cfg(test)] Hold { @@ -179,42 +178,6 @@ struct BrowserWorkerErrorState { consecutive_timeouts: u8, } -struct BrowserResizeOperation { - desired: (u16, u16), - result: Mutex>>, - changed: Condvar, - timed_out: AtomicBool, -} - -impl BrowserResizeOperation { - fn new(desired: (u16, u16)) -> Self { - Self { - desired, - result: Mutex::new(None), - changed: Condvar::new(), - timed_out: AtomicBool::new(false), - } - } - - fn wait(&self, timeout: Duration) -> Option> { - let result = self.result.lock().unwrap(); - if result.is_some() { - return result.clone(); - } - let (result, _) = self.changed.wait_timeout(result, timeout).unwrap(); - if result.is_none() { - self.timed_out.store(true, Ordering::Release); - } - result.clone() - } - - fn complete(&self, result: Result) -> bool { - *self.result.lock().unwrap() = Some(result); - self.changed.notify_all(); - self.timed_out.load(Ordering::Acquire) - } -} - pub struct BrowserRuntime { client: CdpClient, chrome: Option, @@ -241,7 +204,6 @@ pub struct BrowserSurface { command_tx: Mutex>>, latest_reconfigure: Arc>>, latest_nav: Arc>>, - resize_operation: Mutex>>, #[cfg(test)] worker_done: Mutex>>, } @@ -259,21 +221,6 @@ const DEFAULT_CAPTURE_MEGAPIXELS: f64 = TRANSPORT_SAFE_CAPTURE_MEGAPIXELS; const STALL_THRESHOLD: Duration = Duration::from_secs(2); const BROWSER_COMMAND_QUEUE_CAPACITY: usize = 64; const BROWSER_NOT_RESPONDING_MESSAGE: &str = "browser is not responding"; -#[cfg(not(test))] -const BROWSER_RESIZE_COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); -#[cfg(test)] -const BROWSER_RESIZE_COMPLETION_TIMEOUT: Duration = Duration::from_millis(100); - -#[derive(Debug)] -pub struct BrowserResizeTimeout; - -impl std::fmt::Display for BrowserResizeTimeout { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("browser resize completion timed out") - } -} - -impl std::error::Error for BrowserResizeTimeout {} impl BrowserRuntime { pub fn connect(opts: &SurfaceOptions) -> anyhow::Result> { @@ -465,7 +412,6 @@ pub(crate) fn new_surface( command_tx: Mutex::new(Some(command_tx)), latest_reconfigure: latest_reconfigure.clone(), latest_nav: latest_nav.clone(), - resize_operation: Mutex::new(None), #[cfg(test)] worker_done: Mutex::new(Some(worker_done_rx)), })); @@ -809,7 +755,7 @@ fn coalesce_worker_mouse_moves(batch: &mut Vec) { fn run_browser_worker_command( surface: &Surface, - mut command: BrowserCommand, + command: BrowserCommand, mux: &Weak, id: SurfaceId, failures: &mut BrowserWorkerErrorState, @@ -817,10 +763,6 @@ fn run_browser_worker_command( let is_input = command.is_input(); let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); let mut resize_changed = false; - let completion = match &mut command { - BrowserCommand::Reconfigure { completion, .. } => completion.take(), - _ => None, - }; let result = { let Some(browser) = surface.as_browser() else { return; @@ -852,7 +794,7 @@ fn run_browser_worker_command( BrowserCommand::Forward => browser.forward_blocking(), BrowserCommand::Reload => browser.reload_blocking(), BrowserCommand::Activate => browser.activate_blocking(), - BrowserCommand::Reconfigure { cols, rows, completion: _ } => { + BrowserCommand::Reconfigure { cols, rows } => { browser.reconfigure_resize_blocking(cols, rows).map(|changed| { resize_changed = changed; }) @@ -864,15 +806,7 @@ fn run_browser_worker_command( } } }; - let mut completion_abandoned = false; - if let Some(completion) = completion { - let outcome = result.as_ref().map(|_| resize_changed).map_err(ToString::to_string); - if let Some(browser) = surface.as_browser() { - completion_abandoned = browser.finish_resize_operation(&completion, outcome); - } - } - if completion_abandoned - && is_reconfigure + if is_reconfigure && resize_changed && result.is_ok() && let Some(mux) = mux.upgrade() @@ -1014,61 +948,13 @@ impl BrowserSurface { self.close_command_sender(); } - pub fn resize(&self, cols: u16, rows: u16) { + pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { let (cols, rows) = (cols.max(1), rows.max(1)); if !self.resize_needed(cols, rows) { - return; - } - if let Err(e) = self.enqueue_latest_reconfigure(BrowserCommand::Reconfigure { - cols, - rows, - completion: None, - }) { - eprintln!("cmux-tui: browser resize failed for surface {}: {e}", self.meta.id); - } - } - - pub(crate) fn resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { - let desired = (cols.max(1), rows.max(1)); - let (operation, enqueue) = { - let mut slot = self.resize_operation.lock().unwrap(); - match slot.as_ref() { - Some(operation) if operation.desired == desired => (operation.clone(), false), - Some(_) => return Err(BrowserResizeTimeout.into()), - None => { - let operation = Arc::new(BrowserResizeOperation::new(desired)); - *slot = Some(operation.clone()); - (operation, true) - } - } - }; - if enqueue - && let Err(error) = self.enqueue_control(BrowserCommand::Reconfigure { - cols: desired.0, - rows: desired.1, - completion: Some(operation.clone()), - }) - { - self.finish_resize_operation(&operation, Err(error.to_string())); - return Err(error); - } - match operation.wait(BROWSER_RESIZE_COMPLETION_TIMEOUT) { - Some(outcome) => outcome.map_err(anyhow::Error::msg), - None => Err(BrowserResizeTimeout.into()), - } - } - - fn finish_resize_operation( - &self, - operation: &Arc, - outcome: Result, - ) -> bool { - let timed_out = operation.complete(outcome); - let mut slot = self.resize_operation.lock().unwrap(); - if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, operation)) { - *slot = None; + return Ok(false); } - timed_out + self.enqueue_latest_reconfigure(BrowserCommand::Reconfigure { cols, rows })?; + Ok(true) } fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { @@ -1091,7 +977,7 @@ impl BrowserSurface { *cell = next; } let (cols, rows) = self.size(); - self.resize(cols, rows); + let _ = self.resize(cols, rows); } fn update_resize_state(&self, cols: u16, rows: u16) -> Option<(u32, u32)> { @@ -1784,9 +1670,9 @@ fn percent_encode_query(input: &str) -> String { #[cfg(test)] mod tests { use super::{ - BrowserCaptureOptions, BrowserCommand, BrowserFrame, BrowserResizeTimeout, BrowserSource, - BrowserStatus, capture_scale_for, new_surface, normalize_url, runtime_endpoint, - scaled_pixels, take_latest_worker_commands, + BrowserCaptureOptions, BrowserCommand, BrowserFrame, BrowserSource, BrowserStatus, + capture_scale_for, new_surface, normalize_url, runtime_endpoint, scaled_pixels, + take_latest_worker_commands, }; use crate::{Mux, Surface, SurfaceOptions}; use serde_json::{Value, json}; @@ -2156,18 +2042,15 @@ mod tests { #[test] fn latest_reconfigure_and_nav_slots_do_not_clobber_each_other() { - let latest_reconfigure = Arc::new(Mutex::new(Some(BrowserCommand::Reconfigure { - cols: 111, - rows: 222, - completion: None, - }))); + let latest_reconfigure = + Arc::new(Mutex::new(Some(BrowserCommand::Reconfigure { cols: 111, rows: 222 }))); let latest_nav = Arc::new(Mutex::new(Some(BrowserCommand::Navigate("https://next.test".to_string())))); let commands = take_latest_worker_commands(&latest_reconfigure, &latest_nav); assert_eq!(commands.len(), 2); match &commands[0] { - BrowserCommand::Reconfigure { cols, rows, completion: None } => { + BrowserCommand::Reconfigure { cols, rows } => { assert_eq!((*cols, *rows), (111, 222)); } _ => panic!("reconfigure must drain before nav"), @@ -2192,7 +2075,7 @@ mod tests { } #[test] - fn browser_resize_completion_wait_is_bounded() { + fn browser_resize_is_nonblocking_and_latest_wins() { let surface = test_surface(); let browser = surface.as_browser().expect("browser surface"); let done = browser.take_worker_done_for_test(); @@ -2205,14 +2088,12 @@ mod tests { .unwrap(); started.recv_timeout(Duration::from_secs(1)).unwrap(); - let error = browser.resize_blocking(11, 5).unwrap_err(); - assert!(error.downcast_ref::().is_some()); - let first_operation = browser.resize_operation.lock().unwrap().clone().unwrap(); - - let error = browser.resize_blocking(11, 5).unwrap_err(); - assert!(error.downcast_ref::().is_some()); - let joined_operation = browser.resize_operation.lock().unwrap().clone().unwrap(); - assert!(Arc::ptr_eq(&first_operation, &joined_operation)); + assert!(browser.resize(11, 5).unwrap()); + assert!(browser.resize(12, 6).unwrap()); + assert!(matches!( + browser.latest_reconfigure.lock().unwrap().as_ref(), + Some(BrowserCommand::Reconfigure { cols: 12, rows: 6 }) + )); release.send(()).unwrap(); browser.kill(); @@ -2497,7 +2378,7 @@ mod tests { } assert!(browser.frames_stalled_at(now)); - browser.resize_blocking(10, 5).unwrap(); + browser.reconfigure_resize_blocking(10, 5).unwrap(); { let state = browser.state.lock().unwrap(); assert_eq!(state.last_frame_at, Some(now - Duration::from_secs(3))); @@ -2505,7 +2386,7 @@ mod tests { } assert!(browser.frames_stalled_at(now)); - browser.resize_blocking(11, 5).unwrap(); + browser.reconfigure_resize_blocking(11, 5).unwrap(); let state = browser.state.lock().unwrap(); assert_eq!(state.last_frame_at, None); assert!(!state.stall_nudged); @@ -2535,9 +2416,9 @@ mod tests { let _ = browser.update_resize_state(10, 5).expect("changed geometry"); assert!(browser.resize_needed(10, 5)); - assert!(browser.resize_blocking(10, 5).unwrap()); + assert!(browser.reconfigure_resize_blocking(10, 5).unwrap()); assert!(!browser.resize_needed(10, 5)); - assert!(!browser.resize_blocking(10, 5).unwrap()); + assert!(!browser.reconfigure_resize_blocking(10, 5).unwrap()); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 3cd4f931fed6..f9c7988d1413 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -19,7 +19,7 @@ pub mod layout; pub mod platform; pub mod server; -pub use browser::{BrowserResizeTimeout, TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; +pub use browser::{TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; pub use event_bus::{MuxEventBroadcaster, MuxEventReceiver}; pub use layout::{ LayoutResult, Rect, SplitEdge, SplitResize, directional_neighbor, layout_screen, diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 23ad2d4f6ba0..d4da4ffd484b 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -756,8 +756,8 @@ impl Mux { } } - /// Resize a surface and broadcast the final clamped size when it - /// actually changes. + /// Resize a surface and broadcast the final clamped size when it actually + /// changes. Browser workers broadcast after their asynchronous CDP work. pub fn resize_surface(&self, id: SurfaceId, cols: u16, rows: u16) -> anyhow::Result { let Some(surface) = self.surface(id) else { anyhow::bail!("unknown surface {id}"); @@ -770,6 +770,9 @@ impl Mux { if !surface.resize(cols, rows)? { return Ok(false); } + if surface.as_browser().is_some() { + return Ok(true); + } let (cols, rows) = surface.size(); self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); Ok(true) diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index e31b12961eb6..2f7d09096367 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -690,11 +690,12 @@ impl Surface { /// Resize this surface. PTYs receive cell dimensions; browsers also /// use the last configured cell pixel size for CDP device metrics. - /// Returns whether the final clamped size actually changed. + /// Returns whether a clamped size change was applied or accepted. Browser + /// reconfiguration completes on its worker and emits the final size there. pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { match self { Surface::Pty(pty) => Ok(pty.resize(cols, rows)), - Surface::Browser(browser) => browser.resize_blocking(cols, rows), + Surface::Browser(browser) => browser.resize(cols, rows), } } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 428cd5eeb418..c1e3f240cee7 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -16,9 +16,8 @@ use std::time::{Duration, Instant}; use base64::Engine; use cmux_tui_core::{ - BrowserResizeTimeout, BrowserSource, BrowserStatus, MuxEvent, PaneId, Rect, SplitDir, - SplitEdge, SurfaceId, SurfaceKind, WorkspaceId, layout_screen, split_for_pane_edge, - split_sides, + BrowserSource, BrowserStatus, MuxEvent, PaneId, Rect, SplitDir, SplitEdge, SurfaceId, + SurfaceKind, WorkspaceId, layout_screen, split_for_pane_edge, split_sides, }; use crossterm::ExecutableCommand; use crossterm::event::{ @@ -1073,9 +1072,8 @@ impl OrderedSession { Ok(()) } Err(error) => { - let transient = is_remote_timeout(&error) - || is_remote_transport_failure(&error) - || error.downcast_ref::().is_some(); + let transient = + is_remote_timeout(&error) || is_remote_transport_failure(&error); let mut failures = failures.lock().unwrap(); let previous = failures.get(&surface_id).map(|failure| failure.state); let state = next_surface_sync_failure(previous, transient, false); diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index d7e19388d875..b0ba54b67f73 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -725,17 +725,14 @@ impl Terminal { /// VT replay bounded to `max_bytes`, retaining the newest complete rows. /// - /// The formatter first measures the complete replay without allocating its - /// output, preserving full history when it fits. Oversized history is then - /// reduced to a recent row window derived from the budget and reduced - /// further until the active screen fits. + /// Formatting begins with a recent row window derived from the budget. A + /// fitting window grows geometrically up to the complete history, while an + /// oversized window shrinks until the active screen fits. This preserves + /// full history when it fits without first scanning unbounded scrollback. /// A pathological screen whose newest row alone exceeds the budget falls /// back to a terminal reset so callers can still attach and receive live /// output instead of entering a permanent overflow loop. pub fn vt_replay_bounded(&mut self, max_bytes: usize) -> Result> { - if let Some(replay) = self.vt_replay_with_selection_bounded(None, max_bytes)? { - return Ok(replay); - } let Some(scrollbar) = self.scrollbar() else { return Ok(minimal_vt_replay(max_bytes)); }; @@ -746,9 +743,24 @@ impl Terminal { let screen_rows = scrollbar.len.min(scrollbar.total).max(1); let mut tail_rows = vt_replay_row_window(scrollbar.total, screen_rows, self.cols(), max_bytes); + let mut best = None; + let mut upper_failed = false; loop { if let Some(replay) = self.vt_replay_screen_tail_bounded(tail_rows, max_bytes)? { + if upper_failed || tail_rows == scrollbar.total { + return Ok(replay); + } + best = Some(replay); + let next = tail_rows.saturating_mul(2).min(scrollbar.total); + if next == tail_rows { + break; + } + tail_rows = next; + continue; + } + upper_failed = true; + if let Some(replay) = best { return Ok(replay); } if tail_rows <= 1 { From 98d5c4fbdf302f2e5a4090315f766a1bfcdc8fb5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:07:11 -0700 Subject: [PATCH 092/103] Bound asynchronous resize recovery --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 165 +++++++++++++++--- cmux-tui/crates/cmux-tui-core/src/mux.rs | 8 + cmux-tui/crates/cmux-tui-core/src/server.rs | 12 +- cmux-tui/crates/cmux-tui/src/app.rs | 76 +++++++- cmux-tui/crates/cmux-tui/src/session/mod.rs | 26 ++- .../crates/cmux-tui/src/session/remote.rs | 70 ++++++++ 6 files changed, 321 insertions(+), 36 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 6bb7027d6e0e..1a81eaa5f53e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -107,7 +107,8 @@ struct BrowserState { pane_pixels: (u32, u32), capture_pixels: (u32, u32), capture_scale: f64, - pending_reconfigure: Option<(u32, u32)>, + pending_reconfigure: Option, + reconfigure_failure: Option, page_viewport: Option<(u32, u32)>, status: BrowserStatus, source: Option, @@ -118,6 +119,21 @@ struct BrowserState { not_responding_reported: bool, } +#[derive(Clone, Copy, PartialEq)] +struct BrowserGeometry { + size: (u16, u16), + pane_pixels: (u32, u32), + capture_pixels: (u32, u32), + capture_scale: f64, +} + +#[derive(Clone, Copy)] +struct BrowserReconfigureFailure { + geometry: BrowserGeometry, + attempts: u8, + retry_at: Option, +} + enum BrowserCommand { WakeLatest, Mouse { @@ -221,6 +237,8 @@ const DEFAULT_CAPTURE_MEGAPIXELS: f64 = TRANSPORT_SAFE_CAPTURE_MEGAPIXELS; const STALL_THRESHOLD: Duration = Duration::from_secs(2); const BROWSER_COMMAND_QUEUE_CAPACITY: usize = 64; const BROWSER_NOT_RESPONDING_MESSAGE: &str = "browser is not responding"; +const BROWSER_RECONFIGURE_RETRY_DELAYS: [Duration; 2] = + [Duration::from_millis(250), Duration::from_millis(500)]; impl BrowserRuntime { pub fn connect(opts: &SurfaceOptions) -> anyhow::Result> { @@ -396,6 +414,7 @@ pub(crate) fn new_surface( capture_pixels, capture_scale, pending_reconfigure: None, + reconfigure_failure: None, page_viewport: None, status: BrowserStatus::Starting, source: None, @@ -443,6 +462,15 @@ impl BrowserCaptureOptions { } } +fn browser_geometry_locked(state: &BrowserState) -> BrowserGeometry { + BrowserGeometry { + size: state.size, + pane_pixels: state.pane_pixels, + capture_pixels: state.capture_pixels, + capture_scale: state.capture_scale, + } +} + fn capture_scale_for(pane_px_w: u32, pane_px_h: u32, opts: BrowserCaptureOptions) -> f64 { let area = f64::from(pane_px_w.max(1)) * f64::from(pane_px_h.max(1)); let budget = opts.max_capture_megapixels.max(f64::MIN_POSITIVE) * 1_000_000.0; @@ -762,6 +790,10 @@ fn run_browser_worker_command( ) { let is_input = command.is_input(); let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); + let reconfigure_size = match &command { + BrowserCommand::Reconfigure { cols, rows } => Some((*cols, *rows)), + _ => None, + }; let mut resize_changed = false; let result = { let Some(browser) = surface.as_browser() else { @@ -814,6 +846,20 @@ fn run_browser_worker_command( let (cols, rows) = surface.size(); mux.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); } + if let Some((cols, rows)) = reconfigure_size + && let Err(error) = &result + && let Some(browser) = surface.as_browser() + && let Some((_, retry_delay)) = browser.fail_reconfigure((cols, rows)) + && let Some(mux) = mux.upgrade() + { + mux.emit(MuxEvent::SurfaceResizeFailed { + surface: id, + cols, + rows, + error: Arc::::from(error.to_string()), + retry_after_ms: retry_delay.map(|delay| delay.as_millis() as u64), + }); + } record_browser_worker_result(surface, mux, id, is_input, result, failures); } @@ -981,39 +1027,57 @@ impl BrowserSurface { } fn update_resize_state(&self, cols: u16, rows: u16) -> Option<(u32, u32)> { - let (cols, rows) = (cols.max(1), rows.max(1)); - let cell = *self.cell_pixels.lock().unwrap(); - let pixel_w = cols as u32 * cell.0.max(1) as u32; - let pixel_h = rows as u32 * cell.1.max(1) as u32; + let geometry = self.resize_geometry(cols, rows); let mut state = self.state.lock().unwrap(); - let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); - let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); - let unchanged = state.size == (cols, rows) - && state.pane_pixels == (pixel_w, pixel_h) - && state.capture_pixels == capture_pixels; - state.size = (cols, rows); - state.pane_pixels = (pixel_w, pixel_h); - state.capture_pixels = capture_pixels; - state.capture_scale = capture_scale; - if !unchanged { - state.live_since = Some(Instant::now()); - state.last_frame_at = None; - state.stall_nudged = false; + let unchanged = browser_geometry_locked(&state) == geometry; + if unchanged && state.pending_reconfigure.is_none() { + return None; } - if unchanged { - return state.pending_reconfigure; + if state.reconfigure_failure.is_some_and(|failure| failure.geometry != geometry) { + state.reconfigure_failure = None; } - state.pending_reconfigure = Some(capture_pixels); - Some(capture_pixels) + state.pending_reconfigure = Some(geometry); + Some(geometry.capture_pixels) } fn confirm_reconfigure(&self, width: u32, height: u32) { let mut state = self.state.lock().unwrap(); - if state.pending_reconfigure == Some((width, height)) { - state.pending_reconfigure = None; + let Some(geometry) = + state.pending_reconfigure.filter(|geometry| geometry.capture_pixels == (width, height)) + else { + return; + }; + let changed = browser_geometry_locked(&state) != geometry; + state.pending_reconfigure = None; + state.reconfigure_failure = None; + state.size = geometry.size; + state.pane_pixels = geometry.pane_pixels; + state.capture_pixels = geometry.capture_pixels; + state.capture_scale = geometry.capture_scale; + if changed { + state.live_since = Some(Instant::now()); + state.last_frame_at = None; + state.stall_nudged = false; } } + fn fail_reconfigure(&self, desired: (u16, u16)) -> Option<(u8, Option)> { + let mut state = self.state.lock().unwrap(); + let geometry = state.pending_reconfigure.filter(|geometry| geometry.size == desired)?; + state.pending_reconfigure = None; + let attempts = state + .reconfigure_failure + .filter(|failure| failure.geometry == geometry) + .map_or(1, |failure| failure.attempts.saturating_add(1)); + let retry_delay = BROWSER_RECONFIGURE_RETRY_DELAYS.get(usize::from(attempts - 1)).copied(); + state.reconfigure_failure = Some(BrowserReconfigureFailure { + geometry, + attempts, + retry_at: retry_delay.map(|delay| Instant::now() + delay), + }); + Some((attempts, retry_delay)) + } + fn reconfigure_blocking(&self, width: u32, height: u32) -> anyhow::Result<()> { let Some(session) = self.live_session()? else { return Ok(()) }; session.runtime.client.set_device_metrics(&session.session_id, width, height)?; @@ -1023,17 +1087,36 @@ impl BrowserSurface { } pub(crate) fn resize_needed(&self, cols: u16, rows: u16) -> bool { + let geometry = self.resize_geometry(cols, rows); + let mut state = self.state.lock().unwrap(); + if state.reconfigure_failure.is_some_and(|failure| failure.geometry != geometry) { + state.reconfigure_failure = None; + } + if state.pending_reconfigure == Some(geometry) { + return false; + } + if let Some(failure) = state.reconfigure_failure + && failure.geometry == geometry + && failure.retry_at.is_none_or(|retry_at| Instant::now() < retry_at) + { + return false; + } + browser_geometry_locked(&state) != geometry || state.pending_reconfigure.is_some() + } + + fn resize_geometry(&self, cols: u16, rows: u16) -> BrowserGeometry { let (cols, rows) = (cols.max(1), rows.max(1)); let cell = *self.cell_pixels.lock().unwrap(); let pixel_w = cols as u32 * cell.0.max(1) as u32; let pixel_h = rows as u32 * cell.1.max(1) as u32; let capture_scale = capture_scale_for(pixel_w, pixel_h, self.capture_options); let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); - let state = self.state.lock().unwrap(); - state.pending_reconfigure.is_some() - || state.size != (cols, rows) - || state.pane_pixels != (pixel_w, pixel_h) - || state.capture_pixels != capture_pixels + BrowserGeometry { + size: (cols, rows), + pane_pixels: (pixel_w, pixel_h), + capture_pixels, + capture_scale, + } } pub fn attach_frames(&self) -> (BrowserAttachState, BrowserFrameStream) { @@ -2421,6 +2504,30 @@ mod tests { assert!(!browser.reconfigure_resize_blocking(10, 5).unwrap()); } + #[test] + fn browser_resize_failure_retries_are_bounded_and_new_sizes_cancel_the_latch() { + let surface = test_surface(); + let browser = surface.as_browser().expect("browser surface"); + + for attempt in 1..=3 { + browser.update_resize_state(11, 5).expect("resize must enter pending state"); + let (recorded_attempt, retry_delay) = + browser.fail_reconfigure((11, 5)).expect("pending resize failure must be recorded"); + assert_eq!(recorded_attempt, attempt); + assert_eq!(retry_delay.is_some(), attempt < 3); + assert!(!browser.resize_needed(11, 5)); + if attempt < 3 { + browser.state.lock().unwrap().reconfigure_failure.as_mut().unwrap().retry_at = + Some(Instant::now() - Duration::from_millis(1)); + assert!(browser.resize_needed(11, 5)); + } + } + + assert!(!browser.resize_needed(11, 5)); + assert!(browser.resize_needed(12, 5)); + assert!(browser.resize_needed(11, 5)); + } + #[test] fn attach_frames_are_latest_wins_and_close_detaches() { let surface = test_surface(); diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index d4da4ffd484b..05978c0fb789 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -24,6 +24,14 @@ pub enum MuxEvent { cols: u16, rows: u16, }, + /// An asynchronous browser resize failed after queue acceptance. + SurfaceResizeFailed { + surface: SurfaceId, + cols: u16, + rows: u16, + error: Arc, + retry_after_ms: Option, + }, /// A surface's child exited. The mux has already reaped it from the /// tree (a tree-changed follows) by the time this arrives. SurfaceExited(SurfaceId), diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 6d287be842b6..08312fafedc5 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -2331,10 +2331,10 @@ fn handle_command( Ok(json!({})) } Command::ResizeSurface { surface, cols, rows } => { - mux.resize_surface(surface, cols, rows)?; + let accepted = mux.resize_surface(surface, cols, rows)?; mux.record_client_size(cols, rows); mux.control_clients.record_size(client, surface, cols.max(1), rows.max(1)); - Ok(json!({})) + Ok(json!({"accepted": accepted})) } Command::FocusPane { pane } => { if !mux.focus_pane(pane) { @@ -2548,6 +2548,14 @@ fn subscribed_event_json(event: &MuxEvent) -> Value { "cols": cols, "rows": rows, }), + MuxEvent::SurfaceResizeFailed { surface, cols, rows, error, retry_after_ms } => json!({ + "event": "surface-resize-failed", + "surface": surface, + "cols": cols, + "rows": rows, + "error": error.as_ref(), + "retry_after_ms": retry_after_ms, + }), MuxEvent::SurfaceExited(id) => json!({"event": "surface-exited", "surface": id}), MuxEvent::TitleChanged { surface, title } => { json!({"event": "title-changed", "surface": surface, "title": title.as_ref()}) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index c1e3f240cee7..61dc92e92cb9 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -45,7 +45,7 @@ use crate::pty_input::{ PtyInputSender, PtyOperationDelivery, PtyOperationFailure, }; use crate::session::{ - Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_timeout, + Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_retryable, is_remote_timeout, is_remote_transport_failure, }; use crate::sidebar_files::{FileBrowser, FileCommand, file_url, shell_single_quote}; @@ -153,6 +153,9 @@ fn forward_mux_events( return; } if !recovery_succeeded { + if mux_titles.rearm_wake() && tx.send(AppEvent::MuxTitlesReady).is_err() { + return; + } continue; } let mut app_channel_full = false; @@ -269,6 +272,17 @@ impl MuxTitleIngress { self.state.lock().unwrap().epoch } + fn rearm_wake(&self) -> bool { + let mut state = self.state.lock().unwrap(); + state.wake_queued = false; + if state.dirty.is_empty() { + false + } else { + state.wake_queued = true; + true + } + } + fn reconcile_authoritative(&self, through_epoch: u64) { let mut state = self.state.lock().unwrap(); state.titles.retain(|_, retained| retained.epoch > through_epoch); @@ -1072,8 +1086,9 @@ impl OrderedSession { Ok(()) } Err(error) => { - let transient = - is_remote_timeout(&error) || is_remote_transport_failure(&error); + let transient = is_remote_timeout(&error) + || is_remote_transport_failure(&error) + || is_remote_retryable(&error); let mut failures = failures.lock().unwrap(); let previous = failures.get(&surface_id).map(|failure| failure.state); let state = next_surface_sync_failure(previous, transient, false); @@ -1102,6 +1117,32 @@ impl OrderedSession { } } + fn confirm_surface_resize(&self, surface: SurfaceId, size: (u16, u16)) { + let mut failures = self.surface_resize_failures.lock().unwrap(); + if failures.get(&surface).is_some_and(|failure| failure.desired == size) { + failures.remove(&surface); + } + } + + fn note_surface_resize_failure( + &self, + surface: SurfaceId, + desired: (u16, u16), + retry_after_ms: Option, + ) { + let mut failures = self.surface_resize_failures.lock().unwrap(); + let previous = failures + .get(&surface) + .filter(|failure| failure.desired == desired) + .map(|failure| failure.state); + let mut state = next_surface_sync_failure(previous, true, retry_after_ms.is_none()); + if let Some(delay) = retry_after_ms { + state.retry_after = Some(Instant::now() + Duration::from_millis(delay)); + state.sticky_until_reconnect = false; + } + failures.insert(surface, SurfaceResizeFailure { desired, state }); + } + fn surface_resize_decision( &self, surface_id: SurfaceId, @@ -3051,6 +3092,23 @@ impl App { self.remove_surface_from_tree(id); Ok(RenderAction::Draw) } + AppEvent::Mux(MuxEvent::SurfaceResized { surface, cols, rows }) => { + self.session.confirm_surface_resize(surface, (cols, rows)); + Ok(RenderAction::Draw) + } + AppEvent::Mux(MuxEvent::SurfaceResizeFailed { + surface, + cols, + rows, + error, + retry_after_ms, + }) => { + self.session.note_surface_resize_failure(surface, (cols, rows), retry_after_ms); + self.status_message = Some(format!( + "browser surface {surface} resize to {cols}x{rows} failed: {error}" + )); + Ok(RenderAction::Draw) + } AppEvent::Mux(MuxEvent::Status(message)) => { self.status_message = Some(message); Ok(RenderAction::Draw) @@ -7164,6 +7222,18 @@ mod tests { assert_eq!(retained.get(&42).map(AsRef::as_ref), Some("concurrent-title")); } + #[test] + fn title_ingress_rearms_a_lost_wake_after_failed_recovery() { + let titles = MuxTitleIngress::default(); + assert!(titles.push(41, "first")); + assert!(!titles.push(41, "latest")); + + assert!(titles.rearm_wake()); + assert_eq!(titles.take_dirty().get(&41).map(AsRef::as_ref), Some("latest")); + assert!(!titles.rearm_wake()); + assert!(titles.push(41, "next")); + } + #[test] fn mux_forwarder_recovers_after_bounded_mailbox_overflow() { let mux = Mux::new("mux-forwarder-overflow-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index f4af1be873a4..925beb704db2 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -41,6 +41,12 @@ pub(crate) fn is_remote_timeout(error: &anyhow::Error) -> bool { .is_some_and(remote::RemoteRequestError::is_timeout) } +pub(crate) fn is_remote_retryable(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(remote::RemoteRequestError::is_retryable) +} + #[cfg(test)] pub(crate) fn test_remote_timeout_error() -> anyhow::Error { remote::RemoteRequestError::Timeout.into() @@ -702,12 +708,20 @@ impl SurfaceHandle { } SurfaceHandle::Remote(surface, session) => { if resize_action(desired, surface.asserted_size(), surface.server_size(), false) { - session.request(json!({ + let response = session.request(json!({ "cmd": "resize-surface", "surface": surface.id, "cols": desired.0, "rows": desired.1, }))?; + if response.get("accepted").and_then(serde_json::Value::as_bool) == Some(false) + && surface.server_size() != desired + { + return Err(remote::RemoteRequestError::Retryable( + "browser resize is waiting for retry".to_string(), + ) + .into()); + } surface.set_asserted_size(desired); } Ok(()) @@ -741,12 +755,20 @@ impl SurfaceHandle { } SurfaceHandle::Remote(surface, session) => { if resize_action(desired, surface.asserted_size(), surface.server_size(), true) { - session.request(json!({ + let response = session.request(json!({ "cmd": "resize-surface", "surface": surface.id, "cols": desired.0, "rows": desired.1, }))?; + if response.get("accepted").and_then(serde_json::Value::as_bool) == Some(false) + && surface.server_size() != desired + { + return Err(remote::RemoteRequestError::Retryable( + "browser resize is waiting for retry".to_string(), + ) + .into()); + } } surface.set_asserted_size(desired); Ok(()) diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index ac3d48b63718..aae2a9ed7ca9 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -36,6 +36,7 @@ pub(crate) enum RemoteRequestError { Encode(serde_json::Error), Transport(std::io::Error), Timeout, + Retryable(String), Rejected(String), Shutdown, } @@ -48,6 +49,10 @@ impl RemoteRequestError { pub(crate) fn is_timeout(&self) -> bool { matches!(self, Self::Timeout) } + + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::Retryable(_)) + } } impl std::fmt::Display for RemoteRequestError { @@ -56,6 +61,7 @@ impl std::fmt::Display for RemoteRequestError { Self::Encode(error) => write!(formatter, "could not encode remote request: {error}"), Self::Transport(error) => write!(formatter, "remote transport write failed: {error}"), Self::Timeout => write!(formatter, "remote session did not respond"), + Self::Retryable(error) => write!(formatter, "{error}"), Self::Rejected(error) => write!(formatter, "remote command rejected: {error}"), Self::Shutdown => write!(formatter, "remote response wait canceled for shutdown"), } @@ -229,6 +235,13 @@ impl RemoteSurface { *self.asserted_size.lock().unwrap() = Some(size); } + pub(super) fn clear_asserted_size(&self, size: (u16, u16)) { + let mut asserted = self.asserted_size.lock().unwrap(); + if *asserted == Some(size) { + *asserted = None; + } + } + pub fn browser_frame(&self) -> Option { let browser = self.browser.lock().unwrap(); if matches!(browser.status, BrowserStatus::Failed(_)) { @@ -438,6 +451,24 @@ impl RemoteSession { } self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); } + Some("surface-resize-failed") => { + let Some(id) = surface_id() else { return }; + let cols = value.get("cols").and_then(|v| v.as_u64()).unwrap_or(80) as u16; + let rows = value.get("rows").and_then(|v| v.as_u64()).unwrap_or(24) as u16; + let error = + value.get("error").and_then(Value::as_str).unwrap_or("browser resize failed"); + let retry_after_ms = value.get("retry_after_ms").and_then(Value::as_u64); + if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { + surface.clear_asserted_size((cols.max(1), rows.max(1))); + } + self.emit(MuxEvent::SurfaceResizeFailed { + surface: id, + cols, + rows, + error: Arc::::from(error), + retry_after_ms, + }); + } Some("output") => { let Some(id) = surface_id() else { return }; let Some(data) = value.get("data").and_then(|v| v.as_str()) else { return }; @@ -1445,6 +1476,45 @@ mod tests { ))); } + #[cfg(unix)] + #[test] + fn surface_resize_failure_releases_remote_browser_assertion() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + let surface = Arc::new(RemoteSurface { + id: 7, + kind: SurfaceKind::Browser, + term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), + dirty: AtomicBool::new(false), + server_size: Mutex::new((12, 4)), + asserted_size: Mutex::new(Some((90, 31))), + browser: Mutex::new(RemoteBrowserState::default()), + }); + session.surfaces.lock().unwrap().insert(7, surface.clone()); + + session.handle_line(json!({ + "event": "surface-resize-failed", + "surface": 7, + "cols": 90, + "rows": 31, + "error": "device metrics rejected", + "retry_after_ms": 250, + })); + + assert_eq!(surface.asserted_size(), None); + assert!(events.try_iter().any(|event| matches!( + event, + MuxEvent::SurfaceResizeFailed { + surface: 7, + cols: 90, + rows: 31, + retry_after_ms: Some(250), + .. + } + ))); + } + #[cfg(unix)] #[test] fn notification_event_preserves_payload_without_invalidating_tree() { From 44c1e8e526a07959d5cca459c8421a6424e6b2b7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:34:48 -0700 Subject: [PATCH 093/103] Complete browser resize recovery contract --- cmux-tui/bindings/conformance/fixtures.json | 2 +- cmux-tui/bindings/go/client.go | 6 ++- cmux-tui/bindings/go/cmd/e2e/main.go | 4 +- cmux-tui/bindings/go/events.go | 5 ++ cmux-tui/bindings/go/events_test.go | 14 ++++++ cmux-tui/bindings/go/types.go | 14 ++++++ .../java/src/com/cmux/CmuxClient.java | 4 +- .../bindings/java/src/com/cmux/CmuxEvent.java | 9 +++- .../src/com/cmux/ResizeSurfaceResult.java | 3 ++ .../com/cmux/SurfaceResizeFailedEvent.java | 8 +++ .../java/tests/com/cmux/JsonTest.java | 7 +++ cmux-tui/bindings/python/cmux/__init__.py | 2 + cmux-tui/bindings/python/cmux/client.py | 13 +++-- cmux-tui/bindings/python/tests/test_events.py | 15 ++++++ cmux-tui/bindings/rust/src/lib.rs | 50 ++++++++++++++++++- cmux-tui/bindings/typescript/src/client.ts | 3 +- .../typescript/src/protocol/commands.ts | 3 +- .../typescript/src/protocol/events.ts | 9 ++++ .../typescript/test/protocol-types.ts | 10 ++++ cmux-tui/crates/cmux-tui-core/src/browser.rs | 6 +-- cmux-tui/crates/cmux-tui/src/app.rs | 23 +++++++++ cmux-tui/docs/protocol.md | 5 +- cmux-tui/spec/commands.md | 8 +-- cmux-tui/spec/events.md | 24 ++++++++- 24 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java diff --git a/cmux-tui/bindings/conformance/fixtures.json b/cmux-tui/bindings/conformance/fixtures.json index e9ae4a76dc21..bb323f849ffe 100644 --- a/cmux-tui/bindings/conformance/fixtures.json +++ b/cmux-tui/bindings/conformance/fixtures.json @@ -249,7 +249,7 @@ { "type": "command", "request": { "id": 2, "cmd": "resize-surface", "surface": "$surface0", "cols": 100, "rows": 31 }, - "expect": { "id": 2, "ok": true }, + "expect": { "id": 2, "ok": true, "data": { "accepted": true } }, "match": "partial" }, { diff --git a/cmux-tui/bindings/go/client.go b/cmux-tui/bindings/go/client.go index 2e370ea66cde..826a7acac1d6 100644 --- a/cmux-tui/bindings/go/client.go +++ b/cmux-tui/bindings/go/client.go @@ -308,8 +308,10 @@ func (c *Client) RenameWorkspace(ctx context.Context, workspace uint64, name str return c.request(ctx, "rename-workspace", map[string]any{"workspace": workspace, "name": name}, nil) } -func (c *Client) ResizeSurface(ctx context.Context, surface uint64, cols, rows uint16) error { - return c.request(ctx, "resize-surface", map[string]any{"surface": surface, "cols": cols, "rows": rows}, nil) +func (c *Client) ResizeSurface(ctx context.Context, surface uint64, cols, rows uint16) (ResizeSurfaceResult, error) { + var result ResizeSurfaceResult + err := c.request(ctx, "resize-surface", map[string]any{"surface": surface, "cols": cols, "rows": rows}, &result) + return result, err } func (c *Client) FocusPane(ctx context.Context, pane uint64) error { diff --git a/cmux-tui/bindings/go/cmd/e2e/main.go b/cmux-tui/bindings/go/cmd/e2e/main.go index 4485c134efbb..9fcb65a89493 100644 --- a/cmux-tui/bindings/go/cmd/e2e/main.go +++ b/cmux-tui/bindings/go/cmd/e2e/main.go @@ -79,7 +79,7 @@ func run() error { return err } defer events.Close() - if err := client.ResizeSurface(ctx, created.Surface, 100, 31); err != nil { + if _, err := client.ResizeSurface(ctx, created.Surface, 100, 31); err != nil { return err } resized, err := nextResized(events, created.Surface, time.Second) @@ -89,7 +89,7 @@ func run() error { if resized.Cols != 100 || resized.Rows != 31 { return fmt.Errorf("bad resize event: %+v", resized) } - if err := client.ResizeSurface(ctx, created.Surface, 100, 31); err != nil { + if _, err := client.ResizeSurface(ctx, created.Surface, 100, 31); err != nil { return err } if _, err := nextResized(events, created.Surface, 500*time.Millisecond); !errors.Is(err, cmux.ErrTimeout) { diff --git a/cmux-tui/bindings/go/events.go b/cmux-tui/bindings/go/events.go index cc376b4beba1..e8f14a2e314a 100644 --- a/cmux-tui/bindings/go/events.go +++ b/cmux-tui/bindings/go/events.go @@ -36,6 +36,11 @@ func parseEvent(raw map[string]any) Event { if mustDecode(&event) { return event } + case "surface-resize-failed": + var event SurfaceResizeFailedEvent + if mustDecode(&event) { + return event + } case "vt-state": var event VtStateEvent if mustDecode(&event) { diff --git a/cmux-tui/bindings/go/events_test.go b/cmux-tui/bindings/go/events_test.go index af3036b05346..66579a0a69ec 100644 --- a/cmux-tui/bindings/go/events_test.go +++ b/cmux-tui/bindings/go/events_test.go @@ -41,6 +41,20 @@ func TestParseResizedAcceptsProtocolV6DataField(t *testing.T) { } } +func TestParseSurfaceResizeFailedExposesRetrySchedule(t *testing.T) { + event, ok := parseEvent(map[string]any{ + "event": "surface-resize-failed", + "surface": float64(7), + "cols": float64(120), + "rows": float64(40), + "error": "browser is not responding", + "retry_after_ms": float64(250), + }).(SurfaceResizeFailedEvent) + if !ok || event.RetryAfterMS == nil || *event.RetryAfterMS != 250 { + t.Fatalf("surface resize failure = %#v", event) + } +} + func TestParseOverflowExposesRecoveryFields(t *testing.T) { event, ok := parseEvent(map[string]any{ "event": "overflow", diff --git a/cmux-tui/bindings/go/types.go b/cmux-tui/bindings/go/types.go index 493a231c655c..2704cca8a25b 100644 --- a/cmux-tui/bindings/go/types.go +++ b/cmux-tui/bindings/go/types.go @@ -22,6 +22,10 @@ type VtStateResult struct { Data string `json:"data"` } +type ResizeSurfaceResult struct { + Accepted bool `json:"accepted"` +} + type Tree struct { Workspaces []Workspace `json:"workspaces"` } @@ -154,6 +158,16 @@ type SurfaceResizedEvent struct { func (SurfaceResizedEvent) EventName() string { return "surface-resized" } +type SurfaceResizeFailedEvent struct { + Surface uint64 `json:"surface"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + Error string `json:"error"` + RetryAfterMS *uint64 `json:"retry_after_ms"` +} + +func (SurfaceResizeFailedEvent) EventName() string { return "surface-resize-failed" } + type VtStateEvent struct { Surface uint64 `json:"surface"` Cols uint16 `json:"cols"` diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java index b6f8b6ae5912..ac86340127ff 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java @@ -220,11 +220,11 @@ public void renameWorkspace(long workspace, String name) throws CmuxException { request("rename-workspace", params); } - public void resizeSurface(long surface, int cols, int rows) throws CmuxException { + public ResizeSurfaceResult resizeSurface(long surface, int cols, int rows) throws CmuxException { Map params = surfaceParams(surface); params.put("cols", cols); params.put("rows", rows); - request("resize-surface", params); + return new ResizeSurfaceResult(Boolean.TRUE.equals(request("resize-surface", params).get("accepted"))); } public void closeWorkspace(long workspace) throws CmuxException { diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java index 850e62e3955e..4769e6407df9 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java @@ -2,7 +2,7 @@ import java.util.Map; -public sealed interface CmuxEvent permits TreeChangedEvent, EmptyEvent, SurfaceEvent, TitleChangedEvent, SurfaceResizedEvent, VtStateEvent, OutputEvent, ResizedEvent, OverflowEvent, UnknownEvent { +public sealed interface CmuxEvent permits TreeChangedEvent, EmptyEvent, SurfaceEvent, TitleChangedEvent, SurfaceResizedEvent, SurfaceResizeFailedEvent, VtStateEvent, OutputEvent, ResizedEvent, OverflowEvent, UnknownEvent { String event(); static CmuxEvent from(Map raw) { @@ -26,6 +26,13 @@ static CmuxEvent from(Map raw) { (int) CmuxClient.asLong(raw.get("cols")), (int) CmuxClient.asLong(raw.get("rows")) ); + case "surface-resize-failed" -> new SurfaceResizeFailedEvent( + CmuxClient.asLong(raw.get("surface")), + (int) CmuxClient.asLong(raw.get("cols")), + (int) CmuxClient.asLong(raw.get("rows")), + CmuxClient.asString(raw.get("error")), + raw.get("retry_after_ms") instanceof Number ? CmuxClient.asLong(raw.get("retry_after_ms")) : null + ); case "vt-state" -> new VtStateEvent( CmuxClient.asLong(raw.get("surface")), (int) CmuxClient.asLong(raw.get("cols")), diff --git a/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java b/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java new file mode 100644 index 000000000000..2473189ff742 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java @@ -0,0 +1,3 @@ +package com.cmux; + +public record ResizeSurfaceResult(boolean accepted) {} diff --git a/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java b/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java new file mode 100644 index 000000000000..de55941be03b --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java @@ -0,0 +1,8 @@ +package com.cmux; + +public record SurfaceResizeFailedEvent(long surface, int cols, int rows, String error, Long retryAfterMs) implements CmuxEvent { + @Override + public String event() { + return "surface-resize-failed"; + } +} diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index 712268f69d0c..686e16ab7a95 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -58,6 +58,13 @@ public static void main(String[] args) { assertEquals("subscriber fell behind", overflow.error(), "overflow error"); assertEquals("surface", overflow.scope(), "overflow scope"); assertEquals(7L, overflow.surface(), "overflow surface"); + SurfaceResizeFailedEvent resizeFailed = (SurfaceResizeFailedEvent) CmuxEvent.from( + (Map) Json.parse( + "{\"event\":\"surface-resize-failed\",\"surface\":7,\"cols\":120,\"rows\":40,\"error\":\"browser is not responding\",\"retry_after_ms\":250}" + ) + ); + assertEquals("browser is not responding", resizeFailed.error(), "resize failure error"); + assertEquals(250L, resizeFailed.retryAfterMs(), "resize failure retry schedule"); } private static void assertReject(String input) { diff --git a/cmux-tui/bindings/python/cmux/__init__.py b/cmux-tui/bindings/python/cmux/__init__.py index 8f88a94db9f6..6ac7d7303678 100644 --- a/cmux-tui/bindings/python/cmux/__init__.py +++ b/cmux-tui/bindings/python/cmux/__init__.py @@ -11,6 +11,7 @@ Pane, ProtocolError, ReadScreenResult, + ResizeSurfaceResult, Screen, Size, SurfaceResult, @@ -34,6 +35,7 @@ "Pane", "ProtocolError", "ReadScreenResult", + "ResizeSurfaceResult", "Screen", "Size", "SurfaceResult", diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index 2c1839e30c5d..3c40aace4752 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -38,6 +38,11 @@ class EmptyResult: pass +@dataclass(frozen=True) +class ResizeSurfaceResult: + accepted: bool + + @dataclass(frozen=True) class IdentifyResult: app: str @@ -154,6 +159,7 @@ class Event: title: Optional[str] = None scope: Optional[str] = None error: Optional[str] = None + retry_after_ms: Optional[int] = None @property def bytes_data(self) -> Optional[bytes]: @@ -486,9 +492,9 @@ def rename_workspace(self, workspace: int, name: str) -> EmptyResult: self._request("rename-workspace", workspace=workspace, name=name) return EmptyResult() - def resize_surface(self, surface: int, cols: int, rows: int) -> EmptyResult: - self._request("resize-surface", surface=surface, cols=cols, rows=rows) - return EmptyResult() + def resize_surface(self, surface: int, cols: int, rows: int) -> ResizeSurfaceResult: + data = self._request("resize-surface", surface=surface, cols=cols, rows=rows) + return ResizeSurfaceResult(accepted=bool(data["accepted"])) def focus_pane(self, pane: int) -> EmptyResult: self._request("focus-pane", pane=pane) @@ -625,4 +631,5 @@ def _parse_event(value: Dict[str, Any]) -> Event: title=value.get("title"), scope=value.get("scope"), error=value.get("error"), + retry_after_ms=value.get("retry_after_ms"), ) diff --git a/cmux-tui/bindings/python/tests/test_events.py b/cmux-tui/bindings/python/tests/test_events.py index eef456f2fff2..1a9432f62d3e 100644 --- a/cmux-tui/bindings/python/tests/test_events.py +++ b/cmux-tui/bindings/python/tests/test_events.py @@ -49,6 +49,21 @@ def test_overflow_exposes_recovery_fields(self) -> None: self.assertEqual(event.scope, "surface") self.assertEqual(event.surface, 7) + def test_surface_resize_failed_exposes_retry_schedule(self) -> None: + event = _parse_event( + { + "event": "surface-resize-failed", + "surface": 7, + "cols": 120, + "rows": 40, + "error": "browser is not responding", + "retry_after_ms": 250, + } + ) + + self.assertEqual(event.event, "surface-resize-failed") + self.assertEqual(event.retry_after_ms, 250) + def test_stream_yields_buffered_overflow_once_then_stops(self) -> None: connection = _FakeConnection() stream = _Stream.__new__(_Stream) diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index efb3ddc7b0d5..a9b6d39f6289 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -169,6 +169,11 @@ pub struct Size { pub rows: u16, } +#[derive(Debug, Clone, Deserialize)] +pub struct ResizeSurfaceResult { + pub accepted: bool, +} + #[derive(Debug, Clone, Deserialize)] pub struct SurfaceEvent { pub surface: u64, @@ -187,6 +192,15 @@ pub struct SurfaceResizedEvent { pub rows: u16, } +#[derive(Debug, Clone, Deserialize)] +pub struct SurfaceResizeFailedEvent { + pub surface: u64, + pub cols: u16, + pub rows: u16, + pub error: String, + pub retry_after_ms: Option, +} + #[derive(Debug, Clone, Deserialize)] pub struct LayoutChangedEvent { pub screen: u64, @@ -229,6 +243,7 @@ pub enum Event { LayoutChanged(LayoutChangedEvent), SurfaceOutput(SurfaceEvent), SurfaceResized(SurfaceResizedEvent), + SurfaceResizeFailed(SurfaceResizeFailedEvent), SurfaceExited(SurfaceEvent), TitleChanged(TitleChangedEvent), Bell(SurfaceEvent), @@ -459,11 +474,16 @@ impl CmuxClient { self.request::("rename-workspace", params).map(|_| ()) } - pub fn resize_surface(&mut self, surface: u64, cols: u16, rows: u16) -> Result<()> { + pub fn resize_surface( + &mut self, + surface: u64, + cols: u16, + rows: u16, + ) -> Result { let mut params = surface_params(surface); params.insert("cols".to_string(), Value::from(cols)); params.insert("rows".to_string(), Value::from(rows)); - self.request::("resize-surface", params).map(|_| ()) + self.request("resize-surface", params) } pub fn focus_pane(&mut self, pane: u64) -> Result<()> { @@ -723,6 +743,9 @@ fn parse_event(value: Value) -> Event { "layout-changed" => parse_typed(value).map_or_else(Event::Unknown, Event::LayoutChanged), "surface-output" => parse_typed(value).map_or_else(Event::Unknown, Event::SurfaceOutput), "surface-resized" => parse_typed(value).map_or_else(Event::Unknown, Event::SurfaceResized), + "surface-resize-failed" => { + parse_typed(value).map_or_else(Event::Unknown, Event::SurfaceResizeFailed) + } "surface-exited" => parse_typed(value).map_or_else(Event::Unknown, Event::SurfaceExited), "title-changed" => parse_typed(value).map_or_else(Event::Unknown, Event::TitleChanged), "bell" => parse_typed(value).map_or_else(Event::Unknown, Event::Bell), @@ -799,6 +822,29 @@ mod tests { )); } + #[test] + fn surface_resize_failed_decodes_retry_schedule() { + let event = parse_event(serde_json::json!({ + "event": "surface-resize-failed", + "surface": 7, + "cols": 120, + "rows": 40, + "error": "browser is not responding", + "retry_after_ms": 250, + })); + + assert!(matches!( + event, + Event::SurfaceResizeFailed(SurfaceResizeFailedEvent { + surface: 7, + cols: 120, + rows: 40, + error, + retry_after_ms: Some(250), + }) if error == "browser is not responding" + )); + } + #[test] fn overflow_decodes_recovery_fields() { let event = parse_event(serde_json::json!({ diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index 03782b5177ce..ae8c78719f08 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -38,6 +38,7 @@ import type { ProcessInfoResult, ReadScreenResult, ReloadConfigResult, + ResizeSurfaceResult, ReportAgentResult, RunResult, SidebarPluginResult, @@ -378,7 +379,7 @@ export class CmuxClient { renameSurface(surface: Id, name: string): Promise { return this.request("rename-surface", { surface, name }); } renameScreen(screen: Id, name: string): Promise { return this.request("rename-screen", { screen, name }); } renameWorkspace(workspace: Id, name: string): Promise { return this.request("rename-workspace", { workspace, name }); } - resizeSurface(surface: Id, cols: number, rows: number): Promise { + resizeSurface(surface: Id, cols: number, rows: number): Promise { return this.request("resize-surface", { surface, cols, rows }); } focusPane(pane: Id): Promise { return this.request("focus-pane", { pane }); } diff --git a/cmux-tui/bindings/typescript/src/protocol/commands.ts b/cmux-tui/bindings/typescript/src/protocol/commands.ts index cc34c9ab73c9..d5f4c9d5e039 100644 --- a/cmux-tui/bindings/typescript/src/protocol/commands.ts +++ b/cmux-tui/bindings/typescript/src/protocol/commands.ts @@ -193,6 +193,7 @@ export interface ResizeSurfaceRequest extends CmuxRequestBase { cols: number; rows: number; } +export interface ResizeSurfaceResult { accepted: boolean } export interface FocusPaneRequest extends CmuxRequestBase { cmd: "focus-pane"; pane: Id } @@ -389,7 +390,7 @@ export interface CmuxResponseDataMap { "rename-surface": EmptyResult; "rename-screen": EmptyResult; "rename-workspace": EmptyResult; - "resize-surface": EmptyResult; + "resize-surface": ResizeSurfaceResult; "focus-pane": EmptyResult; "select-tab": EmptyResult; "select-screen": EmptyResult; diff --git a/cmux-tui/bindings/typescript/src/protocol/events.ts b/cmux-tui/bindings/typescript/src/protocol/events.ts index 7a56e96fb948..801fbb9bee3a 100644 --- a/cmux-tui/bindings/typescript/src/protocol/events.ts +++ b/cmux-tui/bindings/typescript/src/protocol/events.ts @@ -21,6 +21,14 @@ export interface ScrollChangedEvent { } export interface SurfaceResizedEvent { event: "surface-resized"; surface: Id; cols: number; rows: number } +export interface SurfaceResizeFailedEvent { + event: "surface-resize-failed"; + surface: Id; + cols: number; + rows: number; + error: string; + retry_after_ms: number | null; +} export interface SurfaceExitedEvent { event: "surface-exited"; surface: Id } export interface TitleChangedEvent { event: "title-changed"; surface: Id; title?: string } export interface BellEvent { event: "bell"; surface: Id } @@ -133,6 +141,7 @@ export type KnownSubscribeEvent = | SurfaceOutputEvent | ScrollChangedEvent | SurfaceResizedEvent + | SurfaceResizeFailedEvent | SurfaceExitedEvent | TitleChangedEvent | BellEvent diff --git a/cmux-tui/bindings/typescript/test/protocol-types.ts b/cmux-tui/bindings/typescript/test/protocol-types.ts index 5f61f0b4af6a..42622c305811 100644 --- a/cmux-tui/bindings/typescript/test/protocol-types.ts +++ b/cmux-tui/bindings/typescript/test/protocol-types.ts @@ -70,6 +70,7 @@ function surfaceFromKnownEvent(event: KnownCmuxEvent): number | undefined { case "surface-output": case "scroll-changed": case "surface-resized": + case "surface-resize-failed": case "surface-exited": case "title-changed": case "bell": @@ -116,12 +117,21 @@ const clientEvents: KnownCmuxEvent[] = [ { event: "client-changed", client: 2, name: "tablet", kind: "web" }, { event: "client-detached", client: 2 }, ]; +const resizeFailed: KnownCmuxEvent = { + event: "surface-resize-failed", + surface: 1, + cols: 120, + rows: 40, + error: "browser is not responding", + retry_after_ms: 250, +}; void surfaceFromKnownEvent; void colorsChanged; void futureEvent; void protocolV6Resize; void protocolV7Resize; void clientEvents; +void resizeFailed; // @ts-expect-error `read-screen` requires a surface id. const invalidRequest: CmuxRequest = { cmd: "read-screen" }; diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 1a81eaa5f53e..7172e7bb60b1 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -2489,7 +2489,7 @@ mod tests { } #[test] - fn browser_resize_stays_retryable_until_reconfigure_completes() { + fn pending_browser_resize_suppresses_duplicates_until_reconfigure_completes() { let opts = SurfaceOptions::default(); let surface = new_surface(1, "https://example.test".into(), (10, 5), (8, 16), &opts, Weak::new()); @@ -2497,9 +2497,9 @@ mod tests { *browser.cell_pixels.lock().unwrap() = (9, 16); let _ = browser.update_resize_state(10, 5).expect("changed geometry"); - assert!(browser.resize_needed(10, 5)); + assert!(!browser.resize_needed(10, 5)); - assert!(browser.reconfigure_resize_blocking(10, 5).unwrap()); + assert!(!browser.reconfigure_resize_blocking(10, 5).unwrap()); assert!(!browser.resize_needed(10, 5)); assert!(!browser.reconfigure_resize_blocking(10, 5).unwrap()); } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 61dc92e92cb9..923393e4aa47 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1143,6 +1143,14 @@ impl OrderedSession { failures.insert(surface, SurfaceResizeFailure { desired, state }); } + fn surface_resize_retry_due(&self) -> bool { + let now = Instant::now(); + self.surface_resize_failures.lock().unwrap().values().any(|failure| { + !failure.state.sticky_until_reconnect + && failure.state.retry_after.is_some_and(|retry_after| now >= retry_after) + }) + } + fn surface_resize_decision( &self, surface_id: SurfaceId, @@ -2267,6 +2275,9 @@ impl App { self.reassert_visible_surface_sizes(); } } + if self.session.surface_resize_retry_due() { + self.reassert_visible_surface_sizes(); + } self.retry_sidebar_plugin_if_due(); self.retry_background_refresh_if_due(); if self.session.surface_overflow_retry_due() { @@ -7652,6 +7663,18 @@ mod tests { assert!(super::surface_sync_failure_blocks(capped)); } + #[test] + fn due_session_resize_failure_rearms_idle_loop_retry() { + let mux = Mux::new("session-resize-retry-due-test", SurfaceOptions::default()); + let app = test_app(Session::Local(mux)); + + app.session.note_surface_resize_failure(41, (90, 31), Some(0)); + assert!(app.session.surface_resize_retry_due()); + + app.session.confirm_surface_resize(41, (90, 31)); + assert!(!app.session.surface_resize_retry_due()); + } + #[test] fn refresh_sequences_are_monotonic_across_identity_and_background_paths() { let mux = Mux::new("refresh-sequence-test", SurfaceOptions::default()); diff --git a/cmux-tui/docs/protocol.md b/cmux-tui/docs/protocol.md index 932a84e1e033..6257107eb20e 100644 --- a/cmux-tui/docs/protocol.md +++ b/cmux-tui/docs/protocol.md @@ -101,6 +101,7 @@ Subscribed event lines are: ```json {"event":"surface-output","surface":4} {"event":"surface-resized","surface":4,"cols":120,"rows":40} +{"event":"surface-resize-failed","surface":4,"cols":120,"rows":40,"error":"browser is not responding","retry_after_ms":250} {"event":"surface-exited","surface":4} {"event":"title-changed","surface":4,"title":"build logs"} {"event":"bell","surface":4} @@ -108,11 +109,11 @@ Subscribed event lines are: {"event":"empty"} ``` -`surface-resized` reports the final clamped cell size and is emitted only when the surface size actually changes. +`surface-resized` reports the final clamped cell size and is emitted only when the surface size actually changes. `surface-resize-failed` reports an asynchronous browser resize failure and the delay before an automatic retry, or `null` after retries are exhausted. Protocol v7 `title-changed` carries the authoritative current `title`. Slow subscribers coalesce repeated pending title changes per surface to the latest value. -Browser input, navigation, activation, and browser reconfigure work from `resize-surface` enqueue per-surface CDP work and return `ok:true` after acceptance. Completion or failure is observed later via browser state and status events. Two consecutive CDP call timeouts mark only that browser surface failed with `browser is not responding`. +Browser input, navigation, activation, and browser reconfigure work from `resize-surface` enqueue per-surface CDP work. Protocol v7 `resize-surface` responses include `data.accepted`; `true` means the resize was applied or queued, and `false` means it was already satisfied, pending, or waiting for its retry backoff. Completion arrives as `surface-resized`, and asynchronous failure arrives as `surface-resize-failed`. Two consecutive CDP call timeouts mark only that browser surface failed with `browser is not responding`. ## Attach Surface diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 48b5082c821d..e6260ec842ed 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -1499,7 +1499,7 @@ Example: | status | implemented | | since | protocol 5 | -Resizes a surface to a cell grid. PTY surfaces resize both the PTY and VT terminal state. Browser surfaces update their cell grid and CDP device metrics. `cols` and `rows` are clamped to at least 1 by the surface runtime. In protocol v6, the final pair becomes the session's latest client size even when the target was already at that size, so later creation requests without a size use the most recent client interaction; internal server resizes (such as the sidebar plugin surface tracking the TUI) never update it. The command result does not report whether the size changed. +Resizes a surface to a cell grid. PTY surfaces resize both the PTY and VT terminal state. Browser surfaces update their cell grid and CDP device metrics asynchronously. `cols` and `rows` are clamped to at least 1 by the surface runtime. In protocol v6, the final pair becomes the session's latest client size even when the target was already at that size, so later creation requests without a size use the most recent client interaction; internal server resizes (such as the sidebar plugin surface tracking the TUI) never update it. Protocol v7 adds `accepted`: `true` means the resize was applied or queued, while `false` means the surface already has that size, the same browser resize is pending, or its retry backoff has not elapsed. A browser resize completes with `surface-resized`; asynchronous failure is reported by `surface-resize-failed`. Params: @@ -1512,7 +1512,7 @@ Params: Result: ```text -object{} +object{accepted:bool} ``` Errors: @@ -1536,7 +1536,7 @@ Example: ```json {"id":21,"cmd":"resize-surface","surface":1,"cols":120,"rows":40} -{"id":21,"ok":true,"data":{}} +{"id":21,"ok":true,"data":{"accepted":true}} ``` ### focus-pane @@ -2463,7 +2463,7 @@ The following v5 behaviors are awkward for generated bindings and should be norm | --- | --- | --- | | Create commands | `new-tab`, `new-browser-tab`, `new-screen`, `new-workspace`, and `split` return only `{surface}` | Return `{surface,pane,screen,workspace}` | | Selection commands | `select-*` returns success for unknown targets, out-of-range indexes, and missing selector fields | Return a changed boolean or reject invalid target/index | -| Resize command | `resize-surface` does not report whether size changed or final clamped size | Return `{changed,cols,rows}` | +| Resize command | `resize-surface` reports acceptance but not the final clamped size | Return `{accepted,cols,rows}` | | Ratio command | `set-ratio` silently clamps and does not return final ratio | Return `{ratio}` after clamping | | Naming commands | Empty string clears pane/surface/screen names but stores an empty workspace name | Make empty string clear all optional display names, including workspace | | Attach response ordering | v5 `attach-surface` sends `vt-state` before the command response | v6 keeps attach as an event stream and adds `resized` replay events; clients must gate behavior by protocol | diff --git a/cmux-tui/spec/events.md b/cmux-tui/spec/events.md index 706394985d60..f872fdb9abf2 100644 --- a/cmux-tui/spec/events.md +++ b/cmux-tui/spec/events.md @@ -6,7 +6,7 @@ Implemented event lines can appear on two stream types: | Stream | How to start | Event names | | --- | --- | --- | -| Subscribe stream | `subscribe` command | `tree-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-exited`, `title-changed`, `bell`, `notification`, `config-reload-requested`, `window-title-requested`, `client-attached`, `client-changed`, `client-detached`, `empty`, `overflow` | +| Subscribe stream | `subscribe` command | `tree-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-resize-failed`, `surface-exited`, `title-changed`, `bell`, `notification`, `config-reload-requested`, `window-title-requested`, `client-attached`, `client-changed`, `client-detached`, `empty`, `overflow` | | Attach stream v5 | `attach-surface` command | `vt-state`, `output`, `detached`, `overflow` | | Attach stream v6 | `attach-surface` command | `vt-state`, `resized`, `output`, `colors-changed`, `scroll-changed`, `detached`, `overflow` | @@ -234,6 +234,28 @@ Example: {"event":"surface-resized","surface":1,"cols":120,"rows":40} ``` +### surface-resize-failed + +| Field | Value | +| --- | --- | +| event | `surface-resize-failed` | +| status | implemented | +| since | protocol 7 | + +Payload: + +```text +object{event:"surface-resize-failed",surface:Id,cols:uint16,rows:uint16,error:string,retry_after_ms:uint64|null} +``` + +Meaning: An accepted asynchronous browser resize failed. A numeric `retry_after_ms` is the delay before the session retries the same geometry. `null` means automatic retries are exhausted; a new geometry request or browser reconnection may retry it. + +Example: + +```json +{"event":"surface-resize-failed","surface":1,"cols":120,"rows":40,"error":"browser is not responding","retry_after_ms":250} +``` + ### surface-exited | Field | Value | From b4521f181ad73f592e0d96bc765c37d7287d9565 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:55:27 -0700 Subject: [PATCH 094/103] Keep pointer motion off terminal locks --- cmux-tui/crates/cmux-tui-core/src/surface.rs | 14 ++- cmux-tui/crates/cmux-tui/src/app.rs | 105 +++++++++++++++++- cmux-tui/crates/cmux-tui/src/session/mod.rs | 13 +++ .../frontends/web/src/hooks/useCmuxClient.ts | 6 + cmux-tui/spec/events.md | 2 +- 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 2f7d09096367..8106317ec7be 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{ Receiver, RecvError, RecvTimeoutError, SyncSender, TryRecvError, TrySendError, }; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex, TryLockError, Weak}; use std::time::Duration; use ghostty_vt::{Callbacks, CursorShape, RenderState, Rgb, Terminal}; @@ -604,6 +604,18 @@ impl Surface { Some(f(&mut pty.term.lock().unwrap())) } + /// Run `f` only when the terminal state is immediately available. + /// High-frequency input paths use this to drop coalescible samples instead + /// of parking the UI loop behind PTY parsing or replay generation. + pub fn with_terminal_if_uncontended(&self, f: impl FnOnce(&mut Terminal) -> R) -> Option { + let pty = self.as_pty()?; + match pty.term.try_lock() { + Ok(mut term) => Some(f(&mut term)), + Err(TryLockError::Poisoned(error)) => Some(f(&mut error.into_inner())), + Err(TryLockError::WouldBlock) => None, + } + } + pub fn try_with_terminal(&self, f: impl FnOnce(&mut Terminal) -> R) -> anyhow::Result { let Some(pty) = self.as_pty() else { anyhow::bail!("browser surface does not have a VT terminal"); diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 923393e4aa47..83743b09d767 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -5246,12 +5246,23 @@ impl App { return false; } let Some(area) = self.pane_area_at(x, y).copied() else { return false }; - if !area.content.contains(x, y) - || self.surface_kind(area.surface) != Some(SurfaceKind::Pty) - || !self.pty_mouse_tracking(area.surface) + if !area.content.contains(x, y) || self.surface_kind(area.surface) != Some(SurfaceKind::Pty) { return false; } + if action == MouseAction::Motion { + return self.forward_pty_mouse_motion_if_uncontended( + area.surface, + area.content, + x, + y, + modifiers, + any_button_pressed, + ); + } + if !self.pty_mouse_tracking(area.surface) { + return false; + } let _ = self.forward_pty_mouse_to_surface( area.surface, area.content, @@ -5265,6 +5276,54 @@ impl App { true } + fn forward_pty_mouse_motion_if_uncontended( + &mut self, + surface_id: SurfaceId, + content: Rect, + x: u16, + y: u16, + modifiers: KeyModifiers, + any_button_pressed: bool, + ) -> bool { + let Some(surface) = self.session.surface(surface_id) else { return false }; + let cell_width = u32::from(self.cell_pixels.0.max(1)); + let cell_height = u32::from(self.cell_pixels.1.max(1)); + let input = MouseInput { + action: MouseAction::Motion, + button: None, + mods: Self::ghostty_mouse_mods(modifiers), + position: ( + (x as f32 - content.x as f32 + 0.5) * cell_width as f32, + (y as f32 - content.y as f32 + 0.5) * cell_height as f32, + ), + screen_size: ( + u32::from(content.width).saturating_mul(cell_width), + u32::from(content.height).saturating_mul(cell_height), + ), + cell_size: (cell_width, cell_height), + any_button_pressed, + }; + + self.encode_buf.clear(); + let Some(encoded) = surface.with_terminal_if_uncontended(|terminal| { + if !terminal.mouse_tracking() { + return None; + } + self.mouse_encoder.sync_from_terminal(terminal); + Some(self.mouse_encoder.encode(input, &mut self.encode_buf)) + }) else { + // Motion is coalescible. Drop a sample that races terminal + // parsing while the normal cmux hover bookkeeping continues. + return true; + }; + let Some(encoded) = encoded else { return false }; + if encoded.is_err() || self.encode_buf.is_empty() { + return true; + } + let _ = self.write_encoded_pty_bytes(surface_id, surface, PtyInputKind::Motion); + true + } + #[allow(clippy::too_many_arguments)] fn forward_pty_mouse_to_surface( &mut self, @@ -6670,6 +6729,46 @@ mod tests { mux.close_surface(surface.id); } + #[test] + fn pointer_motion_does_not_wait_for_terminal_parsing() { + let mux = Mux::new( + "mouse-motion-lock-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]), + ..Default::default() + }, + ); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + let held_surface = surface.clone(); + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + held_surface.with_terminal(|_| { + locked_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }); + }); + locked_rx.recv().unwrap(); + + let mut app = test_app(Session::Local(mux.clone())); + assert!(app.forward_pty_mouse_motion_if_uncontended( + surface.id, + Rect { x: 2, y: 3, width: 20, height: 8 }, + 6, + 5, + KeyModifiers::NONE, + false, + )); + + release_tx.send(()).unwrap(); + holder.join().unwrap(); + mux.close_surface(surface.id); + } + #[test] fn rejected_input_shows_an_error_without_disconnecting() { let mux = Mux::new("oversized-input-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 925beb704db2..583402e3d884 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -811,6 +811,19 @@ impl SurfaceHandle { } } + /// Run `f` only when the local or mirrored terminal lock is immediately + /// available. `None` also covers non-PTY surfaces. + pub fn with_terminal_if_uncontended(&self, f: impl FnOnce(&mut Terminal) -> R) -> Option { + match self { + SurfaceHandle::Local(surface) => surface.with_terminal_if_uncontended(f), + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { + let mut term = surface.term.try_lock().ok()?; + Some(f(&mut term)) + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, + } + } + pub fn scroll_delta(&self, delta: isize) -> Option { match self { SurfaceHandle::Local(surface) => { diff --git a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts index 2dc1ce7e8e42..f9e99d2ed374 100644 --- a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts +++ b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts @@ -184,6 +184,12 @@ export function useCmuxClient() { queueSurfaceTitle(changed.surface, changed.title); } } + // This frontend passes only live PTY tabs to useAttachedTerminal; + // browser tabs render the unsupported placeholder and never call + // resizeSurface. A surface-resize-failed broadcast therefore + // belongs to another client and must not be echoed into a + // multi-client retry loop. Browser rendering must add explicit + // per-client geometry ownership before handling that event. if (["tree-changed", "layout-changed", "surface-resized", "surface-exited"].includes(event.event)) { discardPendingSurfaceTitles(); await refresh(); diff --git a/cmux-tui/spec/events.md b/cmux-tui/spec/events.md index f872fdb9abf2..8bf5b4ccb650 100644 --- a/cmux-tui/spec/events.md +++ b/cmux-tui/spec/events.md @@ -248,7 +248,7 @@ Payload: object{event:"surface-resize-failed",surface:Id,cols:uint16,rows:uint16,error:string,retry_after_ms:uint64|null} ``` -Meaning: An accepted asynchronous browser resize failed. A numeric `retry_after_ms` is the delay before the session retries the same geometry. `null` means automatic retries are exhausted; a new geometry request or browser reconnection may retry it. +Meaning: An accepted asynchronous browser resize failed. A numeric `retry_after_ms` is the delay before the requesting client retries the same geometry. `null` means automatic retries are exhausted; a new geometry request or browser reconnection may retry it. The event is broadcast, so subscribers that did not request this geometry must not echo it. Example: From 30fa9c98083c373e728ba0f0a0b683a461c09556 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:10:03 -0700 Subject: [PATCH 095/103] Scope resize recovery to request owners --- cmux-tui/crates/cmux-tui/src/app.rs | 88 +++++++++++++++---- cmux-tui/crates/cmux-tui/src/browser_input.rs | 26 +++++- .../frontends/web/src/hooks/useCmuxClient.ts | 6 +- 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 83743b09d767..6db1ff9dacdc 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -600,6 +600,7 @@ pub struct OrderedSession { remote_refresh_sequence: Arc, surface_resize_claims: Arc>>, surface_resize_claim_sequence: Arc, + surface_resize_ownership: Arc>>, surface_attach_claims: Arc>>, surface_attach_failures: Arc>>, surface_resize_failures: Arc>>, @@ -627,6 +628,7 @@ impl OrderedSession { remote_refresh_sequence: Arc::new(AtomicU64::new(0)), surface_resize_claims: Arc::new(Mutex::new(HashMap::new())), surface_resize_claim_sequence: Arc::new(AtomicU64::new(0)), + surface_resize_ownership: Arc::new(Mutex::new(HashMap::new())), surface_attach_claims: Arc::new(Mutex::new(HashSet::new())), surface_attach_failures: Arc::new(Mutex::new(HashMap::new())), surface_resize_failures: Arc::new(Mutex::new(HashMap::new())), @@ -678,6 +680,7 @@ impl OrderedSession { self.surface_attach_failures.lock().unwrap().remove(&id); self.surface_attach_claims.lock().unwrap().remove(&id); self.surface_resize_failures.lock().unwrap().remove(&id); + self.surface_resize_ownership.lock().unwrap().remove(&id); self.inner.forget_surface(id); } @@ -1118,18 +1121,39 @@ impl OrderedSession { } fn confirm_surface_resize(&self, surface: SurfaceId, size: (u16, u16)) { + let mut ownership = self.surface_resize_ownership.lock().unwrap(); + if ownership.get(&surface).is_some_and(|desired| *desired == size) { + ownership.remove(&surface); + } + drop(ownership); let mut failures = self.surface_resize_failures.lock().unwrap(); if failures.get(&surface).is_some_and(|failure| failure.desired == size) { failures.remove(&surface); } } + fn release_surface_resize_ownership(&self, surface: SurfaceId, desired: (u16, u16)) { + let mut ownership = self.surface_resize_ownership.lock().unwrap(); + if ownership.get(&surface).is_some_and(|owned| *owned == desired) { + ownership.remove(&surface); + } + } + fn note_surface_resize_failure( &self, surface: SurfaceId, desired: (u16, u16), retry_after_ms: Option, - ) { + ) -> bool { + if self + .surface_resize_ownership + .lock() + .unwrap() + .get(&surface) + .is_none_or(|owned| *owned != desired) + { + return false; + } let mut failures = self.surface_resize_failures.lock().unwrap(); let previous = failures .get(&surface) @@ -1141,6 +1165,7 @@ impl OrderedSession { state.sticky_until_reconnect = false; } failures.insert(surface, SurfaceResizeFailure { desired, state }); + true } fn surface_resize_retry_due(&self) -> bool { @@ -3114,11 +3139,14 @@ impl App { error, retry_after_ms, }) => { - self.session.note_surface_resize_failure(surface, (cols, rows), retry_after_ms); - self.status_message = Some(format!( - "browser surface {surface} resize to {cols}x{rows} failed: {error}" - )); - Ok(RenderAction::Draw) + if self.session.note_surface_resize_failure(surface, (cols, rows), retry_after_ms) { + self.status_message = Some(format!( + "browser surface {surface} resize to {cols}x{rows} failed: {error}" + )); + Ok(RenderAction::Draw) + } else { + Ok(RenderAction::None) + } } AppEvent::Mux(MuxEvent::Status(message)) => { self.status_message = Some(message); @@ -3144,6 +3172,10 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::BrowserResizeFailed(failure) => { + self.session.release_surface_resize_ownership( + failure.surface_id, + (failure.cols, failure.rows), + ); self.status_message = Some(format!( "browser surface {} resize to {}x{} failed: {}", failure.surface_id, failure.cols, failure.rows, failure.error @@ -3664,6 +3696,7 @@ impl App { ) { if surface.kind() == SurfaceKind::Browser { let Some(claim) = claim else { return }; + let ownership = self.session.surface_resize_ownership.clone(); let _ = self.browser_input.enqueue(BrowserInputEvent { surface_id, surface, @@ -3672,6 +3705,9 @@ impl App { rows, reassert, _claim: Some(Box::new(claim)), + on_dispatch: Some(Box::new(move || { + ownership.lock().unwrap().insert(surface_id, (cols, rows)); + })), }, }); } else { @@ -5063,12 +5099,10 @@ impl App { *position = (x, y); *stored_modifiers = modifiers; } - let _ = self.forward_pty_mouse_to_surface( + let _ = self.forward_pty_mouse_motion_if_uncontended( surface, content, - x, - y, - MouseAction::Motion, + (x, y), Some(Self::ghostty_mouse_button(active_button)), modifiers, true, @@ -5254,8 +5288,8 @@ impl App { return self.forward_pty_mouse_motion_if_uncontended( area.surface, area.content, - x, - y, + (x, y), + None, modifiers, any_button_pressed, ); @@ -5280,17 +5314,18 @@ impl App { &mut self, surface_id: SurfaceId, content: Rect, - x: u16, - y: u16, + position: (u16, u16), + button: Option, modifiers: KeyModifiers, any_button_pressed: bool, ) -> bool { let Some(surface) = self.session.surface(surface_id) else { return false }; + let (x, y) = position; let cell_width = u32::from(self.cell_pixels.0.max(1)); let cell_height = u32::from(self.cell_pixels.1.max(1)); let input = MouseInput { action: MouseAction::Motion, - button: None, + button, mods: Self::ghostty_mouse_mods(modifiers), position: ( (x as f32 - content.x as f32 + 0.5) * cell_width as f32, @@ -6537,7 +6572,8 @@ mod tests { Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ghostty_vt::{ - Callbacks, KeyEncoder, Mods, MouseAction, MouseEncoder, MouseInput, RenderState, + Callbacks, KeyEncoder, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseEncoder, + MouseInput, RenderState, }; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -6758,11 +6794,19 @@ mod tests { assert!(app.forward_pty_mouse_motion_if_uncontended( surface.id, Rect { x: 2, y: 3, width: 20, height: 8 }, - 6, - 5, + (6, 5), + None, KeyModifiers::NONE, false, )); + assert!(app.forward_pty_mouse_motion_if_uncontended( + surface.id, + Rect { x: 2, y: 3, width: 20, height: 8 }, + (7, 5), + Some(GhosttyMouseButton::Left), + KeyModifiers::NONE, + true, + )); release_tx.send(()).unwrap(); holder.join().unwrap(); @@ -7559,6 +7603,7 @@ mod tests { false, Some(claim), ); + assert!(app.session.surface_resize_ownership.lock().unwrap().get(&7).is_none()); let _ = app.browser_input.enqueue(BrowserInputEvent { surface_id: 7, surface: SurfaceHandle::RemoteBrowserUnsupported, @@ -7767,11 +7812,16 @@ mod tests { let mux = Mux::new("session-resize-retry-due-test", SurfaceOptions::default()); let app = test_app(Session::Local(mux)); - app.session.note_surface_resize_failure(41, (90, 31), Some(0)); + assert!(!app.session.note_surface_resize_failure(41, (90, 31), Some(0))); + assert!(!app.session.surface_resize_retry_due()); + + app.session.surface_resize_ownership.lock().unwrap().insert(41, (90, 31)); + assert!(app.session.note_surface_resize_failure(41, (90, 31), Some(0))); assert!(app.session.surface_resize_retry_due()); app.session.confirm_surface_resize(41, (90, 31)); assert!(!app.session.surface_resize_retry_due()); + assert!(!app.session.surface_resize_ownership.lock().unwrap().contains_key(&41)); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index e3dce22a1655..795b4fec26d9 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -79,6 +79,7 @@ pub enum BrowserInputKind { rows: u16, reassert: bool, _claim: Option>, + on_dispatch: Option>, }, Navigate(String), Back, @@ -324,7 +325,7 @@ fn worker( let mut batch = vec![event]; finish_ordered_batch(&rx, &order, &latest_resizes, &mut batch); coalesce_sequenced_browser_events(&mut batch); - for event in batch { + for mut event in batch { if event.lifetime.load(Ordering::Acquire) { continue; } @@ -339,6 +340,11 @@ fn worker( }) { continue; } + if let BrowserInputKind::Resize { on_dispatch, .. } = &mut event.event.kind + && let Some(mark_dispatched) = on_dispatch.take() + { + mark_dispatched(); + } let result = dispatch(&event.event); let Some((cols, rows)) = desired else { if event.event.kind.is_control() @@ -523,7 +529,13 @@ mod tests { BrowserInputEvent { surface_id: surface, surface: SurfaceHandle::RemoteBrowserUnsupported, - kind: BrowserInputKind::Resize { cols, rows: 24, reassert: false, _claim: None }, + kind: BrowserInputKind::Resize { + cols, + rows: 24, + reassert: false, + _claim: None, + on_dispatch: None, + }, } } @@ -540,6 +552,7 @@ mod tests { rows: 24, reassert: false, _claim: Some(Box::new(DropProbe(dropped))), + on_dispatch: None, }, } } @@ -749,8 +762,15 @@ mod tests { ) .unwrap(); - let _ = dispatcher.enqueue(resize_event(7, 100)); + let dispatched = Arc::new(AtomicBool::new(false)); + let mut first = resize_event(7, 100); + if let BrowserInputKind::Resize { on_dispatch, .. } = &mut first.kind { + let dispatched = dispatched.clone(); + *on_dispatch = Some(Box::new(move || dispatched.store(true, Ordering::Release))); + } + let _ = dispatcher.enqueue(first); let failure = failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(dispatched.load(Ordering::Acquire)); assert_eq!((failure.surface_id, failure.cols, failure.rows), (7, 100, 24)); assert!(dispatcher.resize_failed(7, (100, 24))); diff --git a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts index f9e99d2ed374..237bbb9394ad 100644 --- a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts +++ b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts @@ -67,7 +67,7 @@ export function useCmuxClient() { let retryTimer: ReturnType | undefined; let titleFlushTimer: ReturnType | undefined; const pendingSurfaceTitles = new Map(); - const titleReconciler = new SurfaceTitleReconciler(); + let titleReconciler = new SurfaceTitleReconciler(); const discardPendingSurfaceTitles = () => { if (titleFlushTimer !== undefined) clearTimeout(titleFlushTimer); @@ -109,6 +109,10 @@ export function useCmuxClient() { const start = async (reconnecting: boolean, previousAttempt = 0): Promise => { if (cancelled) return; + // Retained title events belong to one transport generation. A restarted + // server may reuse surface IDs, so no title state may cross reconnects. + discardPendingSurfaceTitles(); + titleReconciler = new SurfaceTitleReconciler(); let dropHandled = false; let canReconnect = false; const transport = new WebSocketTransport(config.url, { authToken: config.token }); From 9160af34654333df97ec7d1a35d001130702f9da Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:29:21 -0700 Subject: [PATCH 096/103] Make resize ownership follow accepted work --- cmux-tui/bindings/go/client_test.go | 11 ++++ cmux-tui/bindings/go/types.go | 12 ++++ .../java/src/com/cmux/CmuxClient.java | 2 +- .../src/com/cmux/ResizeSurfaceResult.java | 10 ++- .../java/tests/com/cmux/JsonTest.java | 1 + cmux-tui/bindings/python/cmux/client.py | 2 +- .../bindings/python/tests/test_protocol.py | 6 ++ cmux-tui/bindings/rust/src/lib.rs | 11 ++++ cmux-tui/bindings/typescript/src/client.ts | 5 +- .../bindings/typescript/test/client.test.ts | 9 +++ cmux-tui/crates/cmux-tui-core/src/browser.rs | 17 ++++- cmux-tui/crates/cmux-tui-core/src/mux.rs | 14 +++- cmux-tui/crates/cmux-tui-core/src/server.rs | 14 +++- cmux-tui/crates/cmux-tui-core/src/surface.rs | 6 +- .../cmux-tui-core/tests/browser_runtime.rs | 5 ++ cmux-tui/crates/cmux-tui/src/app.rs | 57 +++++++++++++--- cmux-tui/crates/cmux-tui/src/browser_input.rs | 63 +++++++++--------- cmux-tui/crates/cmux-tui/src/session/mod.rs | 65 ++++++++----------- .../crates/cmux-tui/src/session/remote.rs | 31 ++++++--- 19 files changed, 239 insertions(+), 102 deletions(-) diff --git a/cmux-tui/bindings/go/client_test.go b/cmux-tui/bindings/go/client_test.go index ba3362e145f2..57a764157705 100644 --- a/cmux-tui/bindings/go/client_test.go +++ b/cmux-tui/bindings/go/client_test.go @@ -3,6 +3,7 @@ package cmux import ( "bufio" "context" + "encoding/json" "errors" "io" "net" @@ -10,6 +11,16 @@ import ( "testing" ) +func TestLegacyResizeResponseDefaultsToAccepted(t *testing.T) { + var result ResizeSurfaceResult + if err := json.Unmarshal([]byte(`{}`), &result); err != nil { + t.Fatal(err) + } + if !result.Accepted { + t.Fatal("legacy resize response must be treated as accepted") + } +} + func TestStreamYieldsBufferedOverflowOnceThenStops(t *testing.T) { client, server := net.Pipe() defer server.Close() diff --git a/cmux-tui/bindings/go/types.go b/cmux-tui/bindings/go/types.go index 2704cca8a25b..fbcb1b0d9102 100644 --- a/cmux-tui/bindings/go/types.go +++ b/cmux-tui/bindings/go/types.go @@ -1,5 +1,7 @@ package cmux +import "encoding/json" + type IdentifyResult struct { App string `json:"app"` Version string `json:"version"` @@ -26,6 +28,16 @@ type ResizeSurfaceResult struct { Accepted bool `json:"accepted"` } +func (r *ResizeSurfaceResult) UnmarshalJSON(data []byte) error { + type wireResult ResizeSurfaceResult + decoded := wireResult{Accepted: true} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = ResizeSurfaceResult(decoded) + return nil +} + type Tree struct { Workspaces []Workspace `json:"workspaces"` } diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java index ac86340127ff..7f4c38f8795d 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java @@ -224,7 +224,7 @@ public ResizeSurfaceResult resizeSurface(long surface, int cols, int rows) throw Map params = surfaceParams(surface); params.put("cols", cols); params.put("rows", rows); - return new ResizeSurfaceResult(Boolean.TRUE.equals(request("resize-surface", params).get("accepted"))); + return ResizeSurfaceResult.from(request("resize-surface", params)); } public void closeWorkspace(long workspace) throws CmuxException { diff --git a/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java b/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java index 2473189ff742..ebdae932d2b2 100644 --- a/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java +++ b/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java @@ -1,3 +1,11 @@ package com.cmux; -public record ResizeSurfaceResult(boolean accepted) {} +import java.util.Map; + +public record ResizeSurfaceResult(boolean accepted) { + static ResizeSurfaceResult from(Map data) { + return new ResizeSurfaceResult( + !data.containsKey("accepted") || Boolean.TRUE.equals(data.get("accepted")) + ); + } +} diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index 686e16ab7a95..640e2e4dcf8a 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -65,6 +65,7 @@ public static void main(String[] args) { ); assertEquals("browser is not responding", resizeFailed.error(), "resize failure error"); assertEquals(250L, resizeFailed.retryAfterMs(), "resize failure retry schedule"); + assertTrue(ResizeSurfaceResult.from(Map.of()).accepted(), "legacy resize accepted"); } private static void assertReject(String input) { diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index 3c40aace4752..2194de5e4149 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -494,7 +494,7 @@ def rename_workspace(self, workspace: int, name: str) -> EmptyResult: def resize_surface(self, surface: int, cols: int, rows: int) -> ResizeSurfaceResult: data = self._request("resize-surface", surface=surface, cols=cols, rows=rows) - return ResizeSurfaceResult(accepted=bool(data["accepted"])) + return ResizeSurfaceResult(accepted=bool(data.get("accepted", True))) def focus_pane(self, pane: int) -> EmptyResult: self._request("focus-pane", pane=pane) diff --git a/cmux-tui/bindings/python/tests/test_protocol.py b/cmux-tui/bindings/python/tests/test_protocol.py index 418619ab2acc..853f8b94954d 100644 --- a/cmux-tui/bindings/python/tests/test_protocol.py +++ b/cmux-tui/bindings/python/tests/test_protocol.py @@ -4,6 +4,12 @@ class ProtocolTests(unittest.TestCase): + def test_legacy_resize_response_defaults_to_accepted(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._request = lambda _command, **_params: {} + + self.assertTrue(client.resize_surface(7, 80, 24).accepted) + def test_attach_rejects_protocols_newer_than_seven_even_with_opt_in(self) -> None: client = CmuxClient.__new__(CmuxClient) client._protocol = 8 diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index a9b6d39f6289..f5504ed32f52 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -171,9 +171,14 @@ pub struct Size { #[derive(Debug, Clone, Deserialize)] pub struct ResizeSurfaceResult { + #[serde(default = "default_true")] pub accepted: bool, } +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Deserialize)] pub struct SurfaceEvent { pub surface: u64, @@ -845,6 +850,12 @@ mod tests { )); } + #[test] + fn legacy_resize_response_defaults_to_accepted() { + let result: ResizeSurfaceResult = serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(result.accepted); + } + #[test] fn overflow_decodes_recovery_fields() { let event = parse_event(serde_json::json!({ diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index ae8c78719f08..ab3d7a637ff4 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -379,8 +379,9 @@ export class CmuxClient { renameSurface(surface: Id, name: string): Promise { return this.request("rename-surface", { surface, name }); } renameScreen(screen: Id, name: string): Promise { return this.request("rename-screen", { screen, name }); } renameWorkspace(workspace: Id, name: string): Promise { return this.request("rename-workspace", { workspace, name }); } - resizeSurface(surface: Id, cols: number, rows: number): Promise { - return this.request("resize-surface", { surface, cols, rows }); + async resizeSurface(surface: Id, cols: number, rows: number): Promise { + const result = await this.request("resize-surface", { surface, cols, rows }); + return { accepted: result.accepted ?? true }; } focusPane(pane: Id): Promise { return this.request("focus-pane", { pane }); } selectTab(options: SelectTabOptions = {}): Promise { return this.request("select-tab", options); } diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index 726a15a1e90a..4bd3f81281fd 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -19,6 +19,15 @@ class ScriptedTransport implements Transport { } } +test("legacy resize response defaults to accepted", async () => { + const transport = new ScriptedTransport((request, connection) => { + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + assert.deepEqual(await client.resizeSurface(7, 80, 24), { accepted: true }); + await client.close(); +}); + test("attachSurface decodes VT colors, output, and resized payloads", async () => { const main = new ScriptedTransport((request, transport) => { assert.equal(request.cmd, "identify"); diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 7172e7bb60b1..12582e2a1cc8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -1013,17 +1013,17 @@ impl BrowserSurface { Ok(needed) } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { { let mut cell = self.cell_pixels.lock().unwrap(); let next = (width_px.max(1), height_px.max(1)); if *cell == next { - return; + return Ok(false); } *cell = next; } let (cols, rows) = self.size(); - let _ = self.resize(cols, rows); + self.resize(cols, rows) } fn update_resize_state(&self, cols: u16, rows: u16) -> Option<(u32, u32)> { @@ -2488,6 +2488,17 @@ mod tests { assert!(browser.resize_needed(10, 5)); } + #[test] + fn cell_pixel_change_reports_only_accepted_reconfigure() { + let opts = SurfaceOptions::default(); + let surface = + new_surface(1, "https://example.test".into(), (10, 5), (8, 16), &opts, Weak::new()); + let browser = surface.as_browser().expect("browser surface"); + + assert!(browser.set_cell_pixel_size(9, 16).unwrap()); + assert!(!browser.set_cell_pixel_size(9, 16).unwrap()); + } + #[test] fn pending_browser_resize_suppresses_duplicates_until_reconfigure_completes() { let opts = SurfaceOptions::default(); diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 05978c0fb789..1e9e2ffb9649 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -730,19 +730,27 @@ impl Mux { } } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { + pub fn set_cell_pixel_size( + &self, + width_px: u16, + height_px: u16, + ) -> Vec<(SurfaceId, (u16, u16))> { let next = (width_px.max(1), height_px.max(1)); { let mut cell = self.cell_pixels.lock().unwrap(); if *cell == next { - return; + return Vec::new(); } *cell = next; } let surfaces = self.state.lock().unwrap().surfaces.values().cloned().collect::>(); + let mut accepted = Vec::new(); for surface in surfaces { - surface.set_cell_pixel_size(next.0, next.1); + if surface.set_cell_pixel_size(next.0, next.1).unwrap_or(false) { + accepted.push((surface.id, surface.size())); + } } + accepted } pub fn default_colors(&self) -> DefaultColors { diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 08312fafedc5..793ce36f997c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -2100,8 +2100,18 @@ fn handle_command( Ok(json!({ "surface": surface.id })) } Command::SetCellPixels { width_px, height_px } => { - mux.set_cell_pixel_size(width_px, height_px); - Ok(json!({})) + let resizes = mux + .set_cell_pixel_size(width_px, height_px) + .into_iter() + .map(|(surface, (cols, rows))| { + json!({ + "surface": surface, + "cols": cols, + "rows": rows, + }) + }) + .collect::>(); + Ok(json!({"resizes": resizes})) } Command::BrowserMouse { surface, kind, x_px, y_px, button, click_count } => { let surface = get_surface(mux, surface)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 8106317ec7be..15f149e2674c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -719,9 +719,11 @@ impl Surface { } } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { if let Some(browser) = self.as_browser() { - browser.set_cell_pixel_size(width_px, height_px); + browser.set_cell_pixel_size(width_px, height_px) + } else { + Ok(false) } } diff --git a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs index 6bd433520417..9aa40df85edc 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs @@ -442,6 +442,11 @@ fn socket_browser_attach_streams_frames_input_and_cell_pixels() { json!({"id": 5, "cmd": "set-cell-pixels", "width_px": 11, "height_px": 17}), ); assert_eq!(metrics["ok"], true); + assert!(metrics["data"]["resizes"].as_array().is_some_and(|resizes| { + resizes.iter().any(|resize| { + resize == &json!({"surface": surface, "cols": 10, "rows": 5}) + }) + })); let metrics_request = recv_method_where(&seen_rx, "Emulation.setDeviceMetricsOverride", |value| { value["params"]["width"] == 110 && value["params"]["height"] == 85 diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 6db1ff9dacdc..d519a83758bb 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -45,7 +45,7 @@ use crate::pty_input::{ PtyInputSender, PtyOperationDelivery, PtyOperationFailure, }; use crate::session::{ - Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_retryable, is_remote_timeout, + Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_timeout, is_remote_transport_failure, }; use crate::sidebar_files::{FileBrowser, FileCommand, file_url, shell_single_quote}; @@ -580,6 +580,20 @@ impl Drop for SurfaceResizeClaim { } } +fn record_surface_resize_dispatch_result( + ownership: &Mutex>, + surface: SurfaceId, + desired: (u16, u16), + accepted: bool, +) { + let mut ownership = ownership.lock().unwrap(); + if accepted { + ownership.insert(surface, desired); + } else if ownership.get(&surface).is_some_and(|owned| *owned == desired) { + ownership.remove(&surface); + } +} + /// Read access stays synchronous, while every UI-originated session mutation /// enters the same ordered worker as PTY input. Accepted keys, mouse releases, /// resizes, and closes therefore have one execution order without blocking the @@ -1082,16 +1096,15 @@ impl OrderedSession { surface.resize(cols, rows) }; match result { - Ok(()) => { + Ok(_) => { failures.lock().unwrap().remove(&surface_id); committed_mutation_generation.fetch_add(1, Ordering::AcqRel); pending.settle(SessionMutationOutcome::Success { tree: None }); Ok(()) } Err(error) => { - let transient = is_remote_timeout(&error) - || is_remote_transport_failure(&error) - || is_remote_retryable(&error); + let transient = + is_remote_timeout(&error) || is_remote_transport_failure(&error); let mut failures = failures.lock().unwrap(); let previous = failures.get(&surface_id).map(|failure| failure.state); let state = next_surface_sync_failure(previous, transient, false); @@ -1327,8 +1340,14 @@ impl OrderedSession { } pub fn set_cell_pixel_size(&self, width: u16, height: u16) { + let ownership = self.surface_resize_ownership.clone(); self.enqueue("set cell pixel size", move |session| { - session.set_cell_pixel_size(width, height) + let accepted = session.set_cell_pixel_size(width, height)?; + let mut ownership = ownership.lock().unwrap(); + for (surface, desired) in accepted { + ownership.insert(surface, desired); + } + Ok(()) }); } @@ -3705,8 +3724,13 @@ impl App { rows, reassert, _claim: Some(Box::new(claim)), - on_dispatch: Some(Box::new(move || { - ownership.lock().unwrap().insert(surface_id, (cols, rows)); + on_result: Some(Box::new(move |accepted| { + record_surface_resize_dispatch_result( + &ownership, + surface_id, + (cols, rows), + accepted, + ); })), }, }); @@ -6556,7 +6580,8 @@ mod tests { PtyFailureIngress, RenderAction, Selection, SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, SidebarPluginSyncState, SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, - pane_parts_for_rect, sidebar_plugin_status_settles_passive_claim, + pane_parts_for_rect, record_surface_resize_dispatch_result, + sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -7627,6 +7652,20 @@ mod tests { )); } + #[test] + fn browser_resize_ownership_tracks_only_accepted_dispatches() { + let ownership = Mutex::new(HashMap::new()); + + record_surface_resize_dispatch_result(&ownership, 7, (100, 30), false); + assert!(ownership.lock().unwrap().is_empty()); + + record_surface_resize_dispatch_result(&ownership, 7, (100, 30), true); + assert_eq!(ownership.lock().unwrap().get(&7), Some(&(100, 30))); + + record_surface_resize_dispatch_result(&ownership, 7, (100, 30), false); + assert!(ownership.lock().unwrap().is_empty()); + } + #[test] fn failed_surface_sync_is_bounded_until_lifecycle_recovery() { let mux = Mux::new("surface-sync-failure-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 795b4fec26d9..6fdd03566c4d 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -79,7 +79,7 @@ pub enum BrowserInputKind { rows: u16, reassert: bool, _claim: Option>, - on_dispatch: Option>, + on_result: Option>, }, Navigate(String), Back, @@ -340,12 +340,12 @@ fn worker( }) { continue; } - if let BrowserInputKind::Resize { on_dispatch, .. } = &mut event.event.kind - && let Some(mark_dispatched) = on_dispatch.take() + let result = dispatch(&event.event); + if let BrowserInputKind::Resize { on_result, .. } = &mut event.event.kind + && let Some(report_result) = on_result.take() { - mark_dispatched(); + report_result(result.as_ref().copied().unwrap_or(false)); } - let result = dispatch(&event.event); let Some((cols, rows)) = desired else { if event.event.kind.is_control() && let Err(error) = result @@ -358,7 +358,7 @@ fn worker( continue; } match result { - Ok(()) => { + Ok(_) => { failed_resizes.lock().unwrap().remove(&event.event.surface_id); } Err(error) => { @@ -446,13 +446,15 @@ fn coalesce_sequenced_browser_events(batch: &mut Vec } } -fn dispatch(event: &BrowserInputEvent) -> anyhow::Result<()> { +fn dispatch(event: &BrowserInputEvent) -> anyhow::Result { let surface = &event.surface; match &event.kind { BrowserInputKind::Mouse { event_type, x, y, button, click_count } => { - surface.browser_mouse_event(event_type, *x, *y, *button, *click_count) + surface.browser_mouse_event(event_type, *x, *y, *button, *click_count).map(|()| true) + } + BrowserInputKind::Wheel { x, y, delta_y } => { + surface.browser_wheel(*x, *y, *delta_y).map(|()| true) } - BrowserInputKind::Wheel { x, y, delta_y } => surface.browser_wheel(*x, *y, *delta_y), BrowserInputKind::Key { event_type, key, @@ -460,15 +462,10 @@ fn dispatch(event: &BrowserInputEvent) -> anyhow::Result<()> { windows_virtual_key_code, modifiers, text, - } => surface.browser_key_event( - event_type, - key, - code, - *windows_virtual_key_code, - *modifiers, - *text, - ), - BrowserInputKind::InsertText(text) => surface.browser_insert_text(text), + } => surface + .browser_key_event(event_type, key, code, *windows_virtual_key_code, *modifiers, *text) + .map(|()| true), + BrowserInputKind::InsertText(text) => surface.browser_insert_text(text).map(|()| true), BrowserInputKind::Resize { cols, rows, reassert, .. } => { if *reassert { surface.reassert_size(*cols, *rows) @@ -476,11 +473,11 @@ fn dispatch(event: &BrowserInputEvent) -> anyhow::Result<()> { surface.resize(*cols, *rows) } } - BrowserInputKind::Navigate(url) => surface.browser_navigate(url), - BrowserInputKind::Back => surface.browser_back(), - BrowserInputKind::Forward => surface.browser_forward(), - BrowserInputKind::Reload => surface.browser_reload(), - BrowserInputKind::Activate => surface.browser_activate(), + BrowserInputKind::Navigate(url) => surface.browser_navigate(url).map(|()| true), + BrowserInputKind::Back => surface.browser_back().map(|()| true), + BrowserInputKind::Forward => surface.browser_forward().map(|()| true), + BrowserInputKind::Reload => surface.browser_reload().map(|()| true), + BrowserInputKind::Activate => surface.browser_activate().map(|()| true), } } @@ -534,7 +531,7 @@ mod tests { rows: 24, reassert: false, _claim: None, - on_dispatch: None, + on_result: None, }, } } @@ -552,7 +549,7 @@ mod tests { rows: 24, reassert: false, _claim: Some(Box::new(DropProbe(dropped))), - on_dispatch: None, + on_result: None, }, } } @@ -762,15 +759,21 @@ mod tests { ) .unwrap(); - let dispatched = Arc::new(AtomicBool::new(false)); + let callback_called = Arc::new(AtomicBool::new(false)); + let accepted = Arc::new(AtomicBool::new(true)); let mut first = resize_event(7, 100); - if let BrowserInputKind::Resize { on_dispatch, .. } = &mut first.kind { - let dispatched = dispatched.clone(); - *on_dispatch = Some(Box::new(move || dispatched.store(true, Ordering::Release))); + if let BrowserInputKind::Resize { on_result, .. } = &mut first.kind { + let callback_called = callback_called.clone(); + let accepted_result = accepted.clone(); + *on_result = Some(Box::new(move |was_accepted| { + accepted_result.store(was_accepted, Ordering::Release); + callback_called.store(true, Ordering::Release); + })); } let _ = dispatcher.enqueue(first); let failure = failure_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(dispatched.load(Ordering::Acquire)); + assert!(callback_called.load(Ordering::Acquire)); + assert!(!accepted.load(Ordering::Acquire)); assert_eq!((failure.surface_id, failure.cols, failure.rows), (7, 100, 24)); assert!(dispatcher.resize_failed(7, (100, 24))); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 583402e3d884..a4f24e7652e3 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -41,12 +41,6 @@ pub(crate) fn is_remote_timeout(error: &anyhow::Error) -> bool { .is_some_and(remote::RemoteRequestError::is_timeout) } -pub(crate) fn is_remote_retryable(error: &anyhow::Error) -> bool { - error - .downcast_ref::() - .is_some_and(remote::RemoteRequestError::is_retryable) -} - #[cfg(test)] pub(crate) fn test_remote_timeout_error() -> anyhow::Error { remote::RemoteRequestError::Timeout.into() @@ -397,12 +391,13 @@ impl Session { } } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result<()> { + pub fn set_cell_pixel_size( + &self, + width_px: u16, + height_px: u16, + ) -> anyhow::Result> { match self { - Session::Local(mux) => { - mux.set_cell_pixel_size(width_px, height_px); - Ok(()) - } + Session::Local(mux) => Ok(mux.set_cell_pixel_size(width_px, height_px)), Session::Remote(remote) => remote.set_cell_pixel_size(width_px, height_px), } } @@ -699,13 +694,10 @@ impl SurfaceHandle { } } - pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> { + pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { let desired = (cols.max(1), rows.max(1)); match self { - SurfaceHandle::Local(surface) => { - let _ = surface.resize(desired.0, desired.1)?; - Ok(()) - } + SurfaceHandle::Local(surface) => surface.resize(desired.0, desired.1), SurfaceHandle::Remote(surface, session) => { if resize_action(desired, surface.asserted_size(), surface.server_size(), false) { let response = session.request(json!({ @@ -714,17 +706,16 @@ impl SurfaceHandle { "cols": desired.0, "rows": desired.1, }))?; - if response.get("accepted").and_then(serde_json::Value::as_bool) == Some(false) - && surface.server_size() != desired - { - return Err(remote::RemoteRequestError::Retryable( - "browser resize is waiting for retry".to_string(), - ) - .into()); + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + if accepted { + surface.set_asserted_size(desired); } - surface.set_asserted_size(desired); + return Ok(accepted); } - Ok(()) + Ok(false) } SurfaceHandle::RemoteBrowserUnsupported => { anyhow::bail!("browser surface is unavailable") @@ -746,13 +737,10 @@ impl SurfaceHandle { } } - pub fn reassert_size(&self, cols: u16, rows: u16) -> anyhow::Result<()> { + pub fn reassert_size(&self, cols: u16, rows: u16) -> anyhow::Result { let desired = (cols.max(1), rows.max(1)); match self { - SurfaceHandle::Local(surface) => { - let _ = surface.resize(desired.0, desired.1)?; - Ok(()) - } + SurfaceHandle::Local(surface) => surface.resize(desired.0, desired.1), SurfaceHandle::Remote(surface, session) => { if resize_action(desired, surface.asserted_size(), surface.server_size(), true) { let response = session.request(json!({ @@ -761,17 +749,16 @@ impl SurfaceHandle { "cols": desired.0, "rows": desired.1, }))?; - if response.get("accepted").and_then(serde_json::Value::as_bool) == Some(false) - && surface.server_size() != desired - { - return Err(remote::RemoteRequestError::Retryable( - "browser resize is waiting for retry".to_string(), - ) - .into()); + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + if accepted { + surface.set_asserted_size(desired); } + return Ok(accepted); } - surface.set_asserted_size(desired); - Ok(()) + Ok(false) } SurfaceHandle::RemoteBrowserUnsupported => { anyhow::bail!("browser surface is unavailable") diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index aae2a9ed7ca9..ba24212c0793 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -36,7 +36,6 @@ pub(crate) enum RemoteRequestError { Encode(serde_json::Error), Transport(std::io::Error), Timeout, - Retryable(String), Rejected(String), Shutdown, } @@ -50,9 +49,6 @@ impl RemoteRequestError { matches!(self, Self::Timeout) } - pub(crate) fn is_retryable(&self) -> bool { - matches!(self, Self::Retryable(_)) - } } impl std::fmt::Display for RemoteRequestError { @@ -61,7 +57,6 @@ impl std::fmt::Display for RemoteRequestError { Self::Encode(error) => write!(formatter, "could not encode remote request: {error}"), Self::Transport(error) => write!(formatter, "remote transport write failed: {error}"), Self::Timeout => write!(formatter, "remote session did not respond"), - Self::Retryable(error) => write!(formatter, "{error}"), Self::Rejected(error) => write!(formatter, "remote command rejected: {error}"), Self::Shutdown => write!(formatter, "remote response wait canceled for shutdown"), } @@ -758,13 +753,31 @@ impl RemoteSession { } } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result<()> { - self.request(json!({ + pub fn set_cell_pixel_size( + &self, + width_px: u16, + height_px: u16, + ) -> anyhow::Result> { + let response = self.request(json!({ "cmd": "set-cell-pixels", "width_px": width_px, "height_px": height_px, - })) - .map(|_| ()) + }))?; + Ok(response + .get("resizes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|resize| { + Some(( + resize.get("surface")?.as_u64()?, + ( + u16::try_from(resize.get("cols")?.as_u64()?).ok()?, + u16::try_from(resize.get("rows")?.as_u64()?).ok()?, + ), + )) + }) + .collect()) } pub fn set_default_colors(&self, colors: DefaultColors) -> anyhow::Result<()> { From c2f27eb9e76e4101968ca4b6c2d2cca9eb7982be Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:41:58 -0700 Subject: [PATCH 097/103] Close terminal and resize publication races --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 66 +++++++++++++++++-- cmux-tui/crates/cmux-tui-core/src/surface.rs | 16 +++++ .../cmux-tui-core/tests/browser_runtime.rs | 4 +- cmux-tui/crates/cmux-tui/src/app.rs | 32 ++++++--- cmux-tui/crates/cmux-tui/src/browser_input.rs | 13 ++-- cmux-tui/crates/cmux-tui/src/session/mod.rs | 20 ++++++ .../crates/cmux-tui/src/session/remote.rs | 1 - 7 files changed, 130 insertions(+), 22 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 12582e2a1cc8..a26e9dd55c64 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -995,11 +995,24 @@ impl BrowserSurface { } pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { + self.resize_reporting_acceptance(cols, rows, Box::new(|_| {})) + } + + pub fn resize_reporting_acceptance( + &self, + cols: u16, + rows: u16, + report: Box, + ) -> anyhow::Result { let (cols, rows) = (cols.max(1), rows.max(1)); if !self.resize_needed(cols, rows) { + report(false); return Ok(false); } - self.enqueue_latest_reconfigure(BrowserCommand::Reconfigure { cols, rows })?; + self.enqueue_latest_reconfigure_reporting( + BrowserCommand::Reconfigure { cols, rows }, + report, + )?; Ok(true) } @@ -1365,12 +1378,37 @@ impl BrowserSurface { } } - fn enqueue_latest_reconfigure(&self, command: BrowserCommand) -> anyhow::Result<()> { + fn enqueue_latest_reconfigure_reporting( + &self, + command: BrowserCommand, + report: Box, + ) -> anyhow::Result<()> { if self.is_dead() { + report(false); anyhow::bail!("browser surface is closed"); } - *self.latest_reconfigure.lock().unwrap() = Some(command); - self.wake_worker() + let tx = match self.command_sender() { + Ok(tx) => tx, + Err(error) => { + report(false); + return Err(error); + } + }; + let mut latest = self.latest_reconfigure.lock().unwrap(); + *latest = Some(command); + match tx.try_send(BrowserCommand::WakeLatest) { + Ok(()) | Err(TrySendError::Full(_)) => { + // The worker must acquire `latest_reconfigure` before it can + // publish completion or failure, so ownership is visible first. + report(true); + Ok(()) + } + Err(TrySendError::Disconnected(_)) => { + *latest = None; + report(false); + anyhow::bail!("browser command worker is closed") + } + } } fn enqueue_latest_nav(&self, command: BrowserCommand) -> anyhow::Result<()> { @@ -2499,6 +2537,26 @@ mod tests { assert!(!browser.set_cell_pixel_size(9, 16).unwrap()); } + #[test] + fn resize_acceptance_is_reported_before_worker_can_take_command() { + let surface = test_surface(); + let browser = surface.as_browser().expect("browser surface"); + let latest = browser.latest_reconfigure.clone(); + + assert!( + browser + .resize_reporting_acceptance( + 11, + 5, + Box::new(move |accepted| { + assert!(accepted); + assert!(latest.try_lock().is_err()); + }), + ) + .unwrap() + ); + } + #[test] fn pending_browser_resize_suppresses_duplicates_until_reconfigure_completes() { let opts = SurfaceOptions::default(); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 15f149e2674c..708a4e423545 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -711,6 +711,22 @@ impl Surface { } } + pub fn resize_reporting_acceptance( + &self, + cols: u16, + rows: u16, + report: Box, + ) -> anyhow::Result { + match self { + Surface::Pty(pty) => { + let accepted = pty.resize(cols, rows); + report(accepted); + Ok(accepted) + } + Surface::Browser(browser) => browser.resize_reporting_acceptance(cols, rows, report), + } + } + pub fn resize_needed(&self, cols: u16, rows: u16) -> bool { let desired = (cols.max(1), rows.max(1)); match self { diff --git a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs index 9aa40df85edc..673b504a0848 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs @@ -443,9 +443,7 @@ fn socket_browser_attach_streams_frames_input_and_cell_pixels() { ); assert_eq!(metrics["ok"], true); assert!(metrics["data"]["resizes"].as_array().is_some_and(|resizes| { - resizes.iter().any(|resize| { - resize == &json!({"surface": surface, "cols": 10, "rows": 5}) - }) + resizes.iter().any(|resize| resize == &json!({"surface": surface, "cols": 10, "rows": 5})) })); let metrics_request = recv_method_where(&seen_rx, "Emulation.setDeviceMetricsOverride", |value| { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index d519a83758bb..fbf9fa75d227 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -208,8 +208,11 @@ fn forward_mux_event( _ => {} } let terminal = matches!(event, MuxEvent::Empty); + if terminal { + let _ = tx.send(AppEvent::Mux(event)); + return ForwardMuxOutcome::Stop; + } match tx.try_send(AppEvent::Mux(event)) { - Ok(()) if terminal => ForwardMuxOutcome::Stop, Ok(()) => ForwardMuxOutcome::Continue, Err(TrySendError::Full(_)) => ForwardMuxOutcome::Recover, Err(TrySendError::Disconnected(_)) => ForwardMuxOutcome::Stop, @@ -6575,13 +6578,13 @@ fn browser_key_mapping( #[cfg(test)] mod tests { use super::{ - App, AppEvent, BACKGROUND_REFRESH_RETRIES, DeferredInput, Drag, MuxTitleIngress, - OrderedSession, PaneArea, PendingSessionMutation, PendingSessionMutationState, - PtyFailureIngress, RenderAction, Selection, SessionCompletion, SessionCompletionAction, - SidebarPluginSyncClaim, SidebarPluginSyncState, SurfaceResizeDecision, - browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_events, - pane_parts_for_rect, record_surface_resize_dispatch_result, - sidebar_plugin_status_settles_passive_claim, + App, AppEvent, BACKGROUND_REFRESH_RETRIES, DeferredInput, Drag, ForwardMuxOutcome, + MuxTitleIngress, OrderedSession, PaneArea, PendingSessionMutation, + PendingSessionMutationState, PtyFailureIngress, RenderAction, Selection, SessionCompletion, + SessionCompletionAction, SidebarPluginSyncClaim, SidebarPluginSyncState, + SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, + forward_mux_event, forward_mux_events, pane_parts_for_rect, + record_surface_resize_dispatch_result, sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -7483,6 +7486,19 @@ mod tests { forwarder.join().unwrap(); } + #[test] + fn mux_forwarder_preserves_empty_while_app_channel_is_full() { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + tx.send(AppEvent::Mux(MuxEvent::Bell(1))).unwrap(); + let titles = MuxTitleIngress::default(); + let forwarder = + std::thread::spawn(move || forward_mux_event(MuxEvent::Empty, &tx, &titles)); + + assert!(matches!(rx.recv().unwrap(), AppEvent::Mux(MuxEvent::Bell(1)))); + assert!(matches!(rx.recv().unwrap(), AppEvent::Mux(MuxEvent::Empty))); + assert!(matches!(forwarder.join().unwrap(), ForwardMuxOutcome::Stop)); + } + #[test] fn mux_recovery_barrier_defers_input_until_authoritative_tree_is_applied() { let mux = Mux::new("mux-recovery-barrier-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index 6fdd03566c4d..ffd35d63c53f 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -340,12 +340,13 @@ fn worker( }) { continue; } - let result = dispatch(&event.event); - if let BrowserInputKind::Resize { on_result, .. } = &mut event.event.kind - && let Some(report_result) = on_result.take() - { - report_result(result.as_ref().copied().unwrap_or(false)); - } + let result = match &mut event.event.kind { + BrowserInputKind::Resize { cols, rows, reassert, on_result, .. } => { + let report = on_result.take().unwrap_or_else(|| Box::new(|_| {})); + event.event.surface.resize_reporting_acceptance(*cols, *rows, *reassert, report) + } + _ => dispatch(&event.event), + }; let Some((cols, rows)) = desired else { if event.event.kind.is_control() && let Err(error) = result diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index a4f24e7652e3..136456abbb18 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -723,6 +723,26 @@ impl SurfaceHandle { } } + pub fn resize_reporting_acceptance( + &self, + cols: u16, + rows: u16, + reassert: bool, + report: Box, + ) -> anyhow::Result { + match self { + SurfaceHandle::Local(surface) => { + surface.resize_reporting_acceptance(cols, rows, report) + } + _ => { + let result = + if reassert { self.reassert_size(cols, rows) } else { self.resize(cols, rows) }; + report(result.as_ref().copied().unwrap_or(false)); + result + } + } + } + pub fn resize_needed(&self, cols: u16, rows: u16, user_interaction: bool) -> bool { let desired = (cols.max(1), rows.max(1)); match self { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index ba24212c0793..6f030efeae59 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -48,7 +48,6 @@ impl RemoteRequestError { pub(crate) fn is_timeout(&self) -> bool { matches!(self, Self::Timeout) } - } impl std::fmt::Display for RemoteRequestError { From c109013b6b2e5ffe52cceb89e28ca40996c43d9a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:00:11 -0700 Subject: [PATCH 098/103] Preserve browser resize barriers --- cmux-tui/bindings/typescript/README.md | 4 +- cmux-tui/bindings/typescript/src/client.ts | 14 ++ .../bindings/typescript/test/client.test.ts | 19 +++ cmux-tui/crates/cmux-tui-core/src/browser.rs | 159 ++++++++++-------- 4 files changed, 123 insertions(+), 73 deletions(-) diff --git a/cmux-tui/bindings/typescript/README.md b/cmux-tui/bindings/typescript/README.md index c1c958f9b372..4dc1d55c12f6 100644 --- a/cmux-tui/bindings/typescript/README.md +++ b/cmux-tui/bindings/typescript/README.md @@ -81,7 +81,9 @@ await client.close(); `new CmuxClient()` uses `CMUX_TUI_SOCKET`, then legacy `CMUX_MUX_SOCKET`, then the default session socket. Unix subscribe and attach streams retain dedicated -connections; an injected transport can multiplex them on its main connection. +connections. An injected transport can multiplex attach streams and one +subscription on its main connection; concurrent subscriptions require a +`streamTransportFactory` because overflow events are terminal to one stream. ## Raw typed requests diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index ab3d7a637ff4..3bdea72fb91a 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -276,6 +276,7 @@ export class CmuxClient { private readonly streamTransportFactory?: () => Transport; private nextRequestId = 1; private protocol: number | null = null; + private sharedSubscriptionActive = false; constructor(options: CmuxClientOptions) { this.transport = options.transport; @@ -398,6 +399,7 @@ export class CmuxClient { (event, dedicated) => dedicated || (!this.attachOnlyEvent(event.event) && !this.isSurfaceOverflow(event)), (event) => event.event === "overflow" && !this.isSurfaceOverflow(event), + true, ); } @@ -445,8 +447,17 @@ export class CmuxClient { map: (event: UnknownEvent) => T, accept: (event: UnknownEvent, dedicated: boolean) => boolean, terminal: (event: T) => boolean = () => false, + exclusiveSharedSubscription = false, ): Promise> { const dedicated = this.streamTransportFactory !== undefined; + if (exclusiveSharedSubscription && !dedicated) { + if (this.sharedSubscriptionActive) { + throw new CmuxProtocolError( + "concurrent subscriptions require streamTransportFactory", + ); + } + this.sharedSubscriptionActive = true; + } const transport = this.streamTransportFactory?.() ?? this.transport; const router = dedicated ? new MessageRouter(transport) : this.router; let eventSubscription: Unsubscribe = () => undefined; @@ -454,6 +465,9 @@ export class CmuxClient { const stream = new CmuxStream(this.timeoutMs, () => { eventSubscription(); terminalSubscription(); + if (exclusiveSharedSubscription && !dedicated) { + this.sharedSubscriptionActive = false; + } if (dedicated) transport.close(); }); eventSubscription = router.onEvent((event) => { diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index 4bd3f81281fd..35b155932c9b 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -225,3 +225,22 @@ test("subscribe yields client attached, changed, and detached events", async () events.close(); await client.close(); }); + +test("concurrent shared subscriptions require dedicated transports", async () => { + const transport = new ScriptedTransport((request, connection) => { + assert.equal(request.cmd, "subscribe"); + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + const first = await client.subscribe(); + + await assert.rejects( + () => client.subscribe(), + /concurrent subscriptions require streamTransportFactory/, + ); + + first.close(); + const replacement = await client.subscribe(); + replacement.close(); + await client.close(); +}); diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index a26e9dd55c64..1f064cb998c9 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -165,6 +165,7 @@ enum BrowserCommand { Reconfigure { cols: u16, rows: u16, + report: Option>, }, #[cfg(test)] Hold { @@ -189,6 +190,14 @@ impl BrowserCommand { } } +fn reject_reconfigure(mut command: BrowserCommand) { + if let BrowserCommand::Reconfigure { report, .. } = &mut command + && let Some(report) = report.take() + { + report(false); + } +} + #[derive(Default)] struct BrowserWorkerErrorState { consecutive_timeouts: u8, @@ -218,7 +227,6 @@ pub struct BrowserSurface { cell_pixels: Mutex<(u16, u16)>, capture_options: BrowserCaptureOptions, command_tx: Mutex>>, - latest_reconfigure: Arc>>, latest_nav: Arc>>, #[cfg(test)] worker_done: Mutex>>, @@ -393,7 +401,6 @@ pub(crate) fn new_surface( let capture_scale = capture_scale_for(pixel_w, pixel_h, capture_options); let capture_pixels = scaled_pixels(pixel_w, pixel_h, capture_scale); let (command_tx, command_rx) = sync_channel(BROWSER_COMMAND_QUEUE_CAPACITY); - let latest_reconfigure = Arc::new(Mutex::new(None)); let latest_nav = Arc::new(Mutex::new(None)); #[cfg(test)] let (worker_done_tx, worker_done_rx) = std::sync::mpsc::channel(); @@ -429,19 +436,11 @@ pub(crate) fn new_surface( cell_pixels: Mutex::new((cell_w, cell_h)), capture_options, command_tx: Mutex::new(Some(command_tx)), - latest_reconfigure: latest_reconfigure.clone(), latest_nav: latest_nav.clone(), #[cfg(test)] worker_done: Mutex::new(Some(worker_done_rx)), })); - start_browser_worker( - surface.clone(), - command_rx, - latest_reconfigure, - latest_nav, - mux, - worker_done_tx, - ); + start_browser_worker(surface.clone(), command_rx, latest_nav, mux, worker_done_tx); surface } @@ -726,7 +725,6 @@ fn start_surface_thread( fn start_browser_worker( surface: Arc, rx: Receiver, - latest_reconfigure: Arc>>, latest_nav: Arc>>, mux: Weak, done_tx: Option>, @@ -743,15 +741,14 @@ fn start_browser_worker( coalesce_worker_mouse_moves(&mut batch); for command in batch { if matches!(command, BrowserCommand::WakeLatest) { - for command in take_latest_worker_commands(&latest_reconfigure, &latest_nav) - { + if let Some(command) = take_latest_worker_commands(&latest_nav) { run_browser_worker_command(&surface, command, &mux, id, &mut failures); } } else { run_browser_worker_command(&surface, command, &mux, id, &mut failures); } } - for command in take_latest_worker_commands(&latest_reconfigure, &latest_nav) { + if let Some(command) = take_latest_worker_commands(&latest_nav) { run_browser_worker_command(&surface, command, &mux, id, &mut failures); } } @@ -762,12 +759,9 @@ fn start_browser_worker( } fn take_latest_worker_commands( - latest_reconfigure: &Arc>>, latest_nav: &Arc>>, -) -> Vec { - let reconfigure = latest_reconfigure.lock().unwrap().take(); - let nav = latest_nav.lock().unwrap().take(); - reconfigure.into_iter().chain(nav).collect() +) -> Option { + latest_nav.lock().unwrap().take() } fn coalesce_worker_mouse_moves(batch: &mut Vec) { @@ -783,15 +777,20 @@ fn coalesce_worker_mouse_moves(batch: &mut Vec) { fn run_browser_worker_command( surface: &Surface, - command: BrowserCommand, + mut command: BrowserCommand, mux: &Weak, id: SurfaceId, failures: &mut BrowserWorkerErrorState, ) { + if let BrowserCommand::Reconfigure { report, .. } = &mut command + && let Some(report) = report.take() + { + report(true); + } let is_input = command.is_input(); let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); let reconfigure_size = match &command { - BrowserCommand::Reconfigure { cols, rows } => Some((*cols, *rows)), + BrowserCommand::Reconfigure { cols, rows, .. } => Some((*cols, *rows)), _ => None, }; let mut resize_changed = false; @@ -826,7 +825,7 @@ fn run_browser_worker_command( BrowserCommand::Forward => browser.forward_blocking(), BrowserCommand::Reload => browser.reload_blocking(), BrowserCommand::Activate => browser.activate_blocking(), - BrowserCommand::Reconfigure { cols, rows } => { + BrowserCommand::Reconfigure { cols, rows, .. } => { browser.reconfigure_resize_blocking(cols, rows).map(|changed| { resize_changed = changed; }) @@ -1009,10 +1008,7 @@ impl BrowserSurface { report(false); return Ok(false); } - self.enqueue_latest_reconfigure_reporting( - BrowserCommand::Reconfigure { cols, rows }, - report, - )?; + self.enqueue_reconfigure(BrowserCommand::Reconfigure { cols, rows, report: Some(report) })?; Ok(true) } @@ -1378,34 +1374,26 @@ impl BrowserSurface { } } - fn enqueue_latest_reconfigure_reporting( - &self, - command: BrowserCommand, - report: Box, - ) -> anyhow::Result<()> { + fn enqueue_reconfigure(&self, command: BrowserCommand) -> anyhow::Result<()> { if self.is_dead() { - report(false); + reject_reconfigure(command); anyhow::bail!("browser surface is closed"); } let tx = match self.command_sender() { Ok(tx) => tx, Err(error) => { - report(false); + reject_reconfigure(command); return Err(error); } }; - let mut latest = self.latest_reconfigure.lock().unwrap(); - *latest = Some(command); - match tx.try_send(BrowserCommand::WakeLatest) { - Ok(()) | Err(TrySendError::Full(_)) => { - // The worker must acquire `latest_reconfigure` before it can - // publish completion or failure, so ownership is visible first. - report(true); - Ok(()) + match tx.try_send(command) { + Ok(()) => Ok(()), + Err(TrySendError::Full(command)) => { + reject_reconfigure(command); + anyhow::bail!("browser command queue is full; browser may be unresponsive") } - Err(TrySendError::Disconnected(_)) => { - *latest = None; - report(false); + Err(TrySendError::Disconnected(command)) => { + reject_reconfigure(command); anyhow::bail!("browser command worker is closed") } } @@ -1795,10 +1783,11 @@ mod tests { capture_scale_for, new_surface, normalize_url, runtime_endpoint, scaled_pixels, take_latest_worker_commands, }; - use crate::{Mux, Surface, SurfaceOptions}; + use crate::{Mux, MuxEvent, Surface, SurfaceOptions}; use serde_json::{Value, json}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Weak, mpsc}; use std::thread; use std::time::{Duration, Instant}; @@ -2162,25 +2151,15 @@ mod tests { } #[test] - fn latest_reconfigure_and_nav_slots_do_not_clobber_each_other() { - let latest_reconfigure = - Arc::new(Mutex::new(Some(BrowserCommand::Reconfigure { cols: 111, rows: 222 }))); + fn latest_navigation_slot_drains_once() { let latest_nav = Arc::new(Mutex::new(Some(BrowserCommand::Navigate("https://next.test".to_string())))); - let commands = take_latest_worker_commands(&latest_reconfigure, &latest_nav); - assert_eq!(commands.len(), 2); - match &commands[0] { - BrowserCommand::Reconfigure { cols, rows } => { - assert_eq!((*cols, *rows), (111, 222)); - } - _ => panic!("reconfigure must drain before nav"), - } - match &commands[1] { + let command = take_latest_worker_commands(&latest_nav).expect("pending navigation"); + match &command { BrowserCommand::Navigate(url) => assert_eq!(url, "https://next.test"), _ => panic!("nav command was lost"), } - assert!(latest_reconfigure.lock().unwrap().is_none()); assert!(latest_nav.lock().unwrap().is_none()); } @@ -2196,10 +2175,19 @@ mod tests { } #[test] - fn browser_resize_is_nonblocking_and_latest_wins() { - let surface = test_surface(); + fn browser_resizes_preserve_input_barriers_and_completion() { + let mux = Mux::new("ordered-browser-resize-test", SurfaceOptions::default()); + let surface = new_surface( + 1, + "https://example.test".into(), + (10, 5), + (8, 16), + &SurfaceOptions::default(), + Arc::downgrade(&mux), + ); let browser = surface.as_browser().expect("browser surface"); let done = browser.take_worker_done_for_test(); + let events = mux.subscribe(); let (entered, started) = mpsc::channel(); let (release, held) = mpsc::channel(); browser @@ -2210,13 +2198,20 @@ mod tests { started.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(browser.resize(11, 5).unwrap()); + browser.mouse_event("mousePressed", 1.0, 1.0, Some("left"), Some(1)).unwrap(); assert!(browser.resize(12, 6).unwrap()); - assert!(matches!( - browser.latest_reconfigure.lock().unwrap().as_ref(), - Some(BrowserCommand::Reconfigure { cols: 12, rows: 6 }) - )); release.send(()).unwrap(); + let resized = (0..2) + .map(|_| { + loop { + if let MuxEvent::SurfaceResized { cols, rows, .. } = events.recv().unwrap() { + break (cols, rows); + } + } + }) + .collect::>(); + assert_eq!(resized, vec![(11, 5), (12, 6)]); browser.kill(); done.recv_timeout(Duration::from_secs(1)).expect("browser worker exited after release"); } @@ -2239,7 +2234,7 @@ mod tests { ); assert!(matches!( events.recv_timeout(Duration::from_secs(1)).unwrap(), - crate::MuxEvent::Status(message) if message == "CDP call Page.navigate timed out" + MuxEvent::Status(message) if message == "CDP call Page.navigate timed out" )); while events.try_recv().is_ok() {} @@ -2253,7 +2248,7 @@ mod tests { ); assert!(matches!( events.recv_timeout(Duration::from_secs(1)).unwrap(), - crate::MuxEvent::Status(message) if message == super::BROWSER_NOT_RESPONDING_MESSAGE + MuxEvent::Status(message) if message == super::BROWSER_NOT_RESPONDING_MESSAGE )); while events.try_recv().is_ok() {} @@ -2297,7 +2292,7 @@ mod tests { ); assert!(matches!( events.recv_timeout(Duration::from_secs(1)).unwrap(), - crate::MuxEvent::Status(message) if message == super::BROWSER_NOT_RESPONDING_MESSAGE + MuxEvent::Status(message) if message == super::BROWSER_NOT_RESPONDING_MESSAGE )); assert_eq!( browser.status(), @@ -2318,7 +2313,7 @@ mod tests { ); assert!(matches!( events.recv_timeout(Duration::from_secs(1)).unwrap(), - crate::MuxEvent::Status(message) if message == super::BROWSER_NOT_RESPONDING_MESSAGE + MuxEvent::Status(message) if message == super::BROWSER_NOT_RESPONDING_MESSAGE )); assert_eq!( browser.status(), @@ -2538,10 +2533,20 @@ mod tests { } #[test] - fn resize_acceptance_is_reported_before_worker_can_take_command() { + fn resize_acceptance_is_reported_by_worker_before_execution() { let surface = test_surface(); let browser = surface.as_browser().expect("browser surface"); - let latest = browser.latest_reconfigure.clone(); + let done = browser.take_worker_done_for_test(); + let (entered, started) = mpsc::channel(); + let (release, held) = mpsc::channel(); + browser + .command_sender() + .unwrap() + .send(BrowserCommand::Hold { entered, release: held }) + .unwrap(); + started.recv_timeout(Duration::from_secs(1)).unwrap(); + let accepted = Arc::new(AtomicBool::new(false)); + let reported = accepted.clone(); assert!( browser @@ -2550,11 +2555,21 @@ mod tests { 5, Box::new(move |accepted| { assert!(accepted); - assert!(latest.try_lock().is_err()); + reported.store(true, Ordering::Release); }), ) .unwrap() ); + assert!(!accepted.load(Ordering::Acquire)); + + release.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while !accepted.load(Ordering::Acquire) && Instant::now() < deadline { + thread::yield_now(); + } + assert!(accepted.load(Ordering::Acquire)); + browser.kill(); + done.recv_timeout(Duration::from_secs(1)).expect("browser worker exited after release"); } #[test] From 594215867d033ef883a9978118a92ae1d0cf9ca1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:14:26 -0700 Subject: [PATCH 099/103] Own browser resize lifecycle in core --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 173 +++++++++++++------ cmux-tui/crates/cmux-tui-core/src/lib.rs | 3 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 25 ++- cmux-tui/crates/cmux-tui-core/src/surface.rs | 12 +- cmux-tui/crates/cmux-tui/src/app.rs | 13 +- cmux-tui/crates/cmux-tui/src/session/mod.rs | 18 +- 6 files changed, 174 insertions(+), 70 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 1f064cb998c9..2a2dd49b63bd 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, SyncSender, TrySendError, sync_channel}; @@ -107,7 +107,8 @@ struct BrowserState { pane_pixels: (u32, u32), capture_pixels: (u32, u32), capture_scale: f64, - pending_reconfigure: Option, + pending_reconfigures: VecDeque, + next_reconfigure_id: u64, reconfigure_failure: Option, page_viewport: Option<(u32, u32)>, status: BrowserStatus, @@ -127,6 +128,12 @@ struct BrowserGeometry { capture_scale: f64, } +#[derive(Clone, Copy, PartialEq)] +struct QueuedBrowserGeometry { + id: u64, + geometry: BrowserGeometry, +} + #[derive(Clone, Copy)] struct BrowserReconfigureFailure { geometry: BrowserGeometry, @@ -163,8 +170,7 @@ enum BrowserCommand { Reload, Activate, Reconfigure { - cols: u16, - rows: u16, + queued: QueuedBrowserGeometry, report: Option>, }, #[cfg(test)] @@ -190,12 +196,16 @@ impl BrowserCommand { } } -fn reject_reconfigure(mut command: BrowserCommand) { +fn reject_reconfigure(mut command: BrowserCommand) -> Option { if let BrowserCommand::Reconfigure { report, .. } = &mut command && let Some(report) = report.take() { report(false); } + match command { + BrowserCommand::Reconfigure { queued, .. } => Some(queued), + _ => None, + } } #[derive(Default)] @@ -420,7 +430,8 @@ pub(crate) fn new_surface( pane_pixels: (pixel_w, pixel_h), capture_pixels, capture_scale, - pending_reconfigure: None, + pending_reconfigures: VecDeque::new(), + next_reconfigure_id: 1, reconfigure_failure: None, page_viewport: None, status: BrowserStatus::Starting, @@ -789,11 +800,10 @@ fn run_browser_worker_command( } let is_input = command.is_input(); let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); - let reconfigure_size = match &command { - BrowserCommand::Reconfigure { cols, rows, .. } => Some((*cols, *rows)), + let reconfigure = match &command { + BrowserCommand::Reconfigure { queued, .. } => Some(*queued), _ => None, }; - let mut resize_changed = false; let result = { let Some(browser) = surface.as_browser() else { return; @@ -825,10 +835,8 @@ fn run_browser_worker_command( BrowserCommand::Forward => browser.forward_blocking(), BrowserCommand::Reload => browser.reload_blocking(), BrowserCommand::Activate => browser.activate_blocking(), - BrowserCommand::Reconfigure { cols, rows, .. } => { - browser.reconfigure_resize_blocking(cols, rows).map(|changed| { - resize_changed = changed; - }) + BrowserCommand::Reconfigure { queued, .. } => { + browser.reconfigure_reserved_blocking(queued) } #[cfg(test)] BrowserCommand::Hold { entered, release } => { @@ -838,19 +846,20 @@ fn run_browser_worker_command( } }; if is_reconfigure - && resize_changed && result.is_ok() && let Some(mux) = mux.upgrade() + && let Some(queued) = reconfigure { - let (cols, rows) = surface.size(); + let (cols, rows) = queued.geometry.size; mux.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); } - if let Some((cols, rows)) = reconfigure_size + if let Some(queued) = reconfigure && let Err(error) = &result && let Some(browser) = surface.as_browser() - && let Some((_, retry_delay)) = browser.fail_reconfigure((cols, rows)) + && let Some((_, retry_delay)) = browser.fail_reconfigure(queued) && let Some(mux) = mux.upgrade() { + let (cols, rows) = queued.geometry.size; mux.emit(MuxEvent::SurfaceResizeFailed { surface: id, cols, @@ -1004,60 +1013,79 @@ impl BrowserSurface { report: Box, ) -> anyhow::Result { let (cols, rows) = (cols.max(1), rows.max(1)); - if !self.resize_needed(cols, rows) { + let Some(queued) = self.reserve_reconfigure(cols, rows) else { report(false); return Ok(false); - } - self.enqueue_reconfigure(BrowserCommand::Reconfigure { cols, rows, report: Some(report) })?; + }; + self.enqueue_reconfigure(BrowserCommand::Reconfigure { queued, report: Some(report) })?; Ok(true) } - fn reconfigure_resize_blocking(&self, cols: u16, rows: u16) -> anyhow::Result { - let needed = self.resize_needed(cols, rows); - let Some((width, height)) = self.update_resize_state(cols, rows) else { - return Ok(false); - }; - self.reconfigure_blocking(width, height)?; - self.confirm_reconfigure(width, height); - Ok(needed) + fn reconfigure_reserved_blocking(&self, queued: QueuedBrowserGeometry) -> anyhow::Result<()> { + self.reconfigure_blocking( + queued.geometry.capture_pixels.0, + queued.geometry.capture_pixels.1, + )?; + self.confirm_reconfigure(queued); + Ok(()) } pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { + self.set_cell_pixel_size_reporting(width_px, height_px, Box::new(|_| {})) + } + + pub fn set_cell_pixel_size_reporting( + &self, + width_px: u16, + height_px: u16, + report: Box, + ) -> anyhow::Result { { let mut cell = self.cell_pixels.lock().unwrap(); let next = (width_px.max(1), height_px.max(1)); if *cell == next { + report(false); return Ok(false); } *cell = next; } let (cols, rows) = self.size(); - self.resize(cols, rows) + self.resize_reporting_acceptance(cols, rows, report) } - fn update_resize_state(&self, cols: u16, rows: u16) -> Option<(u32, u32)> { + fn reserve_reconfigure(&self, cols: u16, rows: u16) -> Option { let geometry = self.resize_geometry(cols, rows); let mut state = self.state.lock().unwrap(); - let unchanged = browser_geometry_locked(&state) == geometry; - if unchanged && state.pending_reconfigure.is_none() { + if state.pending_reconfigures.back().is_some_and(|queued| queued.geometry == geometry) + || state.pending_reconfigures.is_empty() && browser_geometry_locked(&state) == geometry + { return None; } - if state.reconfigure_failure.is_some_and(|failure| failure.geometry != geometry) { - state.reconfigure_failure = None; + if let Some(failure) = state.reconfigure_failure { + if failure.geometry == geometry { + if failure.retry_at.is_none_or(|retry_at| Instant::now() < retry_at) { + return None; + } + } else { + state.reconfigure_failure = None; + } } - state.pending_reconfigure = Some(geometry); - Some(geometry.capture_pixels) + let queued = QueuedBrowserGeometry { id: state.next_reconfigure_id, geometry }; + state.next_reconfigure_id = state.next_reconfigure_id.wrapping_add(1).max(1); + state.pending_reconfigures.push_back(queued); + Some(queued) } - fn confirm_reconfigure(&self, width: u32, height: u32) { + fn confirm_reconfigure(&self, queued: QueuedBrowserGeometry) { let mut state = self.state.lock().unwrap(); - let Some(geometry) = - state.pending_reconfigure.filter(|geometry| geometry.capture_pixels == (width, height)) + let Some(index) = + state.pending_reconfigures.iter().position(|pending| pending.id == queued.id) else { return; }; + state.pending_reconfigures.remove(index); + let geometry = queued.geometry; let changed = browser_geometry_locked(&state) != geometry; - state.pending_reconfigure = None; state.reconfigure_failure = None; state.size = geometry.size; state.pane_pixels = geometry.pane_pixels; @@ -1070,10 +1098,12 @@ impl BrowserSurface { } } - fn fail_reconfigure(&self, desired: (u16, u16)) -> Option<(u8, Option)> { + fn fail_reconfigure(&self, queued: QueuedBrowserGeometry) -> Option<(u8, Option)> { let mut state = self.state.lock().unwrap(); - let geometry = state.pending_reconfigure.filter(|geometry| geometry.size == desired)?; - state.pending_reconfigure = None; + let index = + state.pending_reconfigures.iter().position(|pending| pending.id == queued.id)?; + state.pending_reconfigures.remove(index); + let geometry = queued.geometry; let attempts = state .reconfigure_failure .filter(|failure| failure.geometry == geometry) @@ -1087,6 +1117,15 @@ impl BrowserSurface { Some((attempts, retry_delay)) } + fn release_reconfigure(&self, queued: QueuedBrowserGeometry) { + let mut state = self.state.lock().unwrap(); + if let Some(index) = + state.pending_reconfigures.iter().position(|pending| pending.id == queued.id) + { + state.pending_reconfigures.remove(index); + } + } + fn reconfigure_blocking(&self, width: u32, height: u32) -> anyhow::Result<()> { let Some(session) = self.live_session()? else { return Ok(()) }; session.runtime.client.set_device_metrics(&session.session_id, width, height)?; @@ -1101,7 +1140,7 @@ impl BrowserSurface { if state.reconfigure_failure.is_some_and(|failure| failure.geometry != geometry) { state.reconfigure_failure = None; } - if state.pending_reconfigure == Some(geometry) { + if state.pending_reconfigures.back().is_some_and(|queued| queued.geometry == geometry) { return false; } if let Some(failure) = state.reconfigure_failure @@ -1110,7 +1149,7 @@ impl BrowserSurface { { return false; } - browser_geometry_locked(&state) != geometry || state.pending_reconfigure.is_some() + browser_geometry_locked(&state) != geometry || !state.pending_reconfigures.is_empty() } fn resize_geometry(&self, cols: u16, rows: u16) -> BrowserGeometry { @@ -1376,24 +1415,32 @@ impl BrowserSurface { fn enqueue_reconfigure(&self, command: BrowserCommand) -> anyhow::Result<()> { if self.is_dead() { - reject_reconfigure(command); + if let Some(queued) = reject_reconfigure(command) { + self.release_reconfigure(queued); + } anyhow::bail!("browser surface is closed"); } let tx = match self.command_sender() { Ok(tx) => tx, Err(error) => { - reject_reconfigure(command); + if let Some(queued) = reject_reconfigure(command) { + self.release_reconfigure(queued); + } return Err(error); } }; match tx.try_send(command) { Ok(()) => Ok(()), Err(TrySendError::Full(command)) => { - reject_reconfigure(command); + if let Some(queued) = reject_reconfigure(command) { + self.release_reconfigure(queued); + } anyhow::bail!("browser command queue is full; browser may be unresponsive") } Err(TrySendError::Disconnected(command)) => { - reject_reconfigure(command); + if let Some(queued) = reject_reconfigure(command) { + self.release_reconfigure(queued); + } anyhow::bail!("browser command worker is closed") } } @@ -2494,7 +2541,7 @@ mod tests { } assert!(browser.frames_stalled_at(now)); - browser.reconfigure_resize_blocking(10, 5).unwrap(); + assert!(browser.reserve_reconfigure(10, 5).is_none()); { let state = browser.state.lock().unwrap(); assert_eq!(state.last_frame_at, Some(now - Duration::from_secs(3))); @@ -2502,7 +2549,8 @@ mod tests { } assert!(browser.frames_stalled_at(now)); - browser.reconfigure_resize_blocking(11, 5).unwrap(); + let queued = browser.reserve_reconfigure(11, 5).expect("changed geometry"); + browser.reconfigure_reserved_blocking(queued).unwrap(); let state = browser.state.lock().unwrap(); assert_eq!(state.last_frame_at, None); assert!(!state.stall_nudged); @@ -2561,6 +2609,17 @@ mod tests { .unwrap() ); assert!(!accepted.load(Ordering::Acquire)); + let (duplicate_tx, duplicate_rx) = mpsc::channel(); + assert!( + !browser + .resize_reporting_acceptance( + 11, + 5, + Box::new(move |accepted| duplicate_tx.send(accepted).unwrap()), + ) + .unwrap() + ); + assert!(!duplicate_rx.recv_timeout(Duration::from_secs(1)).unwrap()); release.send(()).unwrap(); let deadline = Instant::now() + Duration::from_secs(1); @@ -2580,12 +2639,13 @@ mod tests { let browser = surface.as_browser().expect("browser surface"); *browser.cell_pixels.lock().unwrap() = (9, 16); - let _ = browser.update_resize_state(10, 5).expect("changed geometry"); + let queued = browser.reserve_reconfigure(10, 5).expect("changed geometry"); assert!(!browser.resize_needed(10, 5)); + assert!(browser.reserve_reconfigure(10, 5).is_none()); - assert!(!browser.reconfigure_resize_blocking(10, 5).unwrap()); + browser.reconfigure_reserved_blocking(queued).unwrap(); assert!(!browser.resize_needed(10, 5)); - assert!(!browser.reconfigure_resize_blocking(10, 5).unwrap()); + assert!(browser.reserve_reconfigure(10, 5).is_none()); } #[test] @@ -2594,9 +2654,10 @@ mod tests { let browser = surface.as_browser().expect("browser surface"); for attempt in 1..=3 { - browser.update_resize_state(11, 5).expect("resize must enter pending state"); + let queued = + browser.reserve_reconfigure(11, 5).expect("resize must enter pending state"); let (recorded_attempt, retry_delay) = - browser.fail_reconfigure((11, 5)).expect("pending resize failure must be recorded"); + browser.fail_reconfigure(queued).expect("pending resize failure must be recorded"); assert_eq!(recorded_attempt, attempt); assert_eq!(retry_delay.is_some(), attempt < 3); assert!(!browser.resize_needed(11, 5)); diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index f9c7988d1413..8fdcc214d9aa 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -29,7 +29,8 @@ pub use model::{Node, Pane, Screen, State, Workspace}; pub use mux::{ AgentRecord, AgentSource, AgentState, AppliedLayout, AppliedPane, Direction, LayoutLeafSpec, LayoutSpec, Mux, MuxEvent, NotificationEvent, NotificationLevel, RunPlacement, - SidebarPluginOptions, SidebarPluginStatus, SurfaceNotification, ZoomMode, ZoomState, + SidebarPluginOptions, SidebarPluginStatus, SurfaceNotification, SurfaceResizeReporter, + ZoomMode, ZoomState, }; pub use short_id::assign_short_ids; pub use surface::{ diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 1e9e2ffb9649..bd1206af23c1 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -13,6 +13,8 @@ use crate::model::{Node, Pane, Screen, State, Workspace}; use crate::surface::{DefaultColors, Surface, SurfaceOptions}; use crate::{PaneId, ScreenId, SplitDir, SurfaceId, WorkspaceId}; +pub type SurfaceResizeReporter = Arc; + /// Events pushed to subscribed frontends. #[derive(Debug, Clone)] pub enum MuxEvent { @@ -734,6 +736,15 @@ impl Mux { &self, width_px: u16, height_px: u16, + ) -> Vec<(SurfaceId, (u16, u16))> { + self.set_cell_pixel_size_reporting(width_px, height_px, Arc::new(|_, _, _| {})) + } + + pub fn set_cell_pixel_size_reporting( + &self, + width_px: u16, + height_px: u16, + report: SurfaceResizeReporter, ) -> Vec<(SurfaceId, (u16, u16))> { let next = (width_px.max(1), height_px.max(1)); { @@ -746,8 +757,18 @@ impl Mux { let surfaces = self.state.lock().unwrap().surfaces.values().cloned().collect::>(); let mut accepted = Vec::new(); for surface in surfaces { - if surface.set_cell_pixel_size(next.0, next.1).unwrap_or(false) { - accepted.push((surface.id, surface.size())); + let id = surface.id; + let size = surface.size(); + let callback = report.clone(); + if surface + .set_cell_pixel_size_reporting( + next.0, + next.1, + Box::new(move |accepted| callback(id, size, accepted)), + ) + .unwrap_or(false) + { + accepted.push((id, size)); } } accepted diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 708a4e423545..7f2ac1a40419 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -736,9 +736,19 @@ impl Surface { } pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { + self.set_cell_pixel_size_reporting(width_px, height_px, Box::new(|_| {})) + } + + pub fn set_cell_pixel_size_reporting( + &self, + width_px: u16, + height_px: u16, + report: Box, + ) -> anyhow::Result { if let Some(browser) = self.as_browser() { - browser.set_cell_pixel_size(width_px, height_px) + browser.set_cell_pixel_size_reporting(width_px, height_px, report) } else { + report(false); Ok(false) } } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index fbf9fa75d227..947149b5322b 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -1345,12 +1345,13 @@ impl OrderedSession { pub fn set_cell_pixel_size(&self, width: u16, height: u16) { let ownership = self.surface_resize_ownership.clone(); self.enqueue("set cell pixel size", move |session| { - let accepted = session.set_cell_pixel_size(width, height)?; - let mut ownership = ownership.lock().unwrap(); - for (surface, desired) in accepted { - ownership.insert(surface, desired); - } - Ok(()) + session.set_cell_pixel_size( + width, + height, + Arc::new(move |surface, desired, accepted| { + record_surface_resize_dispatch_result(&ownership, surface, desired, accepted); + }), + ) }); } diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 136456abbb18..9bb00acf5cf5 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -15,7 +15,8 @@ use std::sync::atomic::Ordering; use cmux_tui_core::{ BrowserFrame, BrowserStatus, DefaultColors, Mux, MuxEventReceiver, PaneId, ScreenId, - SidebarPluginStatus, SplitDir, Surface, SurfaceId, SurfaceKind, WorkspaceId, ZoomMode, + SidebarPluginStatus, SplitDir, Surface, SurfaceId, SurfaceKind, SurfaceResizeReporter, + WorkspaceId, ZoomMode, }; use ghostty_vt::{RenderState, Terminal}; use serde_json::json; @@ -395,10 +396,19 @@ impl Session { &self, width_px: u16, height_px: u16, - ) -> anyhow::Result> { + report: SurfaceResizeReporter, + ) -> anyhow::Result<()> { match self { - Session::Local(mux) => Ok(mux.set_cell_pixel_size(width_px, height_px)), - Session::Remote(remote) => remote.set_cell_pixel_size(width_px, height_px), + Session::Local(mux) => { + mux.set_cell_pixel_size_reporting(width_px, height_px, report); + Ok(()) + } + Session::Remote(remote) => { + for (surface, desired) in remote.set_cell_pixel_size(width_px, height_px)? { + report(surface, desired, true); + } + Ok(()) + } } } From 3a10b3638d328f43c45a173793f3557103923569 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:48:09 -0700 Subject: [PATCH 100/103] Make async completions identity-safe --- cmux-tui/bindings/go/client_test.go | 10 + cmux-tui/bindings/go/types.go | 21 +- .../bindings/java/src/com/cmux/CmuxEvent.java | 6 +- .../src/com/cmux/ResizeSurfaceResult.java | 5 +- .../com/cmux/SurfaceResizeFailedEvent.java | 2 +- .../src/com/cmux/SurfaceResizedEvent.java | 2 +- .../java/tests/com/cmux/JsonTest.java | 2 + cmux-tui/bindings/python/cmux/client.py | 8 +- cmux-tui/bindings/python/tests/test_events.py | 2 + .../bindings/python/tests/test_protocol.py | 6 + cmux-tui/bindings/rust/src/lib.rs | 12 ++ cmux-tui/bindings/typescript/src/client.ts | 2 +- .../typescript/src/protocol/commands.ts | 2 +- .../typescript/src/protocol/events.ts | 3 +- .../bindings/typescript/test/client.test.ts | 9 + cmux-tui/crates/cmux-tui-core/src/browser.rs | 46 +++-- cmux-tui/crates/cmux-tui-core/src/mux.rs | 37 ++-- cmux-tui/crates/cmux-tui-core/src/server.rs | 87 ++++++--- cmux-tui/crates/cmux-tui-core/src/surface.rs | 17 +- .../cmux-tui-core/tests/browser_runtime.rs | 7 +- cmux-tui/crates/cmux-tui/src/app.rs | 182 +++++++++++++----- cmux-tui/crates/cmux-tui/src/browser_input.rs | 6 +- cmux-tui/crates/cmux-tui/src/session/mod.rs | 108 +++++------ .../crates/cmux-tui/src/session/remote.rs | 23 ++- cmux-tui/docs/protocol.md | 8 +- cmux-tui/frontends/web/src/lib/tree.ts | 49 ++++- cmux-tui/frontends/web/test/tree.test.ts | 19 ++ cmux-tui/spec/commands.md | 6 +- cmux-tui/spec/events.md | 12 +- 29 files changed, 480 insertions(+), 219 deletions(-) diff --git a/cmux-tui/bindings/go/client_test.go b/cmux-tui/bindings/go/client_test.go index 57a764157705..93893540eeda 100644 --- a/cmux-tui/bindings/go/client_test.go +++ b/cmux-tui/bindings/go/client_test.go @@ -21,6 +21,16 @@ func TestLegacyResizeResponseDefaultsToAccepted(t *testing.T) { } } +func TestResizeResponsePreservesReservationIdentity(t *testing.T) { + var result ResizeSurfaceResult + if err := json.Unmarshal([]byte(`{"accepted":true,"reservation_id":41}`), &result); err != nil { + t.Fatal(err) + } + if result.ReservationID == nil || *result.ReservationID != 41 { + t.Fatalf("reservation id = %v, want 41", result.ReservationID) + } +} + func TestStreamYieldsBufferedOverflowOnceThenStops(t *testing.T) { client, server := net.Pipe() defer server.Close() diff --git a/cmux-tui/bindings/go/types.go b/cmux-tui/bindings/go/types.go index fbcb1b0d9102..91e92f536ed6 100644 --- a/cmux-tui/bindings/go/types.go +++ b/cmux-tui/bindings/go/types.go @@ -25,7 +25,8 @@ type VtStateResult struct { } type ResizeSurfaceResult struct { - Accepted bool `json:"accepted"` + Accepted bool `json:"accepted"` + ReservationID *uint64 `json:"reservation_id"` } func (r *ResizeSurfaceResult) UnmarshalJSON(data []byte) error { @@ -163,19 +164,21 @@ type TitleChangedEvent struct { func (TitleChangedEvent) EventName() string { return "title-changed" } type SurfaceResizedEvent struct { - Surface uint64 `json:"surface"` - Cols uint16 `json:"cols"` - Rows uint16 `json:"rows"` + Surface uint64 `json:"surface"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + ReservationID *uint64 `json:"reservation_id"` } func (SurfaceResizedEvent) EventName() string { return "surface-resized" } type SurfaceResizeFailedEvent struct { - Surface uint64 `json:"surface"` - Cols uint16 `json:"cols"` - Rows uint16 `json:"rows"` - Error string `json:"error"` - RetryAfterMS *uint64 `json:"retry_after_ms"` + Surface uint64 `json:"surface"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + Error string `json:"error"` + RetryAfterMS *uint64 `json:"retry_after_ms"` + ReservationID *uint64 `json:"reservation_id"` } func (SurfaceResizeFailedEvent) EventName() string { return "surface-resize-failed" } diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java index 4769e6407df9..298406f5045e 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxEvent.java @@ -24,14 +24,16 @@ static CmuxEvent from(Map raw) { case "surface-resized" -> new SurfaceResizedEvent( CmuxClient.asLong(raw.get("surface")), (int) CmuxClient.asLong(raw.get("cols")), - (int) CmuxClient.asLong(raw.get("rows")) + (int) CmuxClient.asLong(raw.get("rows")), + raw.get("reservation_id") instanceof Number ? CmuxClient.asLong(raw.get("reservation_id")) : null ); case "surface-resize-failed" -> new SurfaceResizeFailedEvent( CmuxClient.asLong(raw.get("surface")), (int) CmuxClient.asLong(raw.get("cols")), (int) CmuxClient.asLong(raw.get("rows")), CmuxClient.asString(raw.get("error")), - raw.get("retry_after_ms") instanceof Number ? CmuxClient.asLong(raw.get("retry_after_ms")) : null + raw.get("retry_after_ms") instanceof Number ? CmuxClient.asLong(raw.get("retry_after_ms")) : null, + raw.get("reservation_id") instanceof Number ? CmuxClient.asLong(raw.get("reservation_id")) : null ); case "vt-state" -> new VtStateEvent( CmuxClient.asLong(raw.get("surface")), diff --git a/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java b/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java index ebdae932d2b2..5bed75806a71 100644 --- a/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java +++ b/cmux-tui/bindings/java/src/com/cmux/ResizeSurfaceResult.java @@ -2,10 +2,11 @@ import java.util.Map; -public record ResizeSurfaceResult(boolean accepted) { +public record ResizeSurfaceResult(boolean accepted, Long reservationId) { static ResizeSurfaceResult from(Map data) { return new ResizeSurfaceResult( - !data.containsKey("accepted") || Boolean.TRUE.equals(data.get("accepted")) + !data.containsKey("accepted") || Boolean.TRUE.equals(data.get("accepted")), + data.get("reservation_id") instanceof Number value ? value.longValue() : null ); } } diff --git a/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java b/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java index de55941be03b..6f1e7b781e90 100644 --- a/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/SurfaceResizeFailedEvent.java @@ -1,6 +1,6 @@ package com.cmux; -public record SurfaceResizeFailedEvent(long surface, int cols, int rows, String error, Long retryAfterMs) implements CmuxEvent { +public record SurfaceResizeFailedEvent(long surface, int cols, int rows, String error, Long retryAfterMs, Long reservationId) implements CmuxEvent { @Override public String event() { return "surface-resize-failed"; diff --git a/cmux-tui/bindings/java/src/com/cmux/SurfaceResizedEvent.java b/cmux-tui/bindings/java/src/com/cmux/SurfaceResizedEvent.java index eb33bb86b087..f71c4773ec02 100644 --- a/cmux-tui/bindings/java/src/com/cmux/SurfaceResizedEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/SurfaceResizedEvent.java @@ -1,6 +1,6 @@ package com.cmux; -public record SurfaceResizedEvent(long surface, int cols, int rows) implements CmuxEvent { +public record SurfaceResizedEvent(long surface, int cols, int rows, Long reservationId) implements CmuxEvent { public String event() { return "surface-resized"; } diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index 640e2e4dcf8a..6726e433314b 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -65,6 +65,8 @@ public static void main(String[] args) { ); assertEquals("browser is not responding", resizeFailed.error(), "resize failure error"); assertEquals(250L, resizeFailed.retryAfterMs(), "resize failure retry schedule"); + ResizeSurfaceResult reserved = ResizeSurfaceResult.from(Map.of("accepted", true, "reservation_id", 41)); + assertEquals(41L, reserved.reservationId(), "resize reservation identity"); assertTrue(ResizeSurfaceResult.from(Map.of()).accepted(), "legacy resize accepted"); } diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index 2194de5e4149..93b4635c45d7 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -41,6 +41,7 @@ class EmptyResult: @dataclass(frozen=True) class ResizeSurfaceResult: accepted: bool + reservation_id: Optional[int] = None @dataclass(frozen=True) @@ -160,6 +161,7 @@ class Event: scope: Optional[str] = None error: Optional[str] = None retry_after_ms: Optional[int] = None + reservation_id: Optional[int] = None @property def bytes_data(self) -> Optional[bytes]: @@ -494,7 +496,10 @@ def rename_workspace(self, workspace: int, name: str) -> EmptyResult: def resize_surface(self, surface: int, cols: int, rows: int) -> ResizeSurfaceResult: data = self._request("resize-surface", surface=surface, cols=cols, rows=rows) - return ResizeSurfaceResult(accepted=bool(data.get("accepted", True))) + return ResizeSurfaceResult( + accepted=bool(data.get("accepted", True)), + reservation_id=data.get("reservation_id"), + ) def focus_pane(self, pane: int) -> EmptyResult: self._request("focus-pane", pane=pane) @@ -632,4 +637,5 @@ def _parse_event(value: Dict[str, Any]) -> Event: scope=value.get("scope"), error=value.get("error"), retry_after_ms=value.get("retry_after_ms"), + reservation_id=value.get("reservation_id"), ) diff --git a/cmux-tui/bindings/python/tests/test_events.py b/cmux-tui/bindings/python/tests/test_events.py index 1a9432f62d3e..bb60ec24d9f3 100644 --- a/cmux-tui/bindings/python/tests/test_events.py +++ b/cmux-tui/bindings/python/tests/test_events.py @@ -58,11 +58,13 @@ def test_surface_resize_failed_exposes_retry_schedule(self) -> None: "rows": 40, "error": "browser is not responding", "retry_after_ms": 250, + "reservation_id": 41, } ) self.assertEqual(event.event, "surface-resize-failed") self.assertEqual(event.retry_after_ms, 250) + self.assertEqual(event.reservation_id, 41) def test_stream_yields_buffered_overflow_once_then_stops(self) -> None: connection = _FakeConnection() diff --git a/cmux-tui/bindings/python/tests/test_protocol.py b/cmux-tui/bindings/python/tests/test_protocol.py index 853f8b94954d..839a13b24b51 100644 --- a/cmux-tui/bindings/python/tests/test_protocol.py +++ b/cmux-tui/bindings/python/tests/test_protocol.py @@ -10,6 +10,12 @@ def test_legacy_resize_response_defaults_to_accepted(self) -> None: self.assertTrue(client.resize_surface(7, 80, 24).accepted) + def test_resize_response_preserves_reservation_identity(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._request = lambda _command, **_params: {"accepted": True, "reservation_id": 41} + + self.assertEqual(client.resize_surface(7, 80, 24).reservation_id, 41) + def test_attach_rejects_protocols_newer_than_seven_even_with_opt_in(self) -> None: client = CmuxClient.__new__(CmuxClient) client._protocol = 8 diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index f5504ed32f52..f4b7e1ed0879 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -173,6 +173,8 @@ pub struct Size { pub struct ResizeSurfaceResult { #[serde(default = "default_true")] pub accepted: bool, + #[serde(default)] + pub reservation_id: Option, } fn default_true() -> bool { @@ -195,6 +197,8 @@ pub struct SurfaceResizedEvent { pub surface: u64, pub cols: u16, pub rows: u16, + #[serde(default)] + pub reservation_id: Option, } #[derive(Debug, Clone, Deserialize)] @@ -204,6 +208,8 @@ pub struct SurfaceResizeFailedEvent { pub rows: u16, pub error: String, pub retry_after_ms: Option, + #[serde(default)] + pub reservation_id: Option, } #[derive(Debug, Clone, Deserialize)] @@ -846,6 +852,7 @@ mod tests { rows: 40, error, retry_after_ms: Some(250), + reservation_id: None, }) if error == "browser is not responding" )); } @@ -854,6 +861,11 @@ mod tests { fn legacy_resize_response_defaults_to_accepted() { let result: ResizeSurfaceResult = serde_json::from_value(serde_json::json!({})).unwrap(); assert!(result.accepted); + assert_eq!(result.reservation_id, None); + let reserved: ResizeSurfaceResult = + serde_json::from_value(serde_json::json!({"accepted": true, "reservation_id": 41})) + .unwrap(); + assert_eq!(reserved.reservation_id, Some(41)); } #[test] diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index 3bdea72fb91a..edc4a8a018e4 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -382,7 +382,7 @@ export class CmuxClient { renameWorkspace(workspace: Id, name: string): Promise { return this.request("rename-workspace", { workspace, name }); } async resizeSurface(surface: Id, cols: number, rows: number): Promise { const result = await this.request("resize-surface", { surface, cols, rows }); - return { accepted: result.accepted ?? true }; + return { ...result, accepted: result.accepted ?? true }; } focusPane(pane: Id): Promise { return this.request("focus-pane", { pane }); } selectTab(options: SelectTabOptions = {}): Promise { return this.request("select-tab", options); } diff --git a/cmux-tui/bindings/typescript/src/protocol/commands.ts b/cmux-tui/bindings/typescript/src/protocol/commands.ts index d5f4c9d5e039..29ae51136156 100644 --- a/cmux-tui/bindings/typescript/src/protocol/commands.ts +++ b/cmux-tui/bindings/typescript/src/protocol/commands.ts @@ -193,7 +193,7 @@ export interface ResizeSurfaceRequest extends CmuxRequestBase { cols: number; rows: number; } -export interface ResizeSurfaceResult { accepted: boolean } +export interface ResizeSurfaceResult { accepted: boolean; reservation_id?: number | null } export interface FocusPaneRequest extends CmuxRequestBase { cmd: "focus-pane"; pane: Id } diff --git a/cmux-tui/bindings/typescript/src/protocol/events.ts b/cmux-tui/bindings/typescript/src/protocol/events.ts index 801fbb9bee3a..c0394d2b1a22 100644 --- a/cmux-tui/bindings/typescript/src/protocol/events.ts +++ b/cmux-tui/bindings/typescript/src/protocol/events.ts @@ -20,7 +20,7 @@ export interface ScrollChangedEvent { at_bottom: boolean; } -export interface SurfaceResizedEvent { event: "surface-resized"; surface: Id; cols: number; rows: number } +export interface SurfaceResizedEvent { event: "surface-resized"; surface: Id; cols: number; rows: number; reservation_id?: number | null } export interface SurfaceResizeFailedEvent { event: "surface-resize-failed"; surface: Id; @@ -28,6 +28,7 @@ export interface SurfaceResizeFailedEvent { rows: number; error: string; retry_after_ms: number | null; + reservation_id?: number | null; } export interface SurfaceExitedEvent { event: "surface-exited"; surface: Id } export interface TitleChangedEvent { event: "title-changed"; surface: Id; title?: string } diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index 35b155932c9b..01c063a6df1e 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -28,6 +28,15 @@ test("legacy resize response defaults to accepted", async () => { await client.close(); }); +test("resize response preserves reservation identity", async () => { + const transport = new ScriptedTransport((request, connection) => { + connection.emit({ id: request.id, ok: true, data: { accepted: true, reservation_id: 41 } }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + assert.deepEqual(await client.resizeSurface(7, 80, 24), { accepted: true, reservation_id: 41 }); + await client.close(); +}); + test("attachSurface decodes VT colors, output, and resized payloads", async () => { const main = new ScriptedTransport((request, transport) => { assert.equal(request.cmd, "identify"); diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 2a2dd49b63bd..eecf1a42aa90 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -171,7 +171,7 @@ enum BrowserCommand { Activate, Reconfigure { queued: QueuedBrowserGeometry, - report: Option>, + report: Option) + Send>>, }, #[cfg(test)] Hold { @@ -200,7 +200,7 @@ fn reject_reconfigure(mut command: BrowserCommand) -> Option Some(queued), @@ -793,10 +793,10 @@ fn run_browser_worker_command( id: SurfaceId, failures: &mut BrowserWorkerErrorState, ) { - if let BrowserCommand::Reconfigure { report, .. } = &mut command + if let BrowserCommand::Reconfigure { queued, report } = &mut command && let Some(report) = report.take() { - report(true); + report(Some(queued.id)); } let is_input = command.is_input(); let is_reconfigure = matches!(command, BrowserCommand::Reconfigure { .. }); @@ -851,7 +851,12 @@ fn run_browser_worker_command( && let Some(queued) = reconfigure { let (cols, rows) = queued.geometry.size; - mux.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); + mux.emit(MuxEvent::SurfaceResized { + surface: id, + cols, + rows, + reservation_id: Some(queued.id), + }); } if let Some(queued) = reconfigure && let Err(error) = &result @@ -866,6 +871,7 @@ fn run_browser_worker_command( rows, error: Arc::::from(error.to_string()), retry_after_ms: retry_delay.map(|delay| delay.as_millis() as u64), + reservation_id: Some(queued.id), }); } record_browser_worker_result(surface, mux, id, is_input, result, failures); @@ -1004,21 +1010,22 @@ impl BrowserSurface { pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { self.resize_reporting_acceptance(cols, rows, Box::new(|_| {})) + .map(|reservation_id| reservation_id.is_some()) } pub fn resize_reporting_acceptance( &self, cols: u16, rows: u16, - report: Box, - ) -> anyhow::Result { + report: Box) + Send>, + ) -> anyhow::Result> { let (cols, rows) = (cols.max(1), rows.max(1)); let Some(queued) = self.reserve_reconfigure(cols, rows) else { - report(false); - return Ok(false); + report(None); + return Ok(None); }; self.enqueue_reconfigure(BrowserCommand::Reconfigure { queued, report: Some(report) })?; - Ok(true) + Ok(Some(queued.id)) } fn reconfigure_reserved_blocking(&self, queued: QueuedBrowserGeometry) -> anyhow::Result<()> { @@ -1032,20 +1039,21 @@ impl BrowserSurface { pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { self.set_cell_pixel_size_reporting(width_px, height_px, Box::new(|_| {})) + .map(|reservation_id| reservation_id.is_some()) } pub fn set_cell_pixel_size_reporting( &self, width_px: u16, height_px: u16, - report: Box, - ) -> anyhow::Result { + report: Box) + Send>, + ) -> anyhow::Result> { { let mut cell = self.cell_pixels.lock().unwrap(); let next = (width_px.max(1), height_px.max(1)); if *cell == next { - report(false); - return Ok(false); + report(None); + return Ok(None); } *cell = next; } @@ -2601,25 +2609,27 @@ mod tests { .resize_reporting_acceptance( 11, 5, - Box::new(move |accepted| { - assert!(accepted); + Box::new(move |reservation_id| { + assert!(reservation_id.is_some()); reported.store(true, Ordering::Release); }), ) .unwrap() + .is_some() ); assert!(!accepted.load(Ordering::Acquire)); let (duplicate_tx, duplicate_rx) = mpsc::channel(); assert!( - !browser + browser .resize_reporting_acceptance( 11, 5, Box::new(move |accepted| duplicate_tx.send(accepted).unwrap()), ) .unwrap() + .is_none() ); - assert!(!duplicate_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + assert!(duplicate_rx.recv_timeout(Duration::from_secs(1)).unwrap().is_none()); release.send(()).unwrap(); let deadline = Instant::now() + Duration::from_secs(1); diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index bd1206af23c1..b2f50cadd1e9 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -13,7 +13,7 @@ use crate::model::{Node, Pane, Screen, State, Workspace}; use crate::surface::{DefaultColors, Surface, SurfaceOptions}; use crate::{PaneId, ScreenId, SplitDir, SurfaceId, WorkspaceId}; -pub type SurfaceResizeReporter = Arc; +pub type SurfaceResizeReporter = Arc) + Send + Sync>; /// Events pushed to subscribed frontends. #[derive(Debug, Clone)] @@ -25,6 +25,7 @@ pub enum MuxEvent { surface: SurfaceId, cols: u16, rows: u16, + reservation_id: Option, }, /// An asynchronous browser resize failed after queue acceptance. SurfaceResizeFailed { @@ -33,6 +34,7 @@ pub enum MuxEvent { rows: u16, error: Arc, retry_after_ms: Option, + reservation_id: Option, }, /// A surface's child exited. The mux has already reaped it from the /// tree (a tree-changed follows) by the time this arrives. @@ -736,7 +738,7 @@ impl Mux { &self, width_px: u16, height_px: u16, - ) -> Vec<(SurfaceId, (u16, u16))> { + ) -> Vec<(SurfaceId, (u16, u16), u64)> { self.set_cell_pixel_size_reporting(width_px, height_px, Arc::new(|_, _, _| {})) } @@ -745,7 +747,7 @@ impl Mux { width_px: u16, height_px: u16, report: SurfaceResizeReporter, - ) -> Vec<(SurfaceId, (u16, u16))> { + ) -> Vec<(SurfaceId, (u16, u16), u64)> { let next = (width_px.max(1), height_px.max(1)); { let mut cell = self.cell_pixels.lock().unwrap(); @@ -760,15 +762,15 @@ impl Mux { let id = surface.id; let size = surface.size(); let callback = report.clone(); - if surface + if let Some(reservation_id) = surface .set_cell_pixel_size_reporting( next.0, next.1, Box::new(move |accepted| callback(id, size, accepted)), ) - .unwrap_or(false) + .unwrap_or(None) { - accepted.push((id, size)); + accepted.push((id, size, reservation_id)); } } accepted @@ -796,6 +798,15 @@ impl Mux { /// Resize a surface and broadcast the final clamped size when it actually /// changes. Browser workers broadcast after their asynchronous CDP work. pub fn resize_surface(&self, id: SurfaceId, cols: u16, rows: u16) -> anyhow::Result { + self.resize_surface_with_reservation(id, cols, rows).map(|(accepted, _)| accepted) + } + + pub fn resize_surface_with_reservation( + &self, + id: SurfaceId, + cols: u16, + rows: u16, + ) -> anyhow::Result<(bool, Option)> { let Some(surface) = self.surface(id) else { anyhow::bail!("unknown surface {id}"); }; @@ -804,15 +815,17 @@ impl Mux { // in this method and must not become the default for new surfaces. // Client interactions record explicitly at the protocol/TUI layers. let (cols, rows) = (cols.max(1), rows.max(1)); - if !surface.resize(cols, rows)? { - return Ok(false); - } if surface.as_browser().is_some() { - return Ok(true); + let reservation_id = + surface.resize_reporting_acceptance(cols, rows, Box::new(|_| {}))?; + return Ok((reservation_id.is_some(), reservation_id)); + } + if !surface.resize(cols, rows)? { + return Ok((false, None)); } let (cols, rows) = surface.size(); - self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); - Ok(true) + self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows, reservation_id: None }); + Ok((true, None)) } /// Create a workspace with one screen holding one pane with one tab. diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 793ce36f997c..d7f3ad44b637 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -476,9 +476,6 @@ trait MessageSink: Send + Sync { fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; fn send_control(&self, value: &Value) -> std::io::Result<()>; fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; - fn try_send(&self, value: &Value) -> std::io::Result<()> { - self.send_control(value) - } fn set_write_timeout(&self, _timeout: Option) -> std::io::Result<()> { Ok(()) } @@ -554,13 +551,6 @@ impl MessageWriter { result } - fn try_send(&self, value: &Value) -> std::io::Result<()> { - if !self.is_open() { - return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); - } - self.sink.try_send(value) - } - fn is_open(&self) -> bool { self.open.load(Ordering::Acquire) && self.sink.is_open() } @@ -936,7 +926,7 @@ impl ClientTransport { #[derive(Default)] struct AttachedSurface { - streams: usize, + streams: BTreeMap, size: Option<(u16, u16)>, } @@ -1033,11 +1023,12 @@ impl ClientRegistry { &self, client: u64, surface: SurfaceId, + stream: OutboundStream, ) -> anyhow::Result> { let mut clients = self.clients.lock().unwrap(); let record = clients.get_mut(&client).ok_or_else(|| anyhow::anyhow!("unknown client {client}"))?; - record.attached.entry(surface).or_default().streams += 1; + record.attached.entry(surface).or_default().streams.insert(stream.id, stream); if record.announced_attached { return Ok(None); } @@ -1045,12 +1036,12 @@ impl ClientRegistry { Ok(Some((record.transport.as_str().to_string(), record.name.clone(), record.kind.clone()))) } - fn detach_surface(&self, client: u64, surface: SurfaceId) { + fn detach_surface(&self, client: u64, surface: SurfaceId, stream: u64) { let mut clients = self.clients.lock().unwrap(); let Some(record) = clients.get_mut(&client) else { return }; let Some(attached) = record.attached.get_mut(&surface) else { return }; - attached.streams = attached.streams.saturating_sub(1); - if attached.streams == 0 { + attached.streams.remove(&stream); + if attached.streams.is_empty() { record.attached.remove(&surface); } } @@ -1343,8 +1334,12 @@ fn disconnect_client(mux: &Mux, client: u64, send_detached: bool) -> bool { let Some(record) = mux.control_clients.remove(client) else { return false }; if send_detached { let _ = record.writer.set_write_timeout(Some(CLIENT_DETACH_WRITE_TIMEOUT)); - for surface in record.attached.keys() { - let _ = record.writer.try_send(&json!({"event": "detached", "surface": surface})); + for (surface, attached) in &record.attached { + for stream in attached.streams.values() { + let _ = record + .writer + .send_terminal(&json!({"event": "detached", "surface": surface}), stream); + } } } record.writer.close(); @@ -1829,8 +1824,15 @@ fn handle_attach_send_error(lifecycle: &AttachLifecycle, error: &std::io::Error) } } -fn mark_client_attached(mux: &Mux, client: u64, surface: SurfaceId) -> anyhow::Result<()> { - if let Some((transport, name, kind)) = mux.control_clients.attach_surface(client, surface)? { +fn mark_client_attached( + mux: &Mux, + client: u64, + surface: SurfaceId, + stream: OutboundStream, +) -> anyhow::Result<()> { + if let Some((transport, name, kind)) = + mux.control_clients.attach_surface(client, surface, stream)? + { mux.emit(MuxEvent::ClientAttached { client, transport, name, kind }); } Ok(()) @@ -2103,11 +2105,12 @@ fn handle_command( let resizes = mux .set_cell_pixel_size(width_px, height_px) .into_iter() - .map(|(surface, (cols, rows))| { + .map(|(surface, (cols, rows), reservation_id)| { json!({ "surface": surface, "cols": cols, "rows": rows, + "reservation_id": reservation_id, }) }) .collect::>(); @@ -2341,10 +2344,11 @@ fn handle_command( Ok(json!({})) } Command::ResizeSurface { surface, cols, rows } => { - let accepted = mux.resize_surface(surface, cols, rows)?; + let (accepted, reservation_id) = + mux.resize_surface_with_reservation(surface, cols, rows)?; mux.record_client_size(cols, rows); mux.control_clients.record_size(client, surface, cols.max(1), rows.max(1)); - Ok(json!({"accepted": accepted})) + Ok(json!({"accepted": accepted, "reservation_id": reservation_id})) } Command::FocusPane { pane } => { if !mux.focus_pane(pane) { @@ -2412,7 +2416,7 @@ fn handle_command( handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } - mark_client_attached(mux, client, surface_id)?; + mark_client_attached(mux, client, surface_id, outbound_stream.clone())?; spawn_attach_notification_stream( mux.clone(), surface_id, @@ -2463,7 +2467,7 @@ fn handle_command( } } report_attach_overflow(&writer, surface_id, &lifecycle, &outbound_stream); - mux.control_clients.detach_surface(client, surface_id); + mux.control_clients.detach_surface(client, surface_id, outbound_stream.id); })?; return Ok(json!({})); } @@ -2488,7 +2492,7 @@ fn handle_command( handle_attach_send_error(&lifecycle, &error); return Err(error.into()); } - mark_client_attached(mux, client, surface_id)?; + mark_client_attached(mux, client, surface_id, outbound_stream.clone())?; spawn_attach_notification_stream( mux.clone(), surface_id, @@ -2542,7 +2546,7 @@ fn handle_command( } } report_attach_overflow(&writer, surface_id, &attach.lifecycle, &outbound_stream); - mux.control_clients.detach_surface(client, surface_id); + mux.control_clients.detach_surface(client, surface_id, outbound_stream.id); })?; Ok(json!({})) } @@ -2552,19 +2556,28 @@ fn handle_command( fn subscribed_event_json(event: &MuxEvent) -> Value { match event { MuxEvent::SurfaceOutput(id) => json!({"event": "surface-output", "surface": id}), - MuxEvent::SurfaceResized { surface, cols, rows } => json!({ + MuxEvent::SurfaceResized { surface, cols, rows, reservation_id } => json!({ "event": "surface-resized", "surface": surface, "cols": cols, "rows": rows, + "reservation_id": reservation_id, }), - MuxEvent::SurfaceResizeFailed { surface, cols, rows, error, retry_after_ms } => json!({ + MuxEvent::SurfaceResizeFailed { + surface, + cols, + rows, + error, + retry_after_ms, + reservation_id, + } => json!({ "event": "surface-resize-failed", "surface": surface, "cols": cols, "rows": rows, "error": error.as_ref(), "retry_after_ms": retry_after_ms, + "reservation_id": reservation_id, }), MuxEvent::SurfaceExited(id) => json!({"event": "surface-exited", "surface": id}), MuxEvent::TitleChanged { surface, title } => { @@ -2838,6 +2851,24 @@ mod tests { assert!(writer.is_open()); } + #[test] + fn client_detach_purges_attach_backlog_before_terminal_event() { + let mux = Mux::new("detach-order-test", SurfaceOptions::default()); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + let stream = writer.start_stream(&attach_overflow_json(41)).unwrap(); + let client = mux.control_clients.register(ClientTransport::Unix, writer.clone()); + mux.control_clients.attach_surface(client, 41, stream.clone()).unwrap(); + writer.send_initial(&json!({"event": "vt-state", "surface": 41}), &stream).unwrap(); + writer.send_stream(&json!({"event": "output", "surface": 41}), &stream).unwrap(); + + assert!(disconnect_client(&mux, client, true)); + + let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(terminal, json!({"event": "detached", "surface": 41})); + assert_eq!(outbound.try_pop(), None); + } + #[test] fn closing_bounded_writer_wakes_a_waiting_drain() { let outbound = Arc::new(BoundedOutbound::default()); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index 7f2ac1a40419..ad5f68d79bd8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -715,13 +715,13 @@ impl Surface { &self, cols: u16, rows: u16, - report: Box, - ) -> anyhow::Result { + report: Box) + Send>, + ) -> anyhow::Result> { match self { Surface::Pty(pty) => { let accepted = pty.resize(cols, rows); - report(accepted); - Ok(accepted) + report(accepted.then_some(0)); + Ok(accepted.then_some(0)) } Surface::Browser(browser) => browser.resize_reporting_acceptance(cols, rows, report), } @@ -737,19 +737,20 @@ impl Surface { pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { self.set_cell_pixel_size_reporting(width_px, height_px, Box::new(|_| {})) + .map(|reservation_id| reservation_id.is_some()) } pub fn set_cell_pixel_size_reporting( &self, width_px: u16, height_px: u16, - report: Box, - ) -> anyhow::Result { + report: Box) + Send>, + ) -> anyhow::Result> { if let Some(browser) = self.as_browser() { browser.set_cell_pixel_size_reporting(width_px, height_px, report) } else { - report(false); - Ok(false) + report(None); + Ok(None) } } diff --git a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs index 673b504a0848..b0b02a6ef80c 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs @@ -443,7 +443,12 @@ fn socket_browser_attach_streams_frames_input_and_cell_pixels() { ); assert_eq!(metrics["ok"], true); assert!(metrics["data"]["resizes"].as_array().is_some_and(|resizes| { - resizes.iter().any(|resize| resize == &json!({"surface": surface, "cols": 10, "rows": 5})) + resizes.iter().any(|resize| { + resize["surface"] == surface + && resize["cols"] == 10 + && resize["rows"] == 5 + && resize["reservation_id"].as_u64().is_some() + }) })); let metrics_request = recv_method_where(&seen_rx, "Emulation.setDeviceMetricsOverride", |value| { diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 947149b5322b..2c636e1d371f 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -208,9 +208,14 @@ fn forward_mux_event( _ => {} } let terminal = matches!(event, MuxEvent::Empty); - if terminal { - let _ = tx.send(AppEvent::Mux(event)); - return ForwardMuxOutcome::Stop; + let resize_completion = + matches!(event, MuxEvent::SurfaceResized { .. } | MuxEvent::SurfaceResizeFailed { .. }); + if terminal || resize_completion { + return match tx.send(AppEvent::Mux(event)) { + Ok(()) if terminal => ForwardMuxOutcome::Stop, + Ok(()) => ForwardMuxOutcome::Continue, + Err(_) => ForwardMuxOutcome::Stop, + }; } match tx.try_send(AppEvent::Mux(event)) { Ok(()) => ForwardMuxOutcome::Continue, @@ -501,6 +506,12 @@ struct SurfaceResizeFailure { state: SurfaceSyncFailureState, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SurfaceResizeOwnership { + desired: (u16, u16), + reservation_id: Option, +} + fn next_surface_sync_failure( previous: Option, transient: bool, @@ -584,16 +595,16 @@ impl Drop for SurfaceResizeClaim { } fn record_surface_resize_dispatch_result( - ownership: &Mutex>, + ownership: &Mutex>, surface: SurfaceId, desired: (u16, u16), - accepted: bool, + reservation_id: Option, ) { - let mut ownership = ownership.lock().unwrap(); - if accepted { - ownership.insert(surface, desired); - } else if ownership.get(&surface).is_some_and(|owned| *owned == desired) { - ownership.remove(&surface); + if let Some(reservation_id) = reservation_id { + ownership.lock().unwrap().insert( + surface, + SurfaceResizeOwnership { desired, reservation_id: Some(reservation_id) }, + ); } } @@ -617,7 +628,7 @@ pub struct OrderedSession { remote_refresh_sequence: Arc, surface_resize_claims: Arc>>, surface_resize_claim_sequence: Arc, - surface_resize_ownership: Arc>>, + surface_resize_ownership: Arc>>, surface_attach_claims: Arc>>, surface_attach_failures: Arc>>, surface_resize_failures: Arc>>, @@ -1136,9 +1147,17 @@ impl OrderedSession { } } - fn confirm_surface_resize(&self, surface: SurfaceId, size: (u16, u16)) { + fn confirm_surface_resize( + &self, + surface: SurfaceId, + size: (u16, u16), + reservation_id: Option, + ) { let mut ownership = self.surface_resize_ownership.lock().unwrap(); - if ownership.get(&surface).is_some_and(|desired| *desired == size) { + if ownership.get(&surface).is_some_and(|ownership| { + ownership.desired == size + && reservation_id.is_none_or(|id| ownership.reservation_id == Some(id)) + }) { ownership.remove(&surface); } drop(ownership); @@ -1148,26 +1167,17 @@ impl OrderedSession { } } - fn release_surface_resize_ownership(&self, surface: SurfaceId, desired: (u16, u16)) { - let mut ownership = self.surface_resize_ownership.lock().unwrap(); - if ownership.get(&surface).is_some_and(|owned| *owned == desired) { - ownership.remove(&surface); - } - } - fn note_surface_resize_failure( &self, surface: SurfaceId, desired: (u16, u16), retry_after_ms: Option, + reservation_id: Option, ) -> bool { - if self - .surface_resize_ownership - .lock() - .unwrap() - .get(&surface) - .is_none_or(|owned| *owned != desired) - { + if self.surface_resize_ownership.lock().unwrap().get(&surface).is_none_or(|ownership| { + ownership.desired != desired + || reservation_id.is_some_and(|id| ownership.reservation_id != Some(id)) + }) { return false; } let mut failures = self.surface_resize_failures.lock().unwrap(); @@ -3151,8 +3161,8 @@ impl App { self.remove_surface_from_tree(id); Ok(RenderAction::Draw) } - AppEvent::Mux(MuxEvent::SurfaceResized { surface, cols, rows }) => { - self.session.confirm_surface_resize(surface, (cols, rows)); + AppEvent::Mux(MuxEvent::SurfaceResized { surface, cols, rows, reservation_id }) => { + self.session.confirm_surface_resize(surface, (cols, rows), reservation_id); Ok(RenderAction::Draw) } AppEvent::Mux(MuxEvent::SurfaceResizeFailed { @@ -3161,8 +3171,14 @@ impl App { rows, error, retry_after_ms, + reservation_id, }) => { - if self.session.note_surface_resize_failure(surface, (cols, rows), retry_after_ms) { + if self.session.note_surface_resize_failure( + surface, + (cols, rows), + retry_after_ms, + reservation_id, + ) { self.status_message = Some(format!( "browser surface {surface} resize to {cols}x{rows} failed: {error}" )); @@ -3195,10 +3211,6 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::BrowserResizeFailed(failure) => { - self.session.release_surface_resize_ownership( - failure.surface_id, - (failure.cols, failure.rows), - ); self.status_message = Some(format!( "browser surface {} resize to {}x{} failed: {}", failure.surface_id, failure.cols, failure.rows, failure.error @@ -5159,7 +5171,8 @@ impl App { }; let (surface, handle, reservation_id, fallback, content, button) = (*surface, handle.clone(), *reservation_id, release_bytes.clone(), *content, *button); - if reported_button != button && self.ignored_pty_mouse_buttons.remove(&reported_button) { + if reported_button != button { + self.ignored_pty_mouse_buttons.remove(&reported_button); return true; } let content = self.current_pty_content(surface).unwrap_or(content); @@ -6583,8 +6596,8 @@ mod tests { MuxTitleIngress, OrderedSession, PaneArea, PendingSessionMutation, PendingSessionMutationState, PtyFailureIngress, RenderAction, Selection, SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, SidebarPluginSyncState, - SurfaceResizeDecision, browser_content_size_for_rect, browser_hover_forward_allowed, - forward_mux_event, forward_mux_events, pane_parts_for_rect, + SurfaceResizeDecision, SurfaceResizeOwnership, browser_content_size_for_rect, + browser_hover_forward_allowed, forward_mux_event, forward_mux_events, pane_parts_for_rect, record_surface_resize_dispatch_result, sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; @@ -6750,6 +6763,10 @@ mod tests { .unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<2;5;3M"); + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Right, .. }))); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Right), KeyModifiers::NONE)) + .unwrap(); assert_eq!(app.encode_buf, b"\x1b[<2;5;3m"); assert!(app.drag.is_none()); @@ -6893,6 +6910,25 @@ mod tests { ); } + #[test] + fn mismatched_release_preserves_the_active_pty_drag() { + let mux = Mux::new("mismatched-release-drag-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.drag = Some(Drag::PtyMouse { + surface: 42, + handle: None, + reservation_id: 41, + release_bytes: PtyInputBytes::from_slice(b"fallback-release"), + content: Rect { x: 1, y: 1, width: 20, height: 8 }, + button: MouseButton::Left, + position: (4, 3), + modifiers: KeyModifiers::NONE, + }); + + assert!(app.finish_pty_mouse_drag(4, 3, MouseButton::Right, KeyModifiers::NONE)); + assert!(matches!(app.drag, Some(Drag::PtyMouse { button: MouseButton::Left, .. }))); + } + #[test] fn motion_failure_preserves_pty_drag_for_the_physical_release() { let mux = Mux::new("motion-failure-drag-test", SurfaceOptions::default()); @@ -7500,6 +7536,32 @@ mod tests { assert!(matches!(forwarder.join().unwrap(), ForwardMuxOutcome::Stop)); } + #[test] + fn mux_forwarder_preserves_resize_completion_while_app_channel_is_full() { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + tx.send(AppEvent::Mux(MuxEvent::Bell(1))).unwrap(); + let titles = MuxTitleIngress::default(); + let forwarder = std::thread::spawn(move || { + forward_mux_event( + MuxEvent::SurfaceResized { + surface: 41, + cols: 90, + rows: 31, + reservation_id: Some(7), + }, + &tx, + &titles, + ) + }); + + assert!(matches!(rx.recv().unwrap(), AppEvent::Mux(MuxEvent::Bell(1)))); + assert!(matches!( + rx.recv().unwrap(), + AppEvent::Mux(MuxEvent::SurfaceResized { surface: 41, reservation_id: Some(7), .. }) + )); + assert!(matches!(forwarder.join().unwrap(), ForwardMuxOutcome::Continue)); + } + #[test] fn mux_recovery_barrier_defers_input_until_authoritative_tree_is_applied() { let mux = Mux::new("mux-recovery-barrier-test", SurfaceOptions::default()); @@ -7673,14 +7735,17 @@ mod tests { fn browser_resize_ownership_tracks_only_accepted_dispatches() { let ownership = Mutex::new(HashMap::new()); - record_surface_resize_dispatch_result(&ownership, 7, (100, 30), false); + record_surface_resize_dispatch_result(&ownership, 7, (100, 30), None); assert!(ownership.lock().unwrap().is_empty()); - record_surface_resize_dispatch_result(&ownership, 7, (100, 30), true); - assert_eq!(ownership.lock().unwrap().get(&7), Some(&(100, 30))); + record_surface_resize_dispatch_result(&ownership, 7, (100, 30), Some(41)); + assert_eq!( + ownership.lock().unwrap().get(&7), + Some(&SurfaceResizeOwnership { desired: (100, 30), reservation_id: Some(41) }) + ); - record_surface_resize_dispatch_result(&ownership, 7, (100, 30), false); - assert!(ownership.lock().unwrap().is_empty()); + record_surface_resize_dispatch_result(&ownership, 7, (100, 30), None); + assert!(ownership.lock().unwrap().contains_key(&7)); } #[test] @@ -7868,18 +7933,43 @@ mod tests { let mux = Mux::new("session-resize-retry-due-test", SurfaceOptions::default()); let app = test_app(Session::Local(mux)); - assert!(!app.session.note_surface_resize_failure(41, (90, 31), Some(0))); + assert!(!app.session.note_surface_resize_failure(41, (90, 31), Some(0), Some(7))); assert!(!app.session.surface_resize_retry_due()); - app.session.surface_resize_ownership.lock().unwrap().insert(41, (90, 31)); - assert!(app.session.note_surface_resize_failure(41, (90, 31), Some(0))); + app.session + .surface_resize_ownership + .lock() + .unwrap() + .insert(41, SurfaceResizeOwnership { desired: (90, 31), reservation_id: Some(7) }); + assert!(app.session.note_surface_resize_failure(41, (90, 31), Some(0), Some(7))); assert!(app.session.surface_resize_retry_due()); - app.session.confirm_surface_resize(41, (90, 31)); + app.session.confirm_surface_resize(41, (90, 31), Some(7)); assert!(!app.session.surface_resize_retry_due()); assert!(!app.session.surface_resize_ownership.lock().unwrap().contains_key(&41)); } + #[test] + fn stale_same_geometry_completion_does_not_release_newer_resize_owner() { + let mux = Mux::new("resize-owner-identity-test", SurfaceOptions::default()); + let app = test_app(Session::Local(mux)); + app.session + .surface_resize_ownership + .lock() + .unwrap() + .insert(41, SurfaceResizeOwnership { desired: (90, 31), reservation_id: Some(9) }); + + app.session.confirm_surface_resize(41, (90, 31), Some(7)); + assert_eq!( + app.session.surface_resize_ownership.lock().unwrap().get(&41).copied(), + Some(SurfaceResizeOwnership { desired: (90, 31), reservation_id: Some(9) }) + ); + assert!(!app.session.note_surface_resize_failure(41, (90, 31), Some(0), Some(7))); + + app.session.confirm_surface_resize(41, (90, 31), Some(9)); + assert!(!app.session.surface_resize_ownership.lock().unwrap().contains_key(&41)); + } + #[test] fn refresh_sequences_are_monotonic_across_identity_and_background_paths() { let mux = Mux::new("refresh-sequence-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/browser_input.rs b/cmux-tui/crates/cmux-tui/src/browser_input.rs index ffd35d63c53f..82ed1b8f2692 100644 --- a/cmux-tui/crates/cmux-tui/src/browser_input.rs +++ b/cmux-tui/crates/cmux-tui/src/browser_input.rs @@ -79,7 +79,7 @@ pub enum BrowserInputKind { rows: u16, reassert: bool, _claim: Option>, - on_result: Option>, + on_result: Option) + Send>>, }, Navigate(String), Back, @@ -766,8 +766,8 @@ mod tests { if let BrowserInputKind::Resize { on_result, .. } = &mut first.kind { let callback_called = callback_called.clone(); let accepted_result = accepted.clone(); - *on_result = Some(Box::new(move |was_accepted| { - accepted_result.store(was_accepted, Ordering::Release); + *on_result = Some(Box::new(move |reservation_id| { + accepted_result.store(reservation_id.is_some(), Ordering::Release); callback_called.store(true, Ordering::Release); })); } diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 9bb00acf5cf5..dae9bd3c1a3d 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -404,8 +404,10 @@ impl Session { Ok(()) } Session::Remote(remote) => { - for (surface, desired) in remote.set_cell_pixel_size(width_px, height_px)? { - report(surface, desired, true); + for (surface, desired, reservation_id) in + remote.set_cell_pixel_size(width_px, height_px)? + { + report(surface, desired, reservation_id.or(Some(0))); } Ok(()) } @@ -705,32 +707,7 @@ impl SurfaceHandle { } pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { - let desired = (cols.max(1), rows.max(1)); - match self { - SurfaceHandle::Local(surface) => surface.resize(desired.0, desired.1), - SurfaceHandle::Remote(surface, session) => { - if resize_action(desired, surface.asserted_size(), surface.server_size(), false) { - let response = session.request(json!({ - "cmd": "resize-surface", - "surface": surface.id, - "cols": desired.0, - "rows": desired.1, - }))?; - let accepted = response - .get("accepted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true); - if accepted { - surface.set_asserted_size(desired); - } - return Ok(accepted); - } - Ok(false) - } - SurfaceHandle::RemoteBrowserUnsupported => { - anyhow::bail!("browser surface is unavailable") - } - } + self.resize_reporting_acceptance(cols, rows, false, Box::new(|_| {})) } pub fn resize_reporting_acceptance( @@ -738,19 +715,49 @@ impl SurfaceHandle { cols: u16, rows: u16, reassert: bool, - report: Box, + report: Box) + Send>, ) -> anyhow::Result { - match self { + let desired = (cols.max(1), rows.max(1)); + let reservation_id = match self { SurfaceHandle::Local(surface) => { - surface.resize_reporting_acceptance(cols, rows, report) + return surface + .resize_reporting_acceptance(desired.0, desired.1, report) + .map(|reservation_id| reservation_id.is_some()); } - _ => { - let result = - if reassert { self.reassert_size(cols, rows) } else { self.resize(cols, rows) }; - report(result.as_ref().copied().unwrap_or(false)); - result + SurfaceHandle::Remote(surface, session) => { + if !resize_action(desired, surface.asserted_size(), surface.server_size(), reassert) + { + report(None); + return Ok(false); + } + let response = match session.request(json!({ + "cmd": "resize-surface", + "surface": surface.id, + "cols": desired.0, + "rows": desired.1, + })) { + Ok(response) => response, + Err(error) => { + report(None); + return Err(error); + } + }; + let accepted = + response.get("accepted").and_then(serde_json::Value::as_bool).unwrap_or(true); + if !accepted { + report(None); + return Ok(false); + } + surface.set_asserted_size(desired); + response.get("reservation_id").and_then(serde_json::Value::as_u64).or(Some(0)) } - } + SurfaceHandle::RemoteBrowserUnsupported => { + report(None); + anyhow::bail!("browser surface is unavailable") + } + }; + report(reservation_id); + Ok(true) } pub fn resize_needed(&self, cols: u16, rows: u16, user_interaction: bool) -> bool { @@ -768,32 +775,7 @@ impl SurfaceHandle { } pub fn reassert_size(&self, cols: u16, rows: u16) -> anyhow::Result { - let desired = (cols.max(1), rows.max(1)); - match self { - SurfaceHandle::Local(surface) => surface.resize(desired.0, desired.1), - SurfaceHandle::Remote(surface, session) => { - if resize_action(desired, surface.asserted_size(), surface.server_size(), true) { - let response = session.request(json!({ - "cmd": "resize-surface", - "surface": surface.id, - "cols": desired.0, - "rows": desired.1, - }))?; - let accepted = response - .get("accepted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true); - if accepted { - surface.set_asserted_size(desired); - } - return Ok(accepted); - } - Ok(false) - } - SurfaceHandle::RemoteBrowserUnsupported => { - anyhow::bail!("browser surface is unavailable") - } - } + self.resize_reporting_acceptance(cols, rows, true, Box::new(|_| {})) } pub fn take_dirty(&self) -> bool { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 6f030efeae59..86ab79261e8a 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -31,6 +31,8 @@ const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(test)] const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_millis(100); +pub(crate) type RemoteResizeReservation = (SurfaceId, (u16, u16), Option); + #[derive(Debug)] pub(crate) enum RemoteRequestError { Encode(serde_json::Error), @@ -443,7 +445,12 @@ impl RemoteSession { if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { surface.set_server_size(cols, rows); } - self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); + self.emit(MuxEvent::SurfaceResized { + surface: id, + cols, + rows, + reservation_id: value.get("reservation_id").and_then(Value::as_u64), + }); } Some("surface-resize-failed") => { let Some(id) = surface_id() else { return }; @@ -452,6 +459,7 @@ impl RemoteSession { let error = value.get("error").and_then(Value::as_str).unwrap_or("browser resize failed"); let retry_after_ms = value.get("retry_after_ms").and_then(Value::as_u64); + let reservation_id = value.get("reservation_id").and_then(Value::as_u64); if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { surface.clear_asserted_size((cols.max(1), rows.max(1))); } @@ -461,6 +469,7 @@ impl RemoteSession { rows, error: Arc::::from(error), retry_after_ms, + reservation_id, }); } Some("output") => { @@ -496,7 +505,12 @@ impl RemoteSession { if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { surface.apply_stream_resize(cols, rows, replay.as_deref()); surface.dirty.store(true, Ordering::Release); - self.emit(MuxEvent::SurfaceResized { surface: id, cols, rows }); + self.emit(MuxEvent::SurfaceResized { + surface: id, + cols, + rows, + reservation_id: None, + }); self.emit(MuxEvent::SurfaceOutput(id)); } } @@ -756,7 +770,7 @@ impl RemoteSession { &self, width_px: u16, height_px: u16, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let response = self.request(json!({ "cmd": "set-cell-pixels", "width_px": width_px, @@ -774,6 +788,7 @@ impl RemoteSession { u16::try_from(resize.get("cols")?.as_u64()?).ok()?, u16::try_from(resize.get("rows")?.as_u64()?).ok()?, ), + resize.get("reservation_id").and_then(Value::as_u64), )) }) .collect()) @@ -1484,7 +1499,7 @@ mod tests { assert_eq!(surface.server_size(), (90, 31)); assert!(events.try_iter().any(|event| matches!( event, - MuxEvent::SurfaceResized { surface: 7, cols: 90, rows: 31 } + MuxEvent::SurfaceResized { surface: 7, cols: 90, rows: 31, .. } ))); } diff --git a/cmux-tui/docs/protocol.md b/cmux-tui/docs/protocol.md index 6257107eb20e..289c1f02785b 100644 --- a/cmux-tui/docs/protocol.md +++ b/cmux-tui/docs/protocol.md @@ -100,8 +100,8 @@ Subscribed event lines are: ```json {"event":"surface-output","surface":4} -{"event":"surface-resized","surface":4,"cols":120,"rows":40} -{"event":"surface-resize-failed","surface":4,"cols":120,"rows":40,"error":"browser is not responding","retry_after_ms":250} +{"event":"surface-resized","surface":4,"cols":120,"rows":40,"reservation_id":7} +{"event":"surface-resize-failed","surface":4,"cols":120,"rows":40,"error":"browser is not responding","retry_after_ms":250,"reservation_id":7} {"event":"surface-exited","surface":4} {"event":"title-changed","surface":4,"title":"build logs"} {"event":"bell","surface":4} @@ -109,11 +109,11 @@ Subscribed event lines are: {"event":"empty"} ``` -`surface-resized` reports the final clamped cell size and is emitted only when the surface size actually changes. `surface-resize-failed` reports an asynchronous browser resize failure and the delay before an automatic retry, or `null` after retries are exhausted. +`surface-resized` reports the final clamped cell size and is emitted only when the surface size actually changes. `surface-resize-failed` reports an asynchronous browser resize failure and the delay before an automatic retry, or `null` after retries are exhausted. Browser resize completions repeat the numeric `reservation_id` returned by the accepted request so clients can ignore stale completions. Protocol v7 `title-changed` carries the authoritative current `title`. Slow subscribers coalesce repeated pending title changes per surface to the latest value. -Browser input, navigation, activation, and browser reconfigure work from `resize-surface` enqueue per-surface CDP work. Protocol v7 `resize-surface` responses include `data.accepted`; `true` means the resize was applied or queued, and `false` means it was already satisfied, pending, or waiting for its retry backoff. Completion arrives as `surface-resized`, and asynchronous failure arrives as `surface-resize-failed`. Two consecutive CDP call timeouts mark only that browser surface failed with `browser is not responding`. +Browser input, navigation, activation, and browser reconfigure work from `resize-surface` enqueue per-surface CDP work. Protocol v7 `resize-surface` responses include `data.accepted` and `data.reservation_id`; `true` means the resize was applied or queued, and `false` means it was already satisfied, pending, or waiting for its retry backoff. Completion arrives as `surface-resized`, and asynchronous failure arrives as `surface-resize-failed`. Two consecutive CDP call timeouts mark only that browser surface failed with `browser is not responding`. ## Attach Surface diff --git a/cmux-tui/frontends/web/src/lib/tree.ts b/cmux-tui/frontends/web/src/lib/tree.ts index 169561800814..4c474a78b969 100644 --- a/cmux-tui/frontends/web/src/lib/tree.ts +++ b/cmux-tui/frontends/web/src/lib/tree.ts @@ -145,6 +145,8 @@ interface GeneratedTitle { title: string; } +type SurfaceLocation = readonly [workspace: number, screen: number, pane: number, tab: number]; + export interface TreeRefreshCommit { tree: Tree; applied: boolean; @@ -156,6 +158,7 @@ export class SurfaceTitleReconciler { private appliedRequestSequence = 0; private latestTree: Tree | null = null; private readonly titles = new Map(); + private readonly surfaceLocations = new Map(); record(surface: Id, title: string): void { this.titleGeneration += 1; @@ -171,10 +174,14 @@ export class SurfaceTitleReconciler { } apply(tree: Tree): Tree { - return applySurfaceTitles( - tree, - new Map([...this.titles].map(([surface, update]) => [surface, update.title])), - ); + if (tree !== this.latestTree) this.rebuildSurfaceLocations(tree); + let updated = tree; + for (const [surface, update] of this.titles) { + const location = this.surfaceLocations.get(surface); + if (location) updated = this.applyTitleAt(updated, location, update.title); + } + this.latestTree = updated; + return updated; } commit(tree: Tree, token: TreeRefreshToken): TreeRefreshCommit { @@ -188,4 +195,38 @@ export class SurfaceTitleReconciler { this.latestTree = this.apply(tree); return { tree: this.latestTree, applied: true }; } + + private rebuildSurfaceLocations(tree: Tree): void { + this.surfaceLocations.clear(); + tree.workspaces.forEach((workspace, workspaceIndex) => { + workspace.screens.forEach((screen, screenIndex) => { + screen.panes.forEach((pane, paneIndex) => { + if (!("tabs" in pane)) return; + pane.tabs.forEach((tab, tabIndex) => { + this.surfaceLocations.set(tab.surface, [workspaceIndex, screenIndex, paneIndex, tabIndex]); + }); + }); + }); + }); + } + + private applyTitleAt(tree: Tree, location: SurfaceLocation, title: string): Tree { + const [workspaceIndex, screenIndex, paneIndex, tabIndex] = location; + const workspace = tree.workspaces[workspaceIndex]; + const screen = workspace?.screens[screenIndex]; + const pane = screen?.panes[paneIndex]; + if (!pane || !("tabs" in pane)) return tree; + const tab = pane.tabs[tabIndex]; + if (!tab || tab.title === title) return tree; + + const tabs = [...pane.tabs]; + tabs[tabIndex] = { ...tab, title }; + const panes = [...screen.panes]; + panes[paneIndex] = { ...pane, tabs }; + const screens = [...workspace.screens]; + screens[screenIndex] = { ...screen, panes }; + const workspaces = [...tree.workspaces]; + workspaces[workspaceIndex] = { ...workspace, screens }; + return { workspaces }; + } } diff --git a/cmux-tui/frontends/web/test/tree.test.ts b/cmux-tui/frontends/web/test/tree.test.ts index a386bcdc5c43..387fa8c5495e 100644 --- a/cmux-tui/frontends/web/test/tree.test.ts +++ b/cmux-tui/frontends/web/test/tree.test.ts @@ -130,6 +130,25 @@ describe("applySurfaceTitles", () => { ? recovered.workspaces[0]!.screens[0]!.panes[0]!.tabs[0]!.title : null).toBe("newest"); }); + + it("updates only the indexed title path", () => { + const reconciler = new SurfaceTitleReconciler(); + const withSibling = structuredClone(tree); + withSibling.workspaces.push({ + id: 9, + name: "untouched", + active: false, + screens: [], + }); + const untouched = withSibling.workspaces[1]; + reconciler.apply(withSibling); + reconciler.record(4, "indexed"); + + const updated = reconciler.apply(withSibling); + + expect(updated.workspaces[1]).toBe(untouched); + expect(updated.workspaces[0]).not.toBe(withSibling.workspaces[0]); + }); }); import { locateSurface } from "../src/lib/tree"; diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index e6260ec842ed..488e8f6bc35e 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -1499,7 +1499,7 @@ Example: | status | implemented | | since | protocol 5 | -Resizes a surface to a cell grid. PTY surfaces resize both the PTY and VT terminal state. Browser surfaces update their cell grid and CDP device metrics asynchronously. `cols` and `rows` are clamped to at least 1 by the surface runtime. In protocol v6, the final pair becomes the session's latest client size even when the target was already at that size, so later creation requests without a size use the most recent client interaction; internal server resizes (such as the sidebar plugin surface tracking the TUI) never update it. Protocol v7 adds `accepted`: `true` means the resize was applied or queued, while `false` means the surface already has that size, the same browser resize is pending, or its retry backoff has not elapsed. A browser resize completes with `surface-resized`; asynchronous failure is reported by `surface-resize-failed`. +Resizes a surface to a cell grid. PTY surfaces resize both the PTY and VT terminal state. Browser surfaces update their cell grid and CDP device metrics asynchronously. `cols` and `rows` are clamped to at least 1 by the surface runtime. In protocol v6, the final pair becomes the session's latest client size even when the target was already at that size, so later creation requests without a size use the most recent client interaction; internal server resizes (such as the sidebar plugin surface tracking the TUI) never update it. Protocol v7 adds `accepted`: `true` means the resize was applied or queued, while `false` means the surface already has that size, the same browser resize is pending, or its retry backoff has not elapsed. An accepted browser resize returns a numeric `reservation_id`, which is repeated by its `surface-resized` or `surface-resize-failed` completion. PTY resizes and rejected browser resizes return `null` because their completion does not need asynchronous ownership matching. Params: @@ -1512,7 +1512,7 @@ Params: Result: ```text -object{accepted:bool} +object{accepted:bool,reservation_id:uint64|null} ``` Errors: @@ -1536,7 +1536,7 @@ Example: ```json {"id":21,"cmd":"resize-surface","surface":1,"cols":120,"rows":40} -{"id":21,"ok":true,"data":{"accepted":true}} +{"id":21,"ok":true,"data":{"accepted":true,"reservation_id":7}} ``` ### focus-pane diff --git a/cmux-tui/spec/events.md b/cmux-tui/spec/events.md index 8bf5b4ccb650..7387709ae975 100644 --- a/cmux-tui/spec/events.md +++ b/cmux-tui/spec/events.md @@ -223,15 +223,15 @@ Example: Payload: ```text -object{event:"surface-resized",surface:Id,cols:uint16,rows:uint16} +object{event:"surface-resized",surface:Id,cols:uint16,rows:uint16,reservation_id:uint64|null} ``` -Meaning: A surface's final clamped cell size changed. A same-size `resize-surface` command returns success but emits no `surface-resized` event. +Meaning: A surface's final clamped cell size changed. `reservation_id` identifies the accepted asynchronous browser resize that completed, and is `null` for PTY resizes. A same-size `resize-surface` command returns success but emits no `surface-resized` event. Example: ```json -{"event":"surface-resized","surface":1,"cols":120,"rows":40} +{"event":"surface-resized","surface":1,"cols":120,"rows":40,"reservation_id":7} ``` ### surface-resize-failed @@ -245,15 +245,15 @@ Example: Payload: ```text -object{event:"surface-resize-failed",surface:Id,cols:uint16,rows:uint16,error:string,retry_after_ms:uint64|null} +object{event:"surface-resize-failed",surface:Id,cols:uint16,rows:uint16,error:string,retry_after_ms:uint64|null,reservation_id:uint64} ``` -Meaning: An accepted asynchronous browser resize failed. A numeric `retry_after_ms` is the delay before the requesting client retries the same geometry. `null` means automatic retries are exhausted; a new geometry request or browser reconnection may retry it. The event is broadcast, so subscribers that did not request this geometry must not echo it. +Meaning: An accepted asynchronous browser resize failed. `reservation_id` matches the accepted request. A numeric `retry_after_ms` is the delay before the requesting client retries the same geometry. `null` means automatic retries are exhausted; a new geometry request or browser reconnection may retry it. The event is broadcast, so subscribers that did not request this geometry must not echo it. Example: ```json -{"event":"surface-resize-failed","surface":1,"cols":120,"rows":40,"error":"browser is not responding","retry_after_ms":250} +{"event":"surface-resize-failed","surface":1,"cols":120,"rows":40,"error":"browser is not responding","retry_after_ms":250,"reservation_id":7} ``` ### surface-exited From 38697251023bcb0009d8a6e9a1a973e7c90f1242 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:21:35 -0700 Subject: [PATCH 101/103] Cache PTY mouse protocol per surface --- cmux-tui/crates/cmux-tui-core/src/surface.rs | 72 ++++- cmux-tui/crates/cmux-tui/src/app.rs | 293 +++++++++++------- cmux-tui/crates/cmux-tui/src/session/mod.rs | 62 +++- .../crates/cmux-tui/src/session/remote.rs | 73 ++++- cmux-tui/crates/ghostty-vt/src/lib.rs | 2 +- cmux-tui/crates/ghostty-vt/src/mouse.rs | 47 +++ 6 files changed, 423 insertions(+), 126 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index ad5f68d79bd8..f0580c7b453f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -15,7 +15,7 @@ use std::sync::mpsc::{ use std::sync::{Arc, Mutex, TryLockError, Weak}; use std::time::Duration; -use ghostty_vt::{Callbacks, CursorShape, RenderState, Rgb, Terminal}; +use ghostty_vt::{Callbacks, CursorShape, MouseEncoders, MouseInput, RenderState, Rgb, Terminal}; use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; use crate::platform; @@ -322,6 +322,7 @@ impl Deref for Surface { pub struct PtySurface { pub(crate) meta: SurfaceMeta, term: Mutex, + mouse_encoders: Mutex, writer: Mutex>, master: Mutex>, killer: Mutex>, @@ -421,9 +422,12 @@ impl Surface { term.set_default_colors(colors.fg, colors.bg); term.set_default_cursor(colors.cursor_style, colors.cursor_blink); } + let mut mouse_encoders = MouseEncoders::new()?; + mouse_encoders.sync_from_terminal(&term); let surface = Arc::new(Surface::Pty(PtySurface { meta: SurfaceMeta { id, name: Mutex::new(None), selection: Mutex::new(None) }, term: Mutex::new(term), + mouse_encoders: Mutex::new(mouse_encoders), writer: Mutex::new(writer), master: Mutex::new(pty.master), killer: Mutex::new(killer), @@ -455,6 +459,7 @@ impl Surface { let mut term = pty.term.lock().unwrap(); let before = terminal_scroll_position(&term); term.vt_write(&buf[..n]); + pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); let after = terminal_scroll_position(&term); pty.broadcast_attach_output(&buf[..n]); if title_changed.swap(false, Ordering::Relaxed) { @@ -534,10 +539,13 @@ impl Surface { term.set_default_colors(colors.fg, colors.bg); term.set_default_cursor(colors.cursor_style, colors.cursor_blink); } + let mut mouse_encoders = MouseEncoders::new()?; + mouse_encoders.sync_from_terminal(&term); Ok(Arc::new(Surface::Pty(PtySurface { meta: SurfaceMeta { id, name: Mutex::new(None), selection: Mutex::new(None) }, term: Mutex::new(term), + mouse_encoders: Mutex::new(mouse_encoders), writer: Mutex::new(Box::new(std::io::sink())), master: Mutex::new(Box::new(TestMasterPty { size: Mutex::new(PtySize { @@ -601,21 +609,67 @@ impl Surface { /// method is kept for existing PTY call sites. pub fn with_terminal(&self, f: impl FnOnce(&mut Terminal) -> R) -> Option { let pty = self.as_pty()?; - Some(f(&mut pty.term.lock().unwrap())) + let mut term = pty.term.lock().unwrap(); + let result = f(&mut term); + pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); + Some(result) + } + + pub fn encode_mouse( + &self, + input: MouseInput, + output: &mut Vec, + ) -> Option> { + let pty = self.as_pty()?; + match pty.mouse_encoders.try_lock() { + Ok(mut encoders) => Some(encoders.encode(input, output)), + Err(TryLockError::Poisoned(error)) => Some(error.into_inner().encode(input, output)), + Err(TryLockError::WouldBlock) => None, + } + } + + pub fn encode_mouse_release( + &self, + input: MouseInput, + output: &mut Vec, + ) -> Option> { + let pty = self.as_pty()?; + match pty.mouse_encoders.try_lock() { + Ok(mut encoders) => Some(encoders.encode_release(input, output)), + Err(TryLockError::Poisoned(error)) => { + Some(error.into_inner().encode_release(input, output)) + } + Err(TryLockError::WouldBlock) => None, + } } - /// Run `f` only when the terminal state is immediately available. - /// High-frequency input paths use this to drop coalescible samples instead - /// of parking the UI loop behind PTY parsing or replay generation. - pub fn with_terminal_if_uncontended(&self, f: impl FnOnce(&mut Terminal) -> R) -> Option { + pub fn encode_mouse_press_pair( + &self, + press: MouseInput, + release: MouseInput, + press_output: &mut Vec, + release_output: &mut Vec, + ) -> Option> { let pty = self.as_pty()?; - match pty.term.try_lock() { - Ok(mut term) => Some(f(&mut term)), - Err(TryLockError::Poisoned(error)) => Some(f(&mut error.into_inner())), + match pty.mouse_encoders.try_lock() { + Ok(mut encoders) => { + Some(encoders.encode_press_pair(press, release, press_output, release_output)) + } + Err(TryLockError::Poisoned(error)) => Some(error.into_inner().encode_press_pair( + press, + release, + press_output, + release_output, + )), Err(TryLockError::WouldBlock) => None, } } + pub fn reset_mouse_motion_dedupe(&self) { + let Some(pty) = self.as_pty() else { return }; + pty.mouse_encoders.lock().unwrap().reset_motion_dedupe(); + } + pub fn try_with_terminal(&self, f: impl FnOnce(&mut Terminal) -> R) -> anyhow::Result { let Some(pty) = self.as_pty() else { anyhow::bail!("browser surface does not have a VT terminal"); diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 2c636e1d371f..43c6d6883e91 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -29,8 +29,8 @@ use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use ghostty_vt::{ - KeyEncoder, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseEncoder, MouseInput, - RenderState, Screen, + KeyEncoder, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseInput, RenderState, + Screen, }; use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::CrosstermBackend; @@ -1827,6 +1827,7 @@ enum PtyMousePressResult { #[derive(Debug, Clone, Copy)] struct PtyInputForwardResult { + owned: bool, accepted: bool, reservation_id: Option, } @@ -1912,7 +1913,6 @@ pub struct App { drag: Option, ignored_pty_mouse_buttons: HashSet, encoder: KeyEncoder, - mouse_encoder: MouseEncoder, encode_buf: Vec, quit: bool, } @@ -2062,7 +2062,6 @@ pub fn run( }); session.ensure_initial(initial_size)?; let encoder = KeyEncoder::new()?; - let mouse_encoder = MouseEncoder::new()?; let (tx, rx) = sync_channel::(APP_EVENT_CAPACITY); let browser_failure_tx = tx.clone(); let browser_control_tx = tx.clone(); @@ -2221,7 +2220,6 @@ pub fn run( drag: None, ignored_pty_mouse_buttons: HashSet::new(), encoder, - mouse_encoder, encode_buf: Vec::with_capacity(64), quit: false, }; @@ -2451,8 +2449,9 @@ impl App { } if failure.kind == Some(PtyInputKind::Motion) && failure.delivery == PtyOperationDelivery::KnownNotDelivered + && let Some(surface) = failure.surface_id.and_then(|id| self.session.surface(id)) { - self.mouse_encoder.reset_motion_dedupe(); + surface.reset_mouse_motion_dedupe(); } let failed_active_press = failure.kind == Some(PtyInputKind::Press) && failure.delivery == PtyOperationDelivery::KnownNotDelivered @@ -5001,18 +5000,10 @@ impl App { let Some(area) = self.pane_area_at(x, y).copied() else { return PtyMousePressResult::NotOwned; }; - if !area.content.contains(x, y) - || self.surface_kind(area.surface) != Some(SurfaceKind::Pty) - || !self.pty_mouse_tracking(area.surface) + if !area.content.contains(x, y) || self.surface_kind(area.surface) != Some(SurfaceKind::Pty) { return PtyMousePressResult::NotOwned; } - - if self.active_pane() != Some(area.pane) { - self.focus_pane_after_input(area.pane); - } - self.sidebar_focused = false; - self.selection = None; let Some(handle) = self.session.surface(area.surface) else { return PtyMousePressResult::Consumed; }; @@ -5024,6 +5015,14 @@ impl App { button, modifiers, ); + if !forwarded.owned { + return PtyMousePressResult::NotOwned; + } + if self.active_pane() != Some(area.pane) { + self.focus_pane_after_input(area.pane); + } + self.sidebar_focused = false; + self.selection = None; if matches!(release_capture, PtyMouseReleaseCapture::Failed) { return PtyMousePressResult::Consumed; } @@ -5061,7 +5060,8 @@ impl App { modifiers: KeyModifiers, ) -> (PtyMouseReleaseCapture, PtyInputForwardResult) { let (surface_id, surface) = route; - let failed = || PtyInputForwardResult { accepted: false, reservation_id: None }; + let failed = + || PtyInputForwardResult { owned: true, accepted: false, reservation_id: None }; let cell_width = u32::from(self.cell_pixels.0.max(1)); let cell_height = u32::from(self.cell_pixels.1.max(1)); let position = ( @@ -5083,18 +5083,15 @@ impl App { }; let release = MouseInput { action: MouseAction::Release, any_button_pressed: false, ..press }; - let Ok(mut release_encoder) = MouseEncoder::new() else { + let mut release_output = Vec::new(); + let mut press_output = Vec::new(); + let Some(encoded) = + surface.encode_mouse_press_pair(press, release, &mut press_output, &mut release_output) + else { return (PtyMouseReleaseCapture::Failed, failed()); }; - let mut release_output = Vec::new(); - self.encode_buf.clear(); - let encoded = surface.with_terminal(|terminal| { - release_encoder.sync_from_terminal(terminal); - release_encoder.encode(release, &mut release_output)?; - self.mouse_encoder.sync_from_terminal(terminal); - self.mouse_encoder.encode(press, &mut self.encode_buf) - }); - if !matches!(encoded, Some(Ok(()))) { + self.encode_buf = press_output; + if encoded.is_err() { return (PtyMouseReleaseCapture::Failed, failed()); } let release_capture = if release_output.is_empty() { @@ -5105,7 +5102,7 @@ impl App { if self.encode_buf.is_empty() { return ( release_capture, - PtyInputForwardResult { accepted: true, reservation_id: None }, + PtyInputForwardResult { owned: false, accepted: true, reservation_id: None }, ); } let kind = if matches!(release_capture, PtyMouseReleaseCapture::Bytes(_)) { @@ -5226,18 +5223,14 @@ impl App { cell_size: (cell_width, cell_height), any_button_pressed: false, }; - let Ok(mut encoder) = MouseEncoder::new() else { + let mut output = Vec::new(); + let Some(encoded) = surface.encode_mouse_release(input, &mut output) else { return PtyMouseReleaseCapture::Failed; }; - let mut output = Vec::new(); - let encoded = surface.with_terminal(|terminal| { - encoder.sync_from_terminal(terminal); - encoder.encode(input, &mut output) - }); match encoded { - Some(Ok(())) if output.is_empty() => PtyMouseReleaseCapture::NotReported, - Some(Ok(())) => PtyMouseReleaseCapture::Bytes(PtyInputBytes::from_slice(&output)), - Some(Err(_)) | None => PtyMouseReleaseCapture::Failed, + Ok(()) if output.is_empty() => PtyMouseReleaseCapture::NotReported, + Ok(()) => PtyMouseReleaseCapture::Bytes(PtyInputBytes::from_slice(&output)), + Err(_) => PtyMouseReleaseCapture::Failed, } } @@ -5335,10 +5328,7 @@ impl App { any_button_pressed, ); } - if !self.pty_mouse_tracking(area.surface) { - return false; - } - let _ = self.forward_pty_mouse_to_surface( + self.forward_pty_mouse_to_surface( area.surface, area.content, x, @@ -5347,8 +5337,8 @@ impl App { button, modifiers, any_button_pressed, - ); - true + ) + .owned } fn forward_pty_mouse_motion_if_uncontended( @@ -5380,22 +5370,19 @@ impl App { any_button_pressed, }; - self.encode_buf.clear(); - let Some(encoded) = surface.with_terminal_if_uncontended(|terminal| { - if !terminal.mouse_tracking() { - return None; - } - self.mouse_encoder.sync_from_terminal(terminal); - Some(self.mouse_encoder.encode(input, &mut self.encode_buf)) - }) else { - // Motion is coalescible. Drop a sample that races terminal - // parsing while the normal cmux hover bookkeeping continues. + let mut output = Vec::new(); + let Some(encoded) = surface.encode_mouse(input, &mut output) else { + // The first sample can race terminal initialization. Motion is + // coalescible, so consume it without parking the UI loop. return true; }; - let Some(encoded) = encoded else { return false }; - if encoded.is_err() || self.encode_buf.is_empty() { + self.encode_buf = output; + if encoded.is_err() { return true; } + if self.encode_buf.is_empty() { + return false; + } let _ = self.write_encoded_pty_bytes(surface_id, surface, PtyInputKind::Motion); true } @@ -5413,7 +5400,7 @@ impl App { any_button_pressed: bool, ) -> PtyInputForwardResult { let Some(surface) = self.session.surface(surface_id) else { - return PtyInputForwardResult { accepted: false, reservation_id: None }; + return PtyInputForwardResult { owned: false, accepted: false, reservation_id: None }; }; let cell_width = u32::from(self.cell_pixels.0.max(1)); let cell_height = u32::from(self.cell_pixels.1.max(1)); @@ -5434,21 +5421,19 @@ impl App { any_button_pressed, }; - self.encode_buf.clear(); - let Some(encoded) = surface.with_terminal(|terminal| { - self.mouse_encoder.sync_from_terminal(terminal); - self.mouse_encoder.encode(input, &mut self.encode_buf) - }) else { - return PtyInputForwardResult { accepted: false, reservation_id: None }; + let mut output = Vec::new(); + let Some(encoded) = surface.encode_mouse(input, &mut output) else { + return PtyInputForwardResult { owned: true, accepted: false, reservation_id: None }; }; + self.encode_buf = output; if encoded.is_err() { - return PtyInputForwardResult { accepted: false, reservation_id: None }; + return PtyInputForwardResult { owned: true, accepted: false, reservation_id: None }; } if self.encode_buf.is_empty() { if action == MouseAction::Release { self.cancel_pty_release_reservation(); } - return PtyInputForwardResult { accepted: true, reservation_id: None }; + return PtyInputForwardResult { owned: false, accepted: true, reservation_id: None }; } let kind = match action { MouseAction::Press @@ -5467,7 +5452,9 @@ impl App { MouseAction::Release => PtyInputKind::Release, MouseAction::Motion => PtyInputKind::Motion, }; - self.write_encoded_pty_bytes(surface_id, surface, kind) + let mut forwarded = self.write_encoded_pty_bytes(surface_id, surface, kind); + forwarded.owned = true; + forwarded } fn write_encoded_pty_bytes( @@ -5500,19 +5487,25 @@ impl App { let (result, reservation_id) = self .pty_input .enqueue_with_reservation(PtyInputEvent::input(surface_id, surface, bytes, kind)); - self.rollback_mouse_motion_for_enqueue_failure(kind, result); - PtyInputForwardResult { accepted: self.handle_pty_enqueue_result(result), reservation_id } + self.rollback_mouse_motion_for_enqueue_failure(surface_id, kind, result); + PtyInputForwardResult { + owned: true, + accepted: self.handle_pty_enqueue_result(result), + reservation_id, + } } fn rollback_mouse_motion_for_enqueue_failure( &mut self, + surface_id: SurfaceId, kind: PtyInputKind, result: PtyInputEnqueueResult, ) { if kind == PtyInputKind::Motion && matches!(result, PtyInputEnqueueResult::Saturated | PtyInputEnqueueResult::Failed) + && let Some(surface) = self.session.surface(surface_id) { - self.mouse_encoder.reset_motion_dedupe(); + surface.reset_mouse_motion_dedupe(); } } @@ -5557,13 +5550,6 @@ impl App { } } - fn pty_mouse_tracking(&self, surface_id: SurfaceId) -> bool { - self.session - .surface(surface_id) - .and_then(|surface| surface.with_terminal(|terminal| terminal.mouse_tracking())) - .unwrap_or(false) - } - fn ghostty_mouse_button(button: MouseButton) -> GhosttyMouseButton { match button { MouseButton::Left => GhosttyMouseButton::Left, @@ -6594,11 +6580,12 @@ mod tests { use super::{ App, AppEvent, BACKGROUND_REFRESH_RETRIES, DeferredInput, Drag, ForwardMuxOutcome, MuxTitleIngress, OrderedSession, PaneArea, PendingSessionMutation, - PendingSessionMutationState, PtyFailureIngress, RenderAction, Selection, SessionCompletion, - SessionCompletionAction, SidebarPluginSyncClaim, SidebarPluginSyncState, - SurfaceResizeDecision, SurfaceResizeOwnership, browser_content_size_for_rect, - browser_hover_forward_allowed, forward_mux_event, forward_mux_events, pane_parts_for_rect, - record_surface_resize_dispatch_result, sidebar_plugin_status_settles_passive_claim, + PendingSessionMutationState, PtyFailureIngress, PtyMousePressResult, RenderAction, + Selection, SessionCompletion, SessionCompletionAction, SidebarPluginSyncClaim, + SidebarPluginSyncState, SurfaceResizeDecision, SurfaceResizeOwnership, + browser_content_size_for_rect, browser_hover_forward_allowed, forward_mux_event, + forward_mux_events, pane_parts_for_rect, record_surface_resize_dispatch_result, + sidebar_plugin_status_settles_passive_claim, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -6614,8 +6601,7 @@ mod tests { Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ghostty_vt::{ - Callbacks, KeyEncoder, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseEncoder, - MouseInput, RenderState, + KeyEncoder, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseInput, RenderState, }; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -6825,6 +6811,7 @@ mod tests { }, ); let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1003h\x1b[?1006h")); let held_surface = surface.clone(); let (locked_tx, locked_rx) = std::sync::mpsc::channel(); let (release_tx, release_rx) = std::sync::mpsc::channel(); @@ -6859,6 +6846,100 @@ mod tests { mux.close_surface(surface.id); } + #[test] + fn pty_mouse_press_does_not_wait_for_terminal_parsing() { + let mux = Mux::new( + "mouse-press-lock-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]), + ..Default::default() + }, + ); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + let pane = app.tree.active_screen().unwrap().active_pane; + let content = Rect { x: 2, y: 3, width: 20, height: 8 }; + app.pane_areas.push(PaneArea { + pane, + surface: surface.id, + rect: Rect { x: 1, y: 2, width: 23, height: 10 }, + bar: Some(Rect { x: 1, y: 2, width: 23, height: 1 }), + omnibar: None, + content, + track: None, + }); + + let held_surface = surface.clone(); + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + held_surface.with_terminal(|_| { + locked_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }); + }); + locked_rx.recv().unwrap(); + + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let input = std::thread::spawn(move || { + result_tx + .send(app.begin_pty_mouse_drag( + content.x + 4, + content.y + 2, + MouseButton::Left, + KeyModifiers::NONE, + )) + .unwrap(); + }); + let result = result_rx.recv_timeout(Duration::from_millis(250)); + release_tx.send(()).unwrap(); + holder.join().unwrap(); + input.join().unwrap(); + + assert_eq!(result.unwrap(), PtyMousePressResult::Started); + mux.close_surface(surface.id); + } + + #[test] + fn disabled_mouse_snapshot_does_not_consume_press() { + let mux = Mux::new("disabled-mouse-press-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + let pane = app.tree.active_screen().unwrap().active_pane; + let content = Rect { x: 2, y: 3, width: 20, height: 8 }; + app.pane_areas.push(PaneArea { + pane, + surface: surface.id, + rect: Rect { x: 1, y: 2, width: 23, height: 10 }, + bar: Some(Rect { x: 1, y: 2, width: 23, height: 1 }), + omnibar: None, + content, + track: None, + }); + app.sidebar_focused = true; + + assert_eq!( + app.begin_pty_mouse_drag( + content.x + 4, + content.y + 2, + MouseButton::Left, + KeyModifiers::NONE, + ), + PtyMousePressResult::NotOwned + ); + assert!(app.sidebar_focused); + assert!(app.drag.is_none()); + assert!(app.encode_buf.is_empty()); + mux.close_surface(surface.id); + } + #[test] fn rejected_input_shows_an_error_without_disconnecting() { let mux = Mux::new("oversized-input-test", SurfaceOptions::default()); @@ -6961,40 +7042,45 @@ mod tests { #[test] fn rejected_motion_enqueue_rolls_back_mouse_encoder_dedupe() { let mux = Mux::new("motion-enqueue-rollback-test", SurfaceOptions::default()); - let mut app = test_app(Session::Local(mux)); - let mut terminal = ghostty_vt::Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); - terminal.vt_write(b"\x1b[?1003h\x1b[?1006h"); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1003h\x1b[?1006h")); + let handle = SurfaceHandle::Local(surface.clone()); + let mut app = test_app(Session::Local(mux.clone())); let input = test_mouse_motion(); - assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); - assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(!encode_test_mouse_motion(&handle, input).is_empty()); + assert!(encode_test_mouse_motion(&handle, input).is_empty()); app.rollback_mouse_motion_for_enqueue_failure( + surface.id, PtyInputKind::Motion, PtyInputEnqueueResult::Saturated, ); - assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); - assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(!encode_test_mouse_motion(&handle, input).is_empty()); + assert!(encode_test_mouse_motion(&handle, input).is_empty()); app.rollback_mouse_motion_for_enqueue_failure( + surface.id, PtyInputKind::Motion, PtyInputEnqueueResult::Failed, ); - assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(!encode_test_mouse_motion(&handle, input).is_empty()); + mux.close_surface(surface.id); } #[test] fn evicted_known_undelivered_motion_allows_same_cell_retry() { let mux = Mux::new("motion-cancel-rollback-test", SurfaceOptions::default()); - let mut app = test_app(Session::Local(mux)); - let mut terminal = ghostty_vt::Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); - terminal.vt_write(b"\x1b[?1003h\x1b[?1006h"); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1003h\x1b[?1006h")); + let handle = SurfaceHandle::Local(surface.clone()); + let mut app = test_app(Session::Local(mux.clone())); let input = test_mouse_motion(); - assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); - assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(!encode_test_mouse_motion(&handle, input).is_empty()); + assert!(encode_test_mouse_motion(&handle, input).is_empty()); app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { - surface_id: Some(42), + surface_id: Some(surface.id), kind: Some(PtyInputKind::Motion), reservation_id: None, label: "PTY input", @@ -7003,10 +7089,10 @@ mod tests { delivery: PtyOperationDelivery::Ambiguous, })) .unwrap(); - assert!(encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(encode_test_mouse_motion(&handle, input).is_empty()); app.handle(AppEvent::PtyOperationFailed(PtyOperationFailure { - surface_id: Some(42), + surface_id: Some(surface.id), kind: Some(PtyInputKind::Motion), reservation_id: None, label: "PTY input", @@ -7015,7 +7101,8 @@ mod tests { delivery: PtyOperationDelivery::KnownNotDelivered, })) .unwrap(); - assert!(!encode_test_mouse_motion(&mut app, &terminal, input).is_empty()); + assert!(!encode_test_mouse_motion(&handle, input).is_empty()); + mux.close_surface(surface.id); } #[test] @@ -9054,14 +9141,9 @@ mod tests { } } - fn encode_test_mouse_motion( - app: &mut App, - terminal: &ghostty_vt::Terminal, - input: MouseInput, - ) -> Vec { + fn encode_test_mouse_motion(surface: &SurfaceHandle, input: MouseInput) -> Vec { let mut output = Vec::new(); - app.mouse_encoder.sync_from_terminal(terminal); - app.mouse_encoder.encode(input, &mut output).unwrap(); + surface.encode_mouse(input, &mut output).unwrap().unwrap(); output } @@ -9272,7 +9354,6 @@ mod tests { drag: None, ignored_pty_mouse_buttons: HashSet::new(), encoder: KeyEncoder::new().unwrap(), - mouse_encoder: MouseEncoder::new().unwrap(), encode_buf: Vec::new(), quit: false, }; diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index dae9bd3c1a3d..babc02237a43 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -18,7 +18,7 @@ use cmux_tui_core::{ SidebarPluginStatus, SplitDir, Surface, SurfaceId, SurfaceKind, SurfaceResizeReporter, WorkspaceId, ZoomMode, }; -use ghostty_vt::{RenderState, Terminal}; +use ghostty_vt::{MouseInput, RenderState, Terminal}; use serde_json::json; pub use remote::{RemoteSession, RemoteSurface}; @@ -804,25 +804,71 @@ impl SurfaceHandle { match self { SurfaceHandle::Local(surface) => surface.with_terminal(f), SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { - Some(f(&mut surface.term.lock().unwrap())) + let mut terminal = surface.term.lock().unwrap(); + let result = f(&mut terminal); + surface.sync_mouse_encoders(&terminal); + Some(result) } SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, } } - /// Run `f` only when the local or mirrored terminal lock is immediately - /// available. `None` also covers non-PTY surfaces. - pub fn with_terminal_if_uncontended(&self, f: impl FnOnce(&mut Terminal) -> R) -> Option { + pub fn encode_mouse( + &self, + input: MouseInput, + output: &mut Vec, + ) -> Option> { match self { - SurfaceHandle::Local(surface) => surface.with_terminal_if_uncontended(f), + SurfaceHandle::Local(surface) => surface.encode_mouse(input, output), SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { - let mut term = surface.term.try_lock().ok()?; - Some(f(&mut term)) + surface.encode_mouse(input, output) } SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, } } + pub fn encode_mouse_release( + &self, + input: MouseInput, + output: &mut Vec, + ) -> Option> { + match self { + SurfaceHandle::Local(surface) => surface.encode_mouse_release(input, output), + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { + surface.encode_mouse_release(input, output) + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, + } + } + + pub fn encode_mouse_press_pair( + &self, + press: MouseInput, + release: MouseInput, + press_output: &mut Vec, + release_output: &mut Vec, + ) -> Option> { + match self { + SurfaceHandle::Local(surface) => { + surface.encode_mouse_press_pair(press, release, press_output, release_output) + } + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { + surface.encode_mouse_press_pair(press, release, press_output, release_output) + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, + } + } + + pub fn reset_mouse_motion_dedupe(&self) { + match self { + SurfaceHandle::Local(surface) => surface.reset_mouse_motion_dedupe(), + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { + surface.reset_mouse_motion_dedupe(); + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => {} + } + } + pub fn scroll_delta(&self, delta: isize) -> Option { match self { SurfaceHandle::Local(surface) => { diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 86ab79261e8a..9edba1c9945d 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -17,7 +17,7 @@ use cmux_tui_core::{ MuxEventReceiver, NotificationEvent, NotificationLevel, Rgb, SurfaceId, SurfaceKind, platform::transport, }; -use ghostty_vt::{Callbacks, RenderState, Terminal}; +use ghostty_vt::{Callbacks, MouseEncoders, MouseInput, RenderState, Terminal}; use serde_json::{Value, json}; use super::tree::{TreeView, parse_tree}; @@ -192,6 +192,7 @@ pub struct RemoteSurface { pub id: SurfaceId, pub kind: SurfaceKind, pub term: Mutex, + mouse_encoders: Mutex, pub dirty: AtomicBool, server_size: Mutex<(u16, u16)>, asserted_size: Mutex>, @@ -199,6 +200,60 @@ pub struct RemoteSurface { } impl RemoteSurface { + pub(super) fn sync_mouse_encoders(&self, terminal: &Terminal) { + self.mouse_encoders.lock().unwrap().sync_from_terminal(terminal); + } + + pub(super) fn encode_mouse( + &self, + input: MouseInput, + output: &mut Vec, + ) -> Option> { + match self.mouse_encoders.try_lock() { + Ok(mut encoders) => Some(encoders.encode(input, output)), + Err(std::sync::TryLockError::Poisoned(error)) => { + Some(error.into_inner().encode(input, output)) + } + Err(std::sync::TryLockError::WouldBlock) => None, + } + } + + pub(super) fn encode_mouse_release( + &self, + input: MouseInput, + output: &mut Vec, + ) -> Option> { + match self.mouse_encoders.try_lock() { + Ok(mut encoders) => Some(encoders.encode_release(input, output)), + Err(std::sync::TryLockError::Poisoned(error)) => { + Some(error.into_inner().encode_release(input, output)) + } + Err(std::sync::TryLockError::WouldBlock) => None, + } + } + + pub(super) fn encode_mouse_press_pair( + &self, + press: MouseInput, + release: MouseInput, + press_output: &mut Vec, + release_output: &mut Vec, + ) -> Option> { + match self.mouse_encoders.try_lock() { + Ok(mut encoders) => { + Some(encoders.encode_press_pair(press, release, press_output, release_output)) + } + Err(std::sync::TryLockError::Poisoned(error)) => Some( + error.into_inner().encode_press_pair(press, release, press_output, release_output), + ), + Err(std::sync::TryLockError::WouldBlock) => None, + } + } + + pub(super) fn reset_mouse_motion_dedupe(&self) { + self.mouse_encoders.lock().unwrap().reset_motion_dedupe(); + } + pub(super) fn set_server_size(&self, cols: u16, rows: u16) { let (cols, rows) = (cols.max(1), rows.max(1)); *self.server_size.lock().unwrap() = (cols, rows); @@ -214,9 +269,11 @@ impl RemoteSurface { { fresh.vt_write(replay); *term = fresh; + self.sync_mouse_encoders(&term); return; } let _ = term.resize(cols, rows, 8, 16); + self.sync_mouse_encoders(&term); } pub(super) fn server_size(&self) -> (u16, u16) { @@ -433,6 +490,7 @@ impl RemoteSession { surface.apply_stream_resize(cols, rows, None); let mut term = surface.term.lock().unwrap(); term.vt_write(&replay); + surface.sync_mouse_encoders(&term); drop(term); surface.dirty.store(true, Ordering::Release); } @@ -480,7 +538,10 @@ impl RemoteSession { }; self.log_frame(id, format!("output bytes={}", bytes.len())); if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { - surface.term.lock().unwrap().vt_write(&bytes); + let mut term = surface.term.lock().unwrap(); + term.vt_write(&bytes); + surface.sync_mouse_encoders(&term); + drop(term); if !surface.dirty.swap(true, Ordering::AcqRel) { self.emit(MuxEvent::SurfaceOutput(id)); } @@ -890,6 +951,7 @@ impl RemoteSession { id, kind, term: Mutex::new(term), + mouse_encoders: Mutex::new(MouseEncoders::new()?), dirty: AtomicBool::new(false), server_size: Mutex::new((cols, rows)), asserted_size: Mutex::new(None), @@ -1372,6 +1434,7 @@ mod tests { id: 1, kind: SurfaceKind::Browser, term: Mutex::new(Terminal::new(10, 5, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((10, 5)), asserted_size: Mutex::new(None), @@ -1413,6 +1476,7 @@ mod tests { id: 1, kind: SurfaceKind::Pty, term: Mutex::new(Terminal::new(20, 6, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((20, 6)), asserted_size: Mutex::new(None), @@ -1446,6 +1510,7 @@ mod tests { id: 7, kind: SurfaceKind::Pty, term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((12, 4)), asserted_size: Mutex::new(None), @@ -1482,6 +1547,7 @@ mod tests { id: 7, kind: SurfaceKind::Browser, term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((12, 4)), asserted_size: Mutex::new(Some((12, 4))), @@ -1513,6 +1579,7 @@ mod tests { id: 7, kind: SurfaceKind::Browser, term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((12, 4)), asserted_size: Mutex::new(Some((90, 31))), @@ -1662,6 +1729,7 @@ mod tests { id: 7, kind: SurfaceKind::Pty, term: Mutex::new(Terminal::new(80, 24, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((80, 24)), asserted_size: Mutex::new(None), @@ -1700,6 +1768,7 @@ mod tests { id: 1, kind: SurfaceKind::Pty, term: Mutex::new(Terminal::new(12, 3, 100, Callbacks::default()).unwrap()), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), server_size: Mutex::new((12, 3)), asserted_size: Mutex::new(None), diff --git a/cmux-tui/crates/ghostty-vt/src/lib.rs b/cmux-tui/crates/ghostty-vt/src/lib.rs index db649d8795a7..0da43391e1a4 100644 --- a/cmux-tui/crates/ghostty-vt/src/lib.rs +++ b/cmux-tui/crates/ghostty-vt/src/lib.rs @@ -13,7 +13,7 @@ mod terminal; pub use ghostty_vt_sys as sys; pub use key::{KeyAction, KeyEncoder, KeyInput, Mods, key_input_from_chord}; -pub use mouse::{MouseAction, MouseButton, MouseEncoder, MouseInput}; +pub use mouse::{MouseAction, MouseButton, MouseEncoder, MouseEncoders, MouseInput}; pub use render::{Cell, ColorSpec, CursorInfo, CursorShape, Dirty, RenderState}; pub use terminal::{Callbacks, NotifyFn, PtyWriteFn, Rgb, Screen, Scrollbar, Terminal}; diff --git a/cmux-tui/crates/ghostty-vt/src/mouse.rs b/cmux-tui/crates/ghostty-vt/src/mouse.rs index c1dca6c0919e..723ea93c8722 100644 --- a/cmux-tui/crates/ghostty-vt/src/mouse.rs +++ b/cmux-tui/crates/ghostty-vt/src/mouse.rs @@ -48,6 +48,53 @@ pub struct MouseEncoder { size: Option<((u32, u32), (u32, u32))>, } +// The opaque Ghostty encoder and event have no thread affinity. MouseEncoder +// owns both pointers and requires &mut self for every operation, so moving the +// pair between threads is safe. Shared access remains guarded by the caller. +unsafe impl Send for MouseEncoder {} + +/// Per-surface encoders synchronized when terminal mouse modes change. +/// Keeping both encoders behind the surface avoids taking the terminal lock +/// on UI pointer paths while preserving a press/release protocol snapshot. +pub struct MouseEncoders { + primary: MouseEncoder, + release: MouseEncoder, +} + +impl MouseEncoders { + pub fn new() -> Result { + Ok(Self { primary: MouseEncoder::new()?, release: MouseEncoder::new()? }) + } + + pub fn sync_from_terminal(&mut self, terminal: &Terminal) { + self.primary.sync_from_terminal(terminal); + self.release.sync_from_terminal(terminal); + } + + pub fn encode(&mut self, input: MouseInput, out: &mut Vec) -> Result<()> { + self.primary.encode(input, out) + } + + pub fn encode_release(&mut self, input: MouseInput, out: &mut Vec) -> Result<()> { + self.release.encode(input, out) + } + + pub fn encode_press_pair( + &mut self, + press: MouseInput, + release: MouseInput, + press_out: &mut Vec, + release_out: &mut Vec, + ) -> Result<()> { + self.release.encode(release, release_out)?; + self.primary.encode(press, press_out) + } + + pub fn reset_motion_dedupe(&mut self) { + self.primary.reset_motion_dedupe(); + } +} + impl MouseEncoder { pub fn new() -> Result { let mut encoder: sys::GhosttyMouseEncoder = ptr::null_mut(); From 2091374d299fcd9b842c0b383cd67aae13803151 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:44:26 -0700 Subject: [PATCH 102/103] Preserve mux events under backpressure --- cmux-tui/crates/cmux-tui-core/src/browser.rs | 64 +++++++-- cmux-tui/crates/cmux-tui-core/src/lib.rs | 8 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 54 ++++---- cmux-tui/crates/cmux-tui-core/src/server.rs | 17 ++- cmux-tui/crates/cmux-tui/src/app.rs | 127 +++++++++++------- cmux-tui/crates/cmux-tui/src/session/mod.rs | 38 +++++- .../crates/cmux-tui/src/session/remote.rs | 24 +++- 7 files changed, 232 insertions(+), 100 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index eecf1a42aa90..c319247cedc7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -1048,15 +1048,10 @@ impl BrowserSurface { height_px: u16, report: Box) + Send>, ) -> anyhow::Result> { - { - let mut cell = self.cell_pixels.lock().unwrap(); - let next = (width_px.max(1), height_px.max(1)); - if *cell == next { - report(None); - return Ok(None); - } - *cell = next; - } + // Store desired metrics before calculating the candidate geometry. + // Settled geometry remains in BrowserState, so an enqueue rejection + // leaves a visible mismatch that the same request can retry. + *self.cell_pixels.lock().unwrap() = (width_px.max(1), height_px.max(1)); let (cols, rows) = self.size(); self.resize_reporting_acceptance(cols, rows, report) } @@ -1834,9 +1829,9 @@ fn percent_encode_query(input: &str) -> String { #[cfg(test)] mod tests { use super::{ - BrowserCaptureOptions, BrowserCommand, BrowserFrame, BrowserSource, BrowserStatus, - capture_scale_for, new_surface, normalize_url, runtime_endpoint, scaled_pixels, - take_latest_worker_commands, + BROWSER_COMMAND_QUEUE_CAPACITY, BrowserCaptureOptions, BrowserCommand, BrowserFrame, + BrowserSource, BrowserStatus, capture_scale_for, new_surface, normalize_url, + runtime_endpoint, scaled_pixels, take_latest_worker_commands, }; use crate::{Mux, MuxEvent, Surface, SurfaceOptions}; use serde_json::{Value, json}; @@ -2588,6 +2583,51 @@ mod tests { assert!(!browser.set_cell_pixel_size(9, 16).unwrap()); } + #[test] + fn rejected_cell_pixel_enqueue_can_retry_the_same_metrics() { + let surface = test_surface(); + let browser = surface.as_browser().expect("browser surface"); + let done = browser.take_worker_done_for_test(); + let (entered, started) = mpsc::channel(); + let (release, held) = mpsc::channel(); + browser + .command_sender() + .unwrap() + .send(BrowserCommand::Hold { entered, release: held }) + .unwrap(); + started.recv_timeout(Duration::from_secs(1)).unwrap(); + let sender = browser.command_sender().unwrap(); + for _ in 0..BROWSER_COMMAND_QUEUE_CAPACITY { + sender.try_send(BrowserCommand::Activate).unwrap(); + } + + let (reported_tx, reported_rx) = mpsc::channel(); + assert!( + browser + .set_cell_pixel_size_reporting( + 9, + 16, + Box::new(move |accepted| reported_tx.send(accepted).unwrap()), + ) + .is_err() + ); + assert!(reported_rx.recv_timeout(Duration::from_secs(1)).unwrap().is_none()); + + drop(sender); + release.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + loop { + match browser.set_cell_pixel_size(9, 16) { + Ok(true) => break, + Err(_) if Instant::now() < deadline => thread::yield_now(), + result => panic!("same cell metrics were not retryable: {result:?}"), + } + } + + browser.kill(); + done.recv_timeout(Duration::from_secs(1)).expect("browser worker exited after retry"); + } + #[test] fn resize_acceptance_is_reported_by_worker_before_execution() { let surface = test_surface(); diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 8fdcc214d9aa..53689f49202c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -27,10 +27,10 @@ pub use layout::{ }; pub use model::{Node, Pane, Screen, State, Workspace}; pub use mux::{ - AgentRecord, AgentSource, AgentState, AppliedLayout, AppliedPane, Direction, LayoutLeafSpec, - LayoutSpec, Mux, MuxEvent, NotificationEvent, NotificationLevel, RunPlacement, - SidebarPluginOptions, SidebarPluginStatus, SurfaceNotification, SurfaceResizeReporter, - ZoomMode, ZoomState, + AgentRecord, AgentSource, AgentState, AppliedLayout, AppliedPane, CellPixelUpdate, + CellPixelUpdateFailure, Direction, LayoutLeafSpec, LayoutSpec, Mux, MuxEvent, + NotificationEvent, NotificationLevel, RunPlacement, SidebarPluginOptions, SidebarPluginStatus, + SurfaceNotification, SurfaceResizeReporter, ZoomMode, ZoomState, }; pub use short_id::assign_short_ids; pub use surface::{ diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index b2f50cadd1e9..64db098bdb82 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -15,6 +15,18 @@ use crate::{PaneId, ScreenId, SplitDir, SurfaceId, WorkspaceId}; pub type SurfaceResizeReporter = Arc) + Send + Sync>; +#[derive(Debug, Default)] +pub struct CellPixelUpdate { + pub resizes: Vec<(SurfaceId, (u16, u16), u64)>, + pub failures: Vec, +} + +#[derive(Debug)] +pub struct CellPixelUpdateFailure { + pub surface: SurfaceId, + pub error: String, +} + /// Events pushed to subscribed frontends. #[derive(Debug, Clone)] pub enum MuxEvent { @@ -734,11 +746,7 @@ impl Mux { } } - pub fn set_cell_pixel_size( - &self, - width_px: u16, - height_px: u16, - ) -> Vec<(SurfaceId, (u16, u16), u64)> { + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> CellPixelUpdate { self.set_cell_pixel_size_reporting(width_px, height_px, Arc::new(|_, _, _| {})) } @@ -747,33 +755,31 @@ impl Mux { width_px: u16, height_px: u16, report: SurfaceResizeReporter, - ) -> Vec<(SurfaceId, (u16, u16), u64)> { + ) -> CellPixelUpdate { let next = (width_px.max(1), height_px.max(1)); - { - let mut cell = self.cell_pixels.lock().unwrap(); - if *cell == next { - return Vec::new(); - } - *cell = next; - } + // This is the desired global metric used for new browser surfaces. + // Existing surfaces still check their settled geometry on every call, + // so a rejected queue submission can be retried with the same value. + *self.cell_pixels.lock().unwrap() = next; let surfaces = self.state.lock().unwrap().surfaces.values().cloned().collect::>(); - let mut accepted = Vec::new(); + let mut update = CellPixelUpdate::default(); for surface in surfaces { let id = surface.id; let size = surface.size(); let callback = report.clone(); - if let Some(reservation_id) = surface - .set_cell_pixel_size_reporting( - next.0, - next.1, - Box::new(move |accepted| callback(id, size, accepted)), - ) - .unwrap_or(None) - { - accepted.push((id, size, reservation_id)); + match surface.set_cell_pixel_size_reporting( + next.0, + next.1, + Box::new(move |accepted| callback(id, size, accepted)), + ) { + Ok(Some(reservation_id)) => update.resizes.push((id, size, reservation_id)), + Ok(None) => {} + Err(error) => update + .failures + .push(CellPixelUpdateFailure { surface: id, error: error.to_string() }), } } - accepted + update } pub fn default_colors(&self) -> DefaultColors { diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index d7f3ad44b637..6b4820ddec16 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -2102,8 +2102,9 @@ fn handle_command( Ok(json!({ "surface": surface.id })) } Command::SetCellPixels { width_px, height_px } => { - let resizes = mux - .set_cell_pixel_size(width_px, height_px) + let update = mux.set_cell_pixel_size(width_px, height_px); + let resizes = update + .resizes .into_iter() .map(|(surface, (cols, rows), reservation_id)| { json!({ @@ -2114,7 +2115,17 @@ fn handle_command( }) }) .collect::>(); - Ok(json!({"resizes": resizes})) + let failures = update + .failures + .into_iter() + .map(|failure| { + json!({ + "surface": failure.surface, + "error": failure.error, + }) + }) + .collect::>(); + Ok(json!({"resizes": resizes, "failures": failures})) } Command::BrowserMouse { surface, kind, x_px, y_px, button, click_count } => { let surface = get_surface(mux, surface)?; diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 43c6d6883e91..b6ef924f6039 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -108,33 +108,46 @@ fn forward_mux_events( ) { let mut next_recovery_generation = 0_u64; loop { - let needs_recovery = if session_events.overflowed() { - true - } else { - match session_events.recv() { - Ok(event) => match forward_mux_event(event, &tx, &mux_titles) { - ForwardMuxOutcome::Continue => false, - ForwardMuxOutcome::Recover => true, - ForwardMuxOutcome::Stop => return, - }, - Err(_) => { - if session_events.overflowed() { - true - } else { - return; - } + let needs_recovery = match session_events.recv() { + Ok(event) => { + if matches!(forward_mux_event(event, &tx, &mux_titles), ForwardMuxOutcome::Stop) { + return; + } + false + } + Err(_) => { + if session_events.overflowed() { + true + } else { + return; } } }; - if !needs_recovery { + // The mailbox may close immediately after yielding its final accepted + // event. Recover only after that event is forwarded. + if !needs_recovery && !session_events.overflowed() { continue; } next_recovery_generation = next_recovery_generation.wrapping_add(1).max(1); let recovery_generation = next_recovery_generation; mux_recovery_generation.store(recovery_generation, Ordering::Release); - // Subscribe before fetching the authoritative tree so events emitted - // during recovery are retained by the new mailbox. - session_events = event_source.events(); + // Subscribe before draining the closed mailbox so new events are + // retained while every event accepted before overflow is delivered. + let overflowed_events = std::mem::replace(&mut session_events, event_source.events()); + for event in overflowed_events.try_iter() { + if matches!(forward_mux_event(event, &tx, &mux_titles), ForwardMuxOutcome::Stop) { + return; + } + } + if tx + .send(AppEvent::Mux(MuxEvent::Status( + "mux event backlog overflowed; transient events beyond the hard limit were rejected" + .to_string(), + ))) + .is_err() + { + return; + } let routing_generation = routing_mutation_committed.load(Ordering::Acquire); let title_snapshot_epoch = mux_titles.current_epoch(); let recovered = event_source.refresh_tree().map_err(|error| error.to_string()); @@ -158,18 +171,12 @@ fn forward_mux_events( } continue; } - let mut app_channel_full = false; for event in session_events.try_iter() { - match forward_mux_event(event, &tx, &mux_titles) { - ForwardMuxOutcome::Continue => {} - ForwardMuxOutcome::Recover => { - app_channel_full = true; - break; - } - ForwardMuxOutcome::Stop => return, + if matches!(forward_mux_event(event, &tx, &mux_titles), ForwardMuxOutcome::Stop) { + return; } } - if app_channel_full || session_events.overflowed() { + if session_events.overflowed() { continue; } // A prior title wake may precede the authoritative snapshot. Queue a @@ -184,7 +191,6 @@ fn forward_mux_events( enum ForwardMuxOutcome { Continue, - Recover, Stop, } @@ -198,29 +204,19 @@ fn forward_mux_event( if !mux_titles.push(surface, title) { return ForwardMuxOutcome::Continue; } - return match tx.try_send(AppEvent::MuxTitlesReady) { + return match tx.send(AppEvent::MuxTitlesReady) { Ok(()) => ForwardMuxOutcome::Continue, - Err(TrySendError::Full(_)) => ForwardMuxOutcome::Recover, - Err(TrySendError::Disconnected(_)) => ForwardMuxOutcome::Stop, + Err(_) => ForwardMuxOutcome::Stop, }; } MuxEvent::SurfaceExited(surface) => mux_titles.remove(surface), _ => {} } let terminal = matches!(event, MuxEvent::Empty); - let resize_completion = - matches!(event, MuxEvent::SurfaceResized { .. } | MuxEvent::SurfaceResizeFailed { .. }); - if terminal || resize_completion { - return match tx.send(AppEvent::Mux(event)) { - Ok(()) if terminal => ForwardMuxOutcome::Stop, - Ok(()) => ForwardMuxOutcome::Continue, - Err(_) => ForwardMuxOutcome::Stop, - }; - } - match tx.try_send(AppEvent::Mux(event)) { + match tx.send(AppEvent::Mux(event)) { + Ok(()) if terminal => ForwardMuxOutcome::Stop, Ok(()) => ForwardMuxOutcome::Continue, - Err(TrySendError::Full(_)) => ForwardMuxOutcome::Recover, - Err(TrySendError::Disconnected(_)) => ForwardMuxOutcome::Stop, + Err(_) => ForwardMuxOutcome::Stop, } } @@ -7545,6 +7541,9 @@ mod tests { let mux = Mux::new("mux-forwarder-overflow-test", SurfaceOptions::default()); let event_source = Session::Local(mux.clone()); let session_events = event_source.events(); + for surface in 0..5_000 { + mux.emit(MuxEvent::Bell(surface)); + } let (tx, rx) = std::sync::mpsc::sync_channel(1); let titles = Arc::new(MuxTitleIngress::default()); let routing_generation = Arc::new(AtomicU64::new(0)); @@ -7561,10 +7560,6 @@ mod tests { ); }); - for surface in 0..5_000 { - mux.emit(MuxEvent::Bell(surface)); - } - let first = rx.recv_timeout(Duration::from_secs(1)).unwrap(); let deadline = Instant::now() + Duration::from_secs(1); while recovery_generation.load(Ordering::Acquire) == 0 && Instant::now() < deadline { @@ -7577,6 +7572,7 @@ mod tests { let mut next = Some(first); let mut recovered_generations = HashSet::new(); + let mut preserved_bells = 0; let final_generation = loop { let event = next.take().unwrap_or_else(|| rx.recv_timeout(Duration::from_secs(1)).unwrap()); @@ -7591,9 +7587,11 @@ mod tests { { break recovery_generation; } + AppEvent::Mux(MuxEvent::Bell(_)) => preserved_bells += 1, _ => {} } }; + assert_eq!(preserved_bells, 4_096); assert_eq!(recovery_generation.load(Ordering::Acquire), final_generation); mux.emit(MuxEvent::Bell(9_999)); @@ -7649,6 +7647,39 @@ mod tests { assert!(matches!(forwarder.join().unwrap(), ForwardMuxOutcome::Continue)); } + #[test] + fn mux_forwarder_preserves_one_shot_event_while_app_channel_is_full() { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + tx.send(AppEvent::Mux(MuxEvent::Bell(1))).unwrap(); + let titles = MuxTitleIngress::default(); + let forwarder = + std::thread::spawn(move || forward_mux_event(MuxEvent::Bell(2), &tx, &titles)); + + assert!(matches!(rx.recv().unwrap(), AppEvent::Mux(MuxEvent::Bell(1)))); + assert!(matches!(rx.recv().unwrap(), AppEvent::Mux(MuxEvent::Bell(2)))); + assert!(matches!(forwarder.join().unwrap(), ForwardMuxOutcome::Continue)); + } + + #[test] + fn title_wake_waits_for_app_capacity_without_triggering_recovery() { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + tx.send(AppEvent::Mux(MuxEvent::Bell(1))).unwrap(); + let titles = Arc::new(MuxTitleIngress::default()); + let forwarded_titles = titles.clone(); + let forwarder = std::thread::spawn(move || { + forward_mux_event( + MuxEvent::TitleChanged { surface: 41, title: "latest".into() }, + &tx, + &forwarded_titles, + ) + }); + + assert!(matches!(rx.recv().unwrap(), AppEvent::Mux(MuxEvent::Bell(1)))); + assert!(matches!(rx.recv().unwrap(), AppEvent::MuxTitlesReady)); + assert!(matches!(forwarder.join().unwrap(), ForwardMuxOutcome::Continue)); + assert_eq!(titles.take_dirty().get(&41).map(AsRef::as_ref), Some("latest")); + } + #[test] fn mux_recovery_barrier_defers_input_until_authoritative_tree_is_applied() { let mux = Mux::new("mux-recovery-barrier-test", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index babc02237a43..12edd68c5459 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -400,16 +400,42 @@ impl Session { ) -> anyhow::Result<()> { match self { Session::Local(mux) => { - mux.set_cell_pixel_size_reporting(width_px, height_px, report); - Ok(()) + let update = mux.set_cell_pixel_size_reporting(width_px, height_px, report); + if update.failures.is_empty() { + Ok(()) + } else { + anyhow::bail!( + "cell pixel update rejected: {}", + update + .failures + .into_iter() + .map(|failure| format!( + "surface {}: {}", + failure.surface, failure.error + )) + .collect::>() + .join("; ") + ) + } } Session::Remote(remote) => { - for (surface, desired, reservation_id) in - remote.set_cell_pixel_size(width_px, height_px)? - { + let update = remote.set_cell_pixel_size(width_px, height_px)?; + for (surface, desired, reservation_id) in update.resizes { report(surface, desired, reservation_id.or(Some(0))); } - Ok(()) + if update.failures.is_empty() { + Ok(()) + } else { + anyhow::bail!( + "cell pixel update rejected: {}", + update + .failures + .into_iter() + .map(|(surface, error)| format!("surface {surface}: {error}")) + .collect::>() + .join("; ") + ) + } } } } diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 9edba1c9945d..35292a31d703 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -33,6 +33,11 @@ const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_millis(100); pub(crate) type RemoteResizeReservation = (SurfaceId, (u16, u16), Option); +pub(crate) struct RemoteCellPixelUpdate { + pub resizes: Vec, + pub failures: Vec<(SurfaceId, String)>, +} + #[derive(Debug)] pub(crate) enum RemoteRequestError { Encode(serde_json::Error), @@ -831,13 +836,13 @@ impl RemoteSession { &self, width_px: u16, height_px: u16, - ) -> anyhow::Result> { + ) -> anyhow::Result { let response = self.request(json!({ "cmd": "set-cell-pixels", "width_px": width_px, "height_px": height_px, }))?; - Ok(response + let resizes = response .get("resizes") .and_then(Value::as_array) .into_iter() @@ -852,7 +857,20 @@ impl RemoteSession { resize.get("reservation_id").and_then(Value::as_u64), )) }) - .collect()) + .collect(); + let failures = response + .get("failures") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|failure| { + Some(( + failure.get("surface")?.as_u64()?, + failure.get("error")?.as_str()?.to_string(), + )) + }) + .collect(); + Ok(RemoteCellPixelUpdate { resizes, failures }) } pub fn set_default_colors(&self, colors: DefaultColors) -> anyhow::Result<()> { From f19ead0120e6901b827e685a6b2b4f04e1f7f224 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:53:24 -0700 Subject: [PATCH 103/103] Always deliver the paired mouse release --- cmux-tui/crates/cmux-tui/src/app.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index b6ef924f6039..42b48443092a 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -5178,14 +5178,11 @@ impl App { self.pty_input.cancel_release_reservation(reservation_id); } } - PtyMouseReleaseCapture::Failed => { + PtyMouseReleaseCapture::Failed | PtyMouseReleaseCapture::NotReported => { if !self.enqueue_pty_release(surface, handle, reservation_id, fallback) { self.pty_input.cancel_release_reservation(reservation_id); } } - PtyMouseReleaseCapture::NotReported => { - self.pty_input.cancel_release_reservation(reservation_id); - } } true } @@ -5286,14 +5283,11 @@ impl App { self.pty_input.cancel_release_reservation(reservation_id); } } - PtyMouseReleaseCapture::Failed => { + PtyMouseReleaseCapture::Failed | PtyMouseReleaseCapture::NotReported => { if !self.enqueue_pty_release(surface, handle, reservation_id, fallback) { self.pty_input.cancel_release_reservation(reservation_id); } } - PtyMouseReleaseCapture::NotReported => { - self.pty_input.cancel_release_reservation(reservation_id); - } } } @@ -6699,7 +6693,7 @@ mod tests { surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002l\x1b[?1006l")); app.encode_buf.clear(); app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); - assert!(app.encode_buf.is_empty()); + assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); assert!(app.drag.is_none()); surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); @@ -6779,6 +6773,7 @@ mod tests { app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) .unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002l\x1b[?1006l")); app.handle(AppEvent::Input(Event::FocusLost)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<0;5;3m"); assert!(app.drag.is_none());