diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-02 02:45:20 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-02 02:45:20 +0300 |
| commit | cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch) | |
| tree | 4d37c760099070f1a1488b61c7b7271875aafef8 /src/server/client_transport.rs | |
| parent | 18e69891dca486d669a584facd80644bb51f54a2 (diff) | |
feat: add stable client endpoint compatibility (#3509)
* feat: add stable client endpoint compatibility
* fix: keep portable-pty patch checks composable
* ci: run maintenance checks in pull requests
* fix: reject endpoint errors from terminal clients
* feat: keep compatible servers running across updates
Diffstat (limited to 'src/server/client_transport.rs')
| -rw-r--r-- | src/server/client_transport.rs | 477 |
1 files changed, 373 insertions, 104 deletions
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 673e16d3..25a963e7 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -17,6 +17,10 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::ipc::LocalStream; +use crate::protocol::endpoint::{ + EndpointClientHello, EndpointServerWelcome, ENDPOINT_HELLO_KIND, ENDPOINT_PROTOCOL_GENERATION, + ENDPOINT_WELCOME_KIND, +}; use crate::protocol::{ self, AttachScrollDirection, AttachScrollSource, ClientMessage, ClientPaneInputEvent, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, @@ -39,6 +43,74 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4); /// Maximum input payload size (bytes) for a single `ClientMessage::Input`. const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB +const MAX_CLIENT_SHELL_DIMENSION: u16 = 4096; +const MAX_CLIENT_SHELL_CELLS: u32 = 1_000_000; +const MAX_CLIENT_CELL_SIZE_PX: u32 = 4096; + +fn client_shell_geometry_error( + surface_size: crate::protocol::ClientSurfaceSize, + cell_width_px: u32, + cell_height_px: u32, +) -> Option<&'static str> { + if surface_size.cols == 0 || surface_size.rows == 0 { + return Some("client shell requires a non-empty pane surface"); + } + if surface_size.cols > MAX_CLIENT_SHELL_DIMENSION + || surface_size.rows > MAX_CLIENT_SHELL_DIMENSION + || u32::from(surface_size.cols) * u32::from(surface_size.rows) > MAX_CLIENT_SHELL_CELLS + { + return Some("client shell pane surface exceeds the safe geometry limit"); + } + if cell_width_px > MAX_CLIENT_CELL_SIZE_PX || cell_height_px > MAX_CLIENT_CELL_SIZE_PX { + return Some("client shell cell pixel size exceeds the safe geometry limit"); + } + None +} + +#[derive(serde::Deserialize)] +struct EndpointRequestHead { + id: String, + method: String, +} + +enum DecodedEndpointRequest { + Dispatch(Box<crate::api::schema::Request>), + Error { + request_id: String, + code: &'static str, + message: String, + }, +} + +fn write_endpoint_rejection(stream: &mut LocalStream, code: &str, message: impl Into<String>) { + let welcome = EndpointServerWelcome::incompatible(code, message); + let response = ServerMessage::EndpointControl { + kind: ENDPOINT_WELCOME_KIND.into(), + data: serde_json::to_string(&welcome).unwrap_or_else(|_| "{}".into()), + }; + let _ = protocol::write_message(stream, &response); +} + +fn decode_endpoint_request(request: &str) -> serde_json::Result<DecodedEndpointRequest> { + let head = serde_json::from_str::<EndpointRequestHead>(request)?; + if !crate::server::client_commands::supports_client_shell_method_name(&head.method) { + return Ok(DecodedEndpointRequest::Error { + request_id: head.id, + code: "unsupported_method", + message: format!("method {:?} is not available on this machine", head.method), + }); + } + Ok( + match serde_json::from_str::<crate::api::schema::Request>(request) { + Ok(request) => DecodedEndpointRequest::Dispatch(Box::new(request)), + Err(error) => DecodedEndpointRequest::Error { + request_id: head.id, + code: "invalid_request", + message: format!("invalid endpoint request: {error}"), + }, + }, + ) +} /// Maximum structured input events accepted in one client message. const MAX_INPUT_EVENT_BATCH: usize = 4096; @@ -417,6 +489,14 @@ pub(crate) enum ServerEvent { boot_id: String, request: Box<crate::api::schema::Request>, }, + /// A well-framed endpoint request could not be dispatched by this server. + ClientShellEndpointRequestError { + client_id: u64, + boot_id: String, + request_id: String, + code: &'static str, + message: String, + }, /// One chunk of a deferred endpoint operation's final response is ready. ClientShellEndpointResponseChunkReady { client_id: u64, @@ -616,50 +696,69 @@ pub(crate) fn handle_client_handshake( let (cols, rows) = clamp_terminal_size(cols, rows); (cols, rows, cell_width_px, cell_height_px, pixel_mouse, None) } - ClientMessage::ClientShellHello { - version, - cell_width_px, - cell_height_px, - surface_size, - pixel_mouse, - direct_graphics, - endpoint_keybindings, - mouse_capture, - } => { - if let protocol::VersionCheck::Incompatible(reason) = - protocol::check_client_version(version) - { - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(reason), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - if surface_size.cols == 0 || surface_size.rows == 0 { - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some("client shell requires a non-empty pane surface".to_owned()), - }; - let _ = protocol::write_message(&mut stream, &welcome); + ClientMessage::EndpointControl { kind, data } if kind == ENDPOINT_HELLO_KIND => { + let hello: EndpointClientHello = match serde_json::from_str(&data) { + Ok(hello) => hello, + Err(error) => { + write_endpoint_rejection( + &mut stream, + "invalid_hello", + format!("invalid endpoint hello: {error}"), + ); + return Ok(()); + } + }; + let incompatibility = if hello.generation != ENDPOINT_PROTOCOL_GENERATION { + Some(( + "unsupported_generation", + format!( + "endpoint generation {} is unsupported; this server supports generation {ENDPOINT_PROTOCOL_GENERATION}", + hello.generation + ), + )) + } else if !hello.supports_required_codecs() { + Some(( + "no_common_core", + "client and server have no compatible endpoint core codecs".to_owned(), + )) + } else { + client_shell_geometry_error( + hello.surface_size, + hello.cell_width_px, + hello.cell_height_px, + ) + .map(|reason| ("invalid_surface", reason.to_owned())) + }; + if let Some((code, reason)) = incompatibility { + write_endpoint_rejection(&mut stream, code, reason); return Ok(()); } ( - surface_size.cols, - surface_size.rows, - cell_width_px, - cell_height_px, + hello.surface_size.cols, + hello.surface_size.rows, + hello.cell_width_px, + hello.cell_height_px, false, Some(( - pixel_mouse, - direct_graphics, - endpoint_keybindings, - mouse_capture, + hello.pixel_mouse, + hello.direct_graphics, + hello.endpoint_keybindings, + hello.mouse_capture, )), ) } + ClientMessage::ClientShellHello { .. } => { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some( + "this client predates the stable endpoint protocol; upgrade the Herdr client" + .to_owned(), + ), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } _ => { debug!(client_id, "first message was not a handshake, closing"); let welcome = ServerMessage::Welcome { @@ -678,16 +777,30 @@ pub(crate) fn handle_client_handshake( return Ok(()); } - // Send Welcome. + // Send the negotiated welcome. Endpoint compatibility is independent from + // the same-install protocol used by direct terminal clients. let render_encoding = if shell_options.is_some() { RenderEncoding::SemanticFrame } else { RenderEncoding::TerminalAnsi }; - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: render_encoding, - error: None, + let welcome = if shell_options.is_some() { + let welcome = EndpointServerWelcome::compatible( + crate::server::client_commands::supported_client_shell_method_names() + .iter() + .map(|method| (*method).to_owned()) + .collect(), + ); + ServerMessage::EndpointControl { + kind: ENDPOINT_WELCOME_KIND.into(), + data: serde_json::to_string(&welcome).map_err(io::Error::other)?, + } + } else { + ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: render_encoding, + error: None, + } }; protocol::write_message(&mut stream, &welcome).map_err(|e| io::Error::other(e.to_string()))?; @@ -947,14 +1060,24 @@ fn client_read_loop( cell_height_px, surface_size, pixel_mouse, - } => ServerEvent::ClientShellResize { - client_id, - surface_cols: surface_size.cols.max(1), - surface_rows: surface_size.rows.max(1), - cell_width_px, - cell_height_px, - pixel_mouse, - }, + } => { + if let Some(reason) = + client_shell_geometry_error(surface_size, cell_width_px, cell_height_px) + { + warn!(client_id, %reason, "invalid client shell resize, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + ServerEvent::ClientShellResize { + client_id, + surface_cols: surface_size.cols, + surface_rows: surface_size.rows, + cell_width_px, + cell_height_px, + pixel_mouse, + } + } ClientMessage::ClientShellHostTheme { update } => { if matches!( &update, @@ -1075,14 +1198,20 @@ fn client_read_loop( .blocking_send(ServerEvent::ClientDisconnected { client_id }); break; } - let Ok(request) = serde_json::from_str::<crate::api::schema::Request>(&request) - else { - warn!(client_id, "invalid client shell endpoint command, closing"); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; + let decoded = match decode_endpoint_request(&request) { + Ok(decoded) => decoded, + Err(error) => { + warn!(client_id, %error, "invalid endpoint request envelope, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } }; - if request.id.len() > crate::server::client_commands::MAX_ENDPOINT_REQUEST_ID_BYTES + let request_id = match &decoded { + DecodedEndpointRequest::Dispatch(request) => request.id.as_str(), + DecodedEndpointRequest::Error { request_id, .. } => request_id, + }; + if request_id.len() > crate::server::client_commands::MAX_ENDPOINT_REQUEST_ID_BYTES { warn!( client_id, @@ -1092,12 +1221,31 @@ fn client_read_loop( .blocking_send(ServerEvent::ClientDisconnected { client_id }); break; } - ServerEvent::ClientShellEndpointRequest { - client_id, - boot_id, - request: Box::new(request), + match decoded { + DecodedEndpointRequest::Dispatch(request) => { + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request, + } + } + DecodedEndpointRequest::Error { + request_id, + code, + message, + } => ServerEvent::ClientShellEndpointRequestError { + client_id, + boot_id, + request_id, + code, + message, + }, } } + ClientMessage::EndpointControl { kind, .. } => { + debug!(client_id, %kind, "ignoring unknown endpoint control message"); + continue; + } ClientMessage::Detach => { let _ = server_event_tx.blocking_send(ServerEvent::ClientDetach { client_id }); break; @@ -1195,6 +1343,38 @@ mod tests { (client, server, TestSocketPath(path)) } + fn endpoint_hello(surface_cols: u16, surface_rows: u16) -> ClientMessage { + let hello = EndpointClientHello { + generation: ENDPOINT_PROTOCOL_GENERATION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { + cols: surface_cols, + rows: surface_rows, + }, + pixel_mouse: true, + direct_graphics: true, + endpoint_keybindings: true, + mouse_capture: true, + snapshot_codecs: vec![crate::protocol::endpoint::SNAPSHOT_CODEC_V1.into()], + surface_codecs: vec![crate::protocol::endpoint::SURFACE_CODEC_V1.into()], + input_codecs: vec![crate::protocol::endpoint::INPUT_CODEC_V1.into()], + blob_codecs: vec![crate::protocol::endpoint::BLOB_CODEC_V1.into()], + }; + ClientMessage::EndpointControl { + kind: ENDPOINT_HELLO_KIND.into(), + data: serde_json::to_string(&hello).unwrap(), + } + } + + fn endpoint_welcome(message: ServerMessage) -> EndpointServerWelcome { + let ServerMessage::EndpointControl { kind, data } = message else { + panic!("expected endpoint welcome"); + }; + assert_eq!(kind, ENDPOINT_WELCOME_KIND); + serde_json::from_str(&data).unwrap() + } + fn recv_server_event(receiver: &mut mpsc::Receiver<ServerEvent>, context: &str) -> ServerEvent { let deadline = std::time::Instant::now() + Duration::from_secs(1); loop { @@ -1432,6 +1612,62 @@ mod tests { } #[test] + fn client_shell_geometry_rejects_unsafe_dimensions_and_cell_sizes() { + assert!(client_shell_geometry_error( + crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + 8, + 16, + ) + .is_none()); + assert!(client_shell_geometry_error( + crate::protocol::ClientSurfaceSize { + cols: MAX_CLIENT_SHELL_DIMENSION, + rows: MAX_CLIENT_SHELL_DIMENSION, + }, + 8, + 16, + ) + .is_some()); + assert!(client_shell_geometry_error( + crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + MAX_CLIENT_CELL_SIZE_PX + 1, + 16, + ) + .is_some()); + } + + #[test] + fn unknown_endpoint_method_returns_correlated_error() { + let decoded = decode_endpoint_request( + r#"{"id":"req-1","method":"plugin.future","params":{"value":1}}"#, + ) + .unwrap(); + assert!(matches!( + decoded, + DecodedEndpointRequest::Error { + request_id, + code: "unsupported_method", + .. + } if request_id == "req-1" + )); + } + + #[test] + fn malformed_known_endpoint_method_returns_correlated_error() { + let decoded = + decode_endpoint_request(r#"{"id":"req-2","method":"workspace.focus","params":{}}"#) + .unwrap(); + assert!(matches!( + decoded, + DecodedEndpointRequest::Error { + request_id, + code: "invalid_request", + .. + } if request_id == "req-2" + )); + } + + #[test] fn handshake_negotiates_terminal_ansi_encoding() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); @@ -1509,31 +1745,14 @@ mod tests { handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) }); - protocol::write_message( - &mut client_stream, - &ClientMessage::ClientShellHello { - version: PROTOCOL_VERSION, - cell_width_px: 8, - cell_height_px: 16, - surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 29 }, - pixel_mouse: true, - direct_graphics: true, - endpoint_keybindings: true, - mouse_capture: true, - }, - ) - .expect("write shell hello"); + protocol::write_message(&mut client_stream, &endpoint_hello(80, 29)) + .expect("write shell hello"); let welcome: ServerMessage = protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); - assert!(matches!( - welcome, - ServerMessage::Welcome { - encoding: RenderEncoding::SemanticFrame, - error: None, - .. - } - )); + let welcome = endpoint_welcome(welcome); + assert_eq!(welcome.generation, ENDPOINT_PROTOCOL_GENERATION); + assert!(welcome.error.is_none()); match server_event_rx .blocking_recv() .expect("client shell connected event") @@ -1581,31 +1800,15 @@ mod tests { handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) }); - protocol::write_message( - &mut client_stream, - &ClientMessage::ClientShellHello { - version: PROTOCOL_VERSION, - cell_width_px: 8, - cell_height_px: 16, - surface_size: crate::protocol::ClientSurfaceSize { cols: 0, rows: 29 }, - pixel_mouse: false, - direct_graphics: false, - endpoint_keybindings: false, - mouse_capture: false, - }, - ) - .expect("write empty shell hello"); + protocol::write_message(&mut client_stream, &endpoint_hello(0, 29)) + .expect("write empty shell hello"); let welcome: ServerMessage = protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); - assert!(matches!( - welcome, - ServerMessage::Welcome { - encoding: RenderEncoding::SemanticFrame, - error: Some(error), - .. - } if error.contains("non-empty pane surface") - )); + let welcome = endpoint_welcome(welcome); + assert!(welcome + .error + .is_some_and(|error| error.message.contains("non-empty pane surface"))); handle .join() .expect("handshake thread join") @@ -1650,6 +1853,72 @@ mod tests { } #[test] + fn client_read_loop_ignores_unknown_endpoint_control() { + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-read-future-control"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::EndpointControl { + kind: "future.optional.v1".into(), + data: "{}".into(), + }, + ) + .unwrap(); + protocol::write_message(&mut client_stream, &ClientMessage::Detach).unwrap(); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach after future control"), + ServerEvent::ClientDetach { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] + fn client_read_loop_closes_on_unsafe_shell_resize() { + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-read-unsafe-resize"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { + cols: MAX_CLIENT_SHELL_DIMENSION, + rows: MAX_CLIENT_SHELL_DIMENSION, + }, + pixel_mouse: false, + }, + ) + .unwrap(); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "unsafe resize disconnect"), + ServerEvent::ClientDisconnected { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] fn client_read_loop_rejects_oversized_bracketed_paste_without_disconnect() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); |
