aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/client_transport.rs14
-rw-r--r--src/server/headless.rs412
-rw-r--r--src/server/protocol.rs20
-rw-r--r--src/server/render_stream.rs28
4 files changed, 439 insertions, 35 deletions
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 348d00c7..971c879f 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -58,6 +58,12 @@ pub(crate) enum ServerEvent {
},
/// A client sent an input message.
ClientInput { client_id: u64, data: Vec<u8> },
+ /// A client requested direct attach to one terminal.
+ ClientAttachTerminal {
+ client_id: u64,
+ terminal_id: String,
+ takeover: bool,
+ },
/// A client sent a resize message.
ClientResize {
client_id: u64,
@@ -354,6 +360,14 @@ fn client_read_loop(
}
}
ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
+ ClientMessage::AttachTerminal {
+ terminal_id,
+ takeover,
+ } => ServerEvent::ClientAttachTerminal {
+ client_id,
+ terminal_id,
+ takeover,
+ },
ClientMessage::Hello { .. } => {
// Duplicate Hello — ignore.
continue;
diff --git a/src/server/headless.rs b/src/server/headless.rs
index fbfef251..d1c8d144 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -28,6 +28,7 @@ use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use base64::Engine;
+use bytes::Bytes;
use crate::api;
use crate::app;
@@ -200,8 +201,24 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
// Connected client state
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum ClientConnectionMode {
+ App,
+ TerminalAttach { terminal_id: String },
+}
+
+type RenderTarget = (
+ u64,
+ (u16, u16),
+ crate::kitty_graphics::HostCellSize,
+ bool,
+ ClientConnectionMode,
+);
+
/// A connected client tracked by the server.
struct ClientConnection {
+ /// Whether this connection is the full app client or a direct terminal attach.
+ mode: ClientConnectionMode,
/// The client's terminal size (after clamping).
terminal_size: (u16, u16),
/// Pixel size of one client terminal cell.
@@ -225,6 +242,7 @@ struct ClientConnection {
}
impl ClientConnection {
+ #[cfg(test)]
fn new(
terminal_size: (u16, u16),
cell_size: crate::kitty_graphics::HostCellSize,
@@ -234,7 +252,30 @@ impl ClientConnection {
render_encoding: RenderEncoding,
writer: Option<ClientWriter>,
) -> Self {
+ Self::new_with_mode(
+ ClientConnectionMode::App,
+ terminal_size,
+ cell_size,
+ host_terminal_theme,
+ outer_terminal_focus,
+ last_activity,
+ render_encoding,
+ writer,
+ )
+ }
+
+ fn new_with_mode(
+ mode: ClientConnectionMode,
+ terminal_size: (u16, u16),
+ cell_size: crate::kitty_graphics::HostCellSize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ outer_terminal_focus: Option<bool>,
+ last_activity: u64,
+ render_encoding: RenderEncoding,
+ writer: Option<ClientWriter>,
+ ) -> Self {
Self {
+ mode,
terminal_size,
cell_size,
host_terminal_theme,
@@ -291,6 +332,8 @@ pub struct HeadlessServer {
next_client_id: u64,
/// The client currently driving the shared pane runtime size and theme.
foreground_client_id: Option<u64>,
+ /// Writable direct attach owner per terminal id string.
+ terminal_attach_owners: HashMap<String, u64>,
/// Monotonic activity counter used to pick the most recently active client.
next_activity_stamp: u64,
/// Shared pane runtime size derived from the foreground client,
@@ -336,6 +379,7 @@ impl HeadlessServer {
clients: HashMap::new(),
next_client_id: 1,
foreground_client_id: None,
+ terminal_attach_owners: HashMap::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
@@ -596,6 +640,7 @@ impl HeadlessServer {
let next_foreground = self
.clients
.iter()
+ .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App))
.max_by_key(|(_, client)| client.last_activity)
.map(|(&client_id, _)| client_id);
let changed = next_foreground != self.foreground_client_id;
@@ -607,7 +652,20 @@ impl HeadlessServer {
fn remove_client(&mut self, client_id: u64) -> bool {
let was_foreground = self.foreground_client_id == Some(client_id);
self.send_client_graphics_cleanup(client_id);
- self.clients.remove(&client_id);
+ let removed = self.clients.remove(&client_id);
+ if let Some(ClientConnection {
+ mode: ClientConnectionMode::TerminalAttach { terminal_id },
+ ..
+ }) = removed
+ {
+ self.terminal_attach_owners.remove(&terminal_id);
+ if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) {
+ self.app
+ .state
+ .direct_attach_resize_locks
+ .remove(&terminal_id);
+ }
+ }
if was_foreground {
self.promote_latest_remaining_client()
} else {
@@ -759,6 +817,23 @@ impl HeadlessServer {
changed
}
+ fn terminal_id_by_string(&self, terminal_id: &str) -> Option<crate::terminal::TerminalId> {
+ self.app
+ .state
+ .terminals
+ .keys()
+ .find(|id| id.to_string() == terminal_id)
+ .cloned()
+ }
+
+ fn runtime_for_terminal_id_string(
+ &self,
+ terminal_id: &str,
+ ) -> Option<&crate::terminal::TerminalRuntime> {
+ let terminal_id = self.terminal_id_by_string(terminal_id)?;
+ self.app.state.terminal_runtimes.get(&terminal_id)
+ }
+
fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState {
self.app
.state
@@ -1000,6 +1075,26 @@ impl HeadlessServer {
true
}
+ AppEvent::PaneDied { pane_id } => {
+ let terminal_id = self.app.state.workspaces.iter().find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ tab.panes
+ .get(pane_id)
+ .map(|pane| pane.attached_terminal_id.to_string())
+ })
+ });
+
+ self.app.handle_internal_event(ev);
+
+ if let Some(terminal_id) = terminal_id {
+ self.shutdown_terminal_attach_clients(
+ &terminal_id,
+ format!("terminal {terminal_id} exited"),
+ );
+ }
+
+ true
+ }
_ => {
self.app.handle_internal_event(ev);
true
@@ -1130,6 +1225,104 @@ impl HeadlessServer {
}
}
+ fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) {
+ let client_ids: Vec<u64> = self
+ .clients
+ .iter()
+ .filter_map(|(&client_id, client)| match &client.mode {
+ ClientConnectionMode::TerminalAttach {
+ terminal_id: attached,
+ } if attached == terminal_id => Some(client_id),
+ _ => None,
+ })
+ .collect();
+
+ for client_id in client_ids {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some(reason.clone()),
+ },
+ );
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ }
+ }
+
+ fn attach_terminal_client(
+ &mut self,
+ client_id: u64,
+ terminal_id: String,
+ takeover: bool,
+ ) -> bool {
+ let Some(real_terminal_id) = self.terminal_id_by_string(&terminal_id) else {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some(format!(
+ "terminal attach failed: terminal {terminal_id} not found"
+ )),
+ },
+ );
+ self.remove_client(client_id);
+ return false;
+ };
+
+ if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() {
+ if existing_owner != client_id && !takeover {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some(format!(
+ "terminal attach failed: terminal {terminal_id} already has an attached client; retry with --takeover"
+ )),
+ },
+ );
+ self.remove_client(client_id);
+ return false;
+ }
+ if existing_owner != client_id {
+ self.send_to_client(
+ existing_owner,
+ ServerMessage::ServerShutdown {
+ reason: Some("terminal attach taken over".to_owned()),
+ },
+ );
+ self.remove_client(existing_owner);
+ }
+ }
+
+ let stamp = self.allocate_activity_stamp();
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ let (cols, rows) = client.terminal_size;
+ let cell_size = client.cell_size;
+ client.mode = ClientConnectionMode::TerminalAttach {
+ terminal_id: terminal_id.clone(),
+ };
+ client.render_state.reset_baseline();
+ client.last_activity = stamp;
+ let was_foreground = self.foreground_client_id == Some(client_id);
+ if was_foreground {
+ self.promote_latest_remaining_client();
+ }
+
+ info!(client_id, cols, rows, terminal_id = %terminal_id, "terminal attach client connected");
+ self.terminal_attach_owners
+ .insert(terminal_id.clone(), client_id);
+ self.app
+ .state
+ .direct_attach_resize_locks
+ .insert(real_terminal_id.clone());
+ if let Some(runtime) = self.app.state.terminal_runtimes.get(&real_terminal_id) {
+ runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px);
+ }
+ true
+ }
+
/// Handles a server event. Returns true if the event requires a re-render.
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
match ev {
@@ -1154,7 +1347,8 @@ impl HeadlessServer {
let last_activity = self.allocate_activity_stamp();
self.clients.insert(
client_id,
- ClientConnection::new(
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::App,
(cols, rows),
crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
@@ -1172,8 +1366,25 @@ impl HeadlessServer {
self.resize_shared_runtime_to_effective_size();
true
}
+ ServerEvent::ClientAttachTerminal {
+ client_id,
+ terminal_id,
+ takeover,
+ } => self.attach_terminal_client(client_id, terminal_id, takeover),
ServerEvent::ClientInput { client_id, data } => {
debug!(client_id, len = data.len(), "client input received");
+ if let Some(ClientConnection {
+ mode: ClientConnectionMode::TerminalAttach { terminal_id },
+ ..
+ }) = self.clients.get(&client_id)
+ {
+ if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) {
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(data)) {
+ warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach input failed");
+ }
+ }
+ return true;
+ }
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
let host_surface_redraw =
crate::raw_input::events_require_host_surface_redraw(&events);
@@ -1251,6 +1462,30 @@ impl HeadlessServer {
client_id,
cols, rows, cell_width_px, cell_height_px, "client resize"
);
+ let direct_terminal_id = if let Some(ClientConnection {
+ mode: ClientConnectionMode::TerminalAttach { terminal_id },
+ terminal_size,
+ cell_size,
+ render_state,
+ ..
+ }) = self.clients.get_mut(&client_id)
+ {
+ *terminal_size = (cols, rows);
+ *cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ };
+ render_state.reset_baseline();
+ Some(terminal_id.clone())
+ } else {
+ None
+ };
+ if let Some(terminal_id) = direct_terminal_id {
+ if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) {
+ runtime.resize(rows, cols, cell_width_px, cell_height_px);
+ }
+ return true;
+ }
if let Some(client) = self.clients.get_mut(&client_id) {
client.terminal_size = (cols, rows);
client.cell_size = crate::kitty_graphics::HostCellSize {
@@ -1508,6 +1743,9 @@ impl HeadlessServer {
let mut broken_clients: Vec<u64> = Vec::new();
for (&client_id, client) in &mut self.clients {
+ if !matches!(client.mode, ClientConnectionMode::App) {
+ continue;
+ }
if client.host_mouse_capture_active == Some(enabled) {
continue;
}
@@ -1537,21 +1775,23 @@ impl HeadlessServer {
/// frames to all connected clients.
fn render_and_stream(&mut self) {
let foreground_client_id = self.foreground_client_id;
- let mut render_targets: Vec<(u64, (u16, u16), crate::kitty_graphics::HostCellSize, bool)> =
- self.clients
- .iter()
- .filter(|(_, client)| client.writer.is_some())
- .map(|(&client_id, client)| {
- (
- client_id,
- client.terminal_size,
- client.cell_size,
- foreground_client_id == Some(client_id),
- )
- })
- .collect();
+ let mut render_targets: Vec<RenderTarget> = self
+ .clients
+ .iter()
+ .filter(|(_, client)| client.writer.is_some())
+ .map(|(&client_id, client)| {
+ (
+ client_id,
+ client.terminal_size,
+ client.cell_size,
+ foreground_client_id == Some(client_id),
+ client.mode.clone(),
+ )
+ })
+ .collect();
- render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id));
+ render_targets
+ .sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id));
if render_targets.is_empty() {
let (cols, rows) = self.effective_size;
@@ -1570,32 +1810,55 @@ impl HeadlessServer {
}
let mut broken_clients: Vec<u64> = Vec::new();
- for (client_id, (cols, rows), cell_size, is_foreground) in render_targets {
+ for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets {
let area = Rect::new(0, 0, cols, rows);
- let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known()
- {
- crate::server::render_stream::render_virtual_with_cell_size(
- &mut self.app.state,
- area,
- is_foreground,
- cell_size,
- )
- } else {
- crate::server::render_stream::render_virtual(
- &mut self.app.state,
- area,
- is_foreground,
- )
+ let is_app_client = matches!(mode, ClientConnectionMode::App);
+ let mut frame = match mode {
+ ClientConnectionMode::App => {
+ let (buffer, cursor) =
+ if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
+ crate::server::render_stream::render_virtual_with_cell_size(
+ &mut self.app.state,
+ area,
+ is_foreground,
+ cell_size,
+ )
+ } else {
+ crate::server::render_stream::render_virtual(
+ &mut self.app.state,
+ area,
+ is_foreground,
+ )
+ };
+ let hyperlinks =
+ crate::server::render_stream::visible_hyperlinks(&self.app.state);
+ FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks)
+ }
+ ClientConnectionMode::TerminalAttach { terminal_id } => {
+ let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some(format!(
+ "terminal attach ended: terminal {terminal_id} not found"
+ )),
+ },
+ );
+ broken_clients.push(client_id);
+ continue;
+ };
+ let (buffer, cursor) =
+ crate::server::render_stream::render_terminal_virtual(runtime, area);
+ let hyperlinks = runtime.visible_hyperlinks(area);
+ FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks)
+ }
};
- let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state);
- let mut frame =
- FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks);
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
};
let mut next_graphics_cache = client.graphics_cache.clone();
- if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
+ if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() {
frame.graphics = crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
cell_size,
@@ -1999,6 +2262,7 @@ mod tests {
clients: HashMap::new(),
next_client_id: 1,
foreground_client_id: None,
+ terminal_attach_owners: HashMap::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
@@ -2020,6 +2284,13 @@ mod tests {
}
}
+ fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> {
+ match read_server_message(bytes) {
+ ServerMessage::ServerShutdown { reason } => reason,
+ other => panic!("expected shutdown, got {other:?}"),
+ }
+ }
+
fn test_client_writer() -> (
ClientWriter,
std::sync::mpsc::Receiver<Vec<u8>>,
@@ -2038,6 +2309,77 @@ mod tests {
}
#[test]
+ fn terminal_attach_rejects_missing_terminal_and_removes_client() {
+ let mut server = test_headless_server();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+
+ assert!(server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 7,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ render_encoding: RenderEncoding::TerminalAnsi,
+ writer,
+ }));
+ assert!(server.clients.contains_key(&7));
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 7,
+ terminal_id: "term_missing".to_owned(),
+ takeover: false,
+ })
+ );
+ assert!(!server.clients.contains_key(&7));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(
+ reason,
+ Some("terminal attach failed: terminal term_missing not found".to_owned())
+ );
+ }
+
+ #[test]
+ fn terminal_attach_client_exits_when_attached_pane_dies() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("attached");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ let terminal_id = server.app.state.workspaces[0]
+ .pane_state(pane_id)
+ .expect("pane")
+ .attached_terminal_id
+ .to_string();
+ let (writer, control_rx, _render_rx) = test_client_writer();
+
+ assert!(server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 7,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ render_encoding: RenderEncoding::TerminalAnsi,
+ writer,
+ }));
+ assert!(
+ server.handle_server_event(ServerEvent::ClientAttachTerminal {
+ client_id: 7,
+ terminal_id: terminal_id.clone(),
+ takeover: false,
+ })
+ );
+ assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7));
+
+ assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id }));
+
+ assert!(!server.clients.contains_key(&7));
+ assert!(!server.terminal_attach_owners.contains_key(&terminal_id));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(reason, Some(format!("terminal {terminal_id} exited")));
+ }
+
+ #[test]
fn client_socket_path_derived_from_api_socket_override() {
let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None);
assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 7d0d3e2d..6745b0db 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -79,6 +79,14 @@ pub enum ClientMessage {
/// Graceful disconnect request.
Detach,
+
+ /// Switch this connection into direct terminal attach mode.
+ AttachTerminal {
+ /// Terminal id to attach to.
+ terminal_id: String,
+ /// Replace an existing writable attach owner for this terminal.
+ takeover: bool,
+ },
}
// ---------------------------------------------------------------------------
@@ -647,6 +655,18 @@ mod tests {
assert_eq!(msg, decoded);
}
+ #[test]
+ fn client_attach_terminal_roundtrip() {
+ let msg = ClientMessage::AttachTerminal {
+ terminal_id: "term_123".to_owned(),
+ takeover: true,
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
// ---- Round-trip: ServerMessage ----
#[test]
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 94a43980..d82eb855 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -258,6 +258,34 @@ pub(crate) fn render_virtual_with_cell_size(
(buffer, cursor)
}
+/// Renders one server-owned terminal directly for `terminal attach` clients.
+pub(crate) fn render_terminal_virtual(
+ runtime: &crate::terminal::TerminalRuntime,
+ area: Rect,
+) -> (ratatui::buffer::Buffer, Option<CursorState>) {
+ let backend = CursorTrackingBackend::new(area.width, area.height);
+ let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail");
+
+ terminal
+ .draw(|frame| {
+ runtime.render(frame, area, true);
+ })
+ .expect("render to TestBackend should never fail");
+
+ let buffer = terminal.backend().buffer().clone();
+ let cursor = runtime
+ .cursor_state(area, true)
+ .map(|cursor| CursorState {
+ x: cursor.x,
+ y: cursor.y,
+ visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime),
+ shape: cursor.shape,
+ })
+ .or_else(|| terminal.backend().rendered_cursor());
+
+ (buffer, cursor)
+}
+
pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), String, String)> {
let Some(ws_idx) = app_state.active else {
return Vec::new();