aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 02:45:20 +0300
committerGitHub <noreply@github.com>2026-09-02 02:45:20 +0300
commitcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (patch)
tree4d37c760099070f1a1488b61c7b7271875aafef8 /src
parent18e69891dca486d669a584facd80644bb51f54a2 (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')
-rw-r--r--src/api/mod.rs2
-rw-r--r--src/api/schema/server.rs3
-rw-r--r--src/api/schema/tests.rs7
-rw-r--r--src/api/server.rs6
-rw-r--r--src/app/actions.rs4
-rw-r--r--src/cli/status.rs135
-rw-r--r--src/client/endpoint_commands.rs59
-rw-r--r--src/client/handshake.rs86
-rw-r--r--src/client/mod.rs130
-rw-r--r--src/client/shell/actions.rs162
-rw-r--r--src/client/shell/composition.rs25
-rw-r--r--src/client/shell/config.rs37
-rw-r--r--src/client/shell/copy_mode.rs4
-rw-r--r--src/client/shell/graphics.rs1
-rw-r--r--src/client/shell/mouse.rs23
-rw-r--r--src/client/shell/notifications.rs216
-rw-r--r--src/client/shell/settings.rs23
-rw-r--r--src/client/shell/state.rs49
-rw-r--r--src/client/shell/surface_patch.rs2
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs30
-rw-r--r--src/client/shell/tests/keybindings_settings.rs147
-rw-r--r--src/client/shell/tests/startup_overlays.rs36
-rw-r--r--src/client/shell/worktrees.rs38
-rw-r--r--src/client/terminal_sessions.rs7
-rw-r--r--src/protocol/endpoint.rs275
-rw-r--r--src/protocol/mod.rs1
-rw-r--r--src/protocol/wire.rs337
-rw-r--r--src/remote/attach.rs316
-rw-r--r--src/remote/host_unix.rs9
-rw-r--r--src/server/autodetect.rs15
-rw-r--r--src/server/client_commands.rs240
-rw-r--r--src/server/client_transport.rs477
-rw-r--r--src/server/headless.rs35
-rw-r--r--src/server/headless/render.rs11
-rw-r--r--src/server/headless/tests/mod.rs109
-rw-r--r--src/server/render_scale_benchmark.rs44
-rw-r--r--src/ui/release_notes.rs4
-rw-r--r--src/update.rs210
38 files changed, 2636 insertions, 679 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 363d7702..d28a82ae 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -7,8 +7,8 @@ mod subscriptions;
mod wait;
pub use event_hub::EventHub;
-pub(crate) use server::start_server_with_stop_control;
pub use server::ServerHandle;
+pub(crate) use server::{api_method_name, start_server_with_stop_control};
pub use status::{read_runtime_status_at, RuntimeStatus};
use std::path::PathBuf;
diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs
index 7ceda633..96e51564 100644
--- a/src/api/schema/server.rs
+++ b/src/api/schema/server.rs
@@ -18,4 +18,7 @@ pub struct ServerCapabilities {
pub live_handoff: bool,
#[serde(default)]
pub detached_server_daemon: bool,
+ /// Stable client-owned endpoint generation supported by this server.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub endpoint_protocol_generation: Option<u32>,
}
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index e4a7f2a3..06529f5e 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -708,6 +708,12 @@ fn scroll_changed_subscription_event_round_trips() {
}
#[test]
+fn agent_status_request_values_remain_strict() {
+ assert!(serde_json::from_str::<AgentStatus>(r#""working""#).is_ok());
+ assert!(serde_json::from_str::<AgentStatus>(r#""future_status""#).is_err());
+}
+
+#[test]
fn success_response_round_trips() {
let response = SuccessResponse {
id: "req_1".into(),
@@ -717,6 +723,7 @@ fn success_response_round_trips() {
capabilities: Some(ServerCapabilities {
live_handoff: true,
detached_server_daemon: true,
+ endpoint_protocol_generation: Some(1),
}),
},
};
diff --git a/src/api/server.rs b/src/api/server.rs
index ff0b6fdf..64beec33 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -68,6 +68,7 @@ fn default_capabilities() -> Option<ServerCapabilities> {
Some(ServerCapabilities {
live_handoff: crate::platform::capabilities().live_handoff,
detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(),
+ endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
})
}
@@ -371,7 +372,7 @@ fn handle_request(
dispatch_to_app(request, api_tx, None, response_write_complete, None)
}
-fn api_method_name(method: &Method) -> &'static str {
+pub(crate) fn api_method_name(method: &Method) -> &'static str {
match method {
Method::Ping(_) => "ping",
Method::ServerStop(_) => "server.stop",
@@ -1090,6 +1091,9 @@ mod tests {
Some(ServerCapabilities {
live_handoff: true,
detached_server_daemon: true,
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
}),
None,
None,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 85839322..853c9084 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -3750,7 +3750,7 @@ mod tests {
assert_eq!(toast.title, "v0.5.0 available");
assert_eq!(
toast.context,
- "detach, run `herdr update`, then follow its restart guidance"
+ "detach, run `herdr update`, then run Herdr again to reconnect"
);
}
@@ -3771,7 +3771,7 @@ mod tests {
let toast = state.toast.as_ref().expect("update toast");
assert_eq!(
toast.context,
- "detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready"
+ "detach, run `brew update && brew upgrade herdr`, then run Herdr again to reconnect"
);
}
diff --git a/src/cli/status.rs b/src/cli/status.rs
index 74bb3e2a..4dba81b7 100644
--- a/src/cli/status.rs
+++ b/src/cli/status.rs
@@ -98,12 +98,20 @@ fn print_full_status(json: bool) -> std::io::Result<i32> {
crate::config::Config::load().config.update.channel.as_str()
);
println!(" protocol: {}", crate::protocol::PROTOCOL_VERSION);
+ println!(
+ " endpoint_protocol_generation: {}",
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
+ );
println!();
println!("server:");
print_server_status_body(&server, " ");
println!();
println!("update:");
println!(" restart_needed: {}", restart_needed_label(&server));
+ println!(
+ " server_binary_stale: {}",
+ server_binary_stale_label(&server)
+ );
Ok(0)
}
@@ -130,6 +138,10 @@ fn print_client_status(json: bool) -> std::io::Result<()> {
crate::config::Config::load().config.update.channel.as_str()
);
println!("protocol: {}", crate::protocol::PROTOCOL_VERSION);
+ println!(
+ "endpoint_protocol_generation: {}",
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
+ );
println!("binary: {}", current_exe_label());
Ok(())
}
@@ -137,12 +149,21 @@ fn print_client_status(json: bool) -> std::io::Result<()> {
fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) {
match server {
ServerRuntimeStatus::Running {
- version, protocol, ..
+ version,
+ protocol,
+ capabilities,
} => {
println!("{indent}status: running");
println!("{indent}version: {}", option_label(version.as_deref()));
- println!("{indent}protocol: {}", protocol_label(*protocol));
- println!("{indent}compatible: {}", compatibility_label(*protocol));
+ println!(
+ "{indent}endpoint_compatible: {}",
+ endpoint_compatibility_label(capabilities.as_ref())
+ );
+ println!("{indent}private_protocol: {}", protocol_label(*protocol));
+ println!(
+ "{indent}private_protocol_compatible: {}",
+ compatibility_label(*protocol)
+ );
println!("{indent}socket: {}", api::socket_path().display());
}
ServerRuntimeStatus::NotRunning => {
@@ -191,14 +212,33 @@ fn compatibility_label(protocol: Option<u32>) -> &'static str {
}
}
+fn endpoint_compatibility_label(
+ capabilities: Option<&crate::api::schema::ServerCapabilities>,
+) -> &'static str {
+ match capabilities.and_then(|value| value.endpoint_protocol_generation) {
+ Some(generation)
+ if generation == crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION =>
+ {
+ "yes"
+ }
+ Some(_) => "no",
+ None => "unknown",
+ }
+}
+
fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str {
- match server {
- ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
- Some(version) if version == crate::build_info::version() => "no",
- Some(_) => "yes",
- None => "unknown",
- },
- ServerRuntimeStatus::NotRunning => "no",
+ match restart_needed_bool(server) {
+ Some(true) => "yes",
+ Some(false) => "no",
+ None => "unknown",
+ }
+}
+
+fn server_binary_stale_label(server: &ServerRuntimeStatus) -> &'static str {
+ match server_binary_stale_bool(server) {
+ Some(true) => "yes",
+ Some(false) => "no",
+ None => "unknown",
}
}
@@ -214,6 +254,7 @@ struct ClientStatusJson {
version: String,
channel: &'static str,
protocol: u32,
+ endpoint_protocol_generation: u32,
binary: String,
session: Option<String>,
}
@@ -226,20 +267,24 @@ struct ServerStatusJson {
protocol: Option<u32>,
capabilities: Option<ServerCapabilitiesJson>,
compatible: Option<bool>,
+ endpoint_compatible: Option<bool>,
socket: String,
session: Option<String>,
restart_needed: Option<bool>,
+ server_binary_stale: Option<bool>,
}
#[derive(Serialize)]
struct ServerCapabilitiesJson {
live_handoff: bool,
detached_server_daemon: bool,
+ endpoint_protocol_generation: Option<u32>,
}
#[derive(Serialize)]
struct UpdateStatusJson {
restart_needed: Option<bool>,
+ server_binary_stale: Option<bool>,
}
fn client_status_json() -> ClientStatusJson {
@@ -247,6 +292,7 @@ fn client_status_json() -> ClientStatusJson {
version: crate::build_info::version(),
channel: crate::config::Config::load().config.update.channel.as_str(),
protocol: crate::protocol::PROTOCOL_VERSION,
+ endpoint_protocol_generation: crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
binary: current_exe_label(),
session: crate::session::active_name(),
}
@@ -268,11 +314,18 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
.map(|capabilities| ServerCapabilitiesJson {
live_handoff: capabilities.live_handoff,
detached_server_daemon: capabilities.detached_server_daemon,
+ endpoint_protocol_generation: capabilities.endpoint_protocol_generation,
}),
compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION),
+ endpoint_compatible: capabilities.as_ref().and_then(|capabilities| {
+ capabilities.endpoint_protocol_generation.map(|generation| {
+ generation == crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
+ })
+ }),
socket: api::socket_path().display().to_string(),
session: crate::session::active_name(),
restart_needed: restart_needed_bool(server),
+ server_binary_stale: server_binary_stale_bool(server),
},
ServerRuntimeStatus::NotRunning => ServerStatusJson {
status: "not_running",
@@ -281,9 +334,11 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
protocol: None,
capabilities: None,
compatible: None,
+ endpoint_compatible: None,
socket: api::socket_path().display().to_string(),
session: crate::session::active_name(),
restart_needed: Some(false),
+ server_binary_stale: Some(false),
},
}
}
@@ -291,16 +346,27 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson {
UpdateStatusJson {
restart_needed: restart_needed_bool(server),
+ server_binary_stale: server_binary_stale_bool(server),
}
}
fn restart_needed_bool(server: &ServerRuntimeStatus) -> Option<bool> {
match server {
- ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
- Some(version) if version == crate::build_info::version() => Some(false),
- Some(_) => Some(true),
- None => None,
- },
+ ServerRuntimeStatus::Running { capabilities, .. } => Some(
+ capabilities
+ .as_ref()
+ .and_then(|value| value.endpoint_protocol_generation)
+ != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ ),
+ ServerRuntimeStatus::NotRunning => Some(false),
+ }
+}
+
+fn server_binary_stale_bool(server: &ServerRuntimeStatus) -> Option<bool> {
+ match server {
+ ServerRuntimeStatus::Running { version, .. } => version
+ .as_deref()
+ .map(|version| version != crate::build_info::version()),
ServerRuntimeStatus::NotRunning => Some(false),
}
}
@@ -322,3 +388,42 @@ fn print_status_help() {
eprintln!(" herdr status server [--json] show running server status");
eprintln!(" herdr status client [--json] show local client binary status");
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn running_server(
+ version: Option<&str>,
+ endpoint_generation: Option<u32>,
+ ) -> ServerRuntimeStatus {
+ ServerRuntimeStatus::Running {
+ version: version.map(str::to_owned),
+ protocol: Some(crate::protocol::PROTOCOL_VERSION),
+ capabilities: Some(crate::api::schema::ServerCapabilities {
+ live_handoff: true,
+ detached_server_daemon: true,
+ endpoint_protocol_generation: endpoint_generation,
+ }),
+ }
+ }
+
+ #[test]
+ fn stale_compatible_server_does_not_require_restart() {
+ let server = running_server(
+ Some("0.0.0-old"),
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ );
+
+ assert_eq!(restart_needed_bool(&server), Some(false));
+ assert_eq!(server_binary_stale_bool(&server), Some(true));
+ }
+
+ #[test]
+ fn server_without_endpoint_baseline_requires_restart() {
+ let server = running_server(Some(crate::build_info::version().as_str()), None);
+
+ assert_eq!(restart_needed_bool(&server), Some(true));
+ assert_eq!(server_binary_stale_bool(&server), Some(false));
+ }
+}
diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs
index decb8522..d07b3d42 100644
--- a/src/client/endpoint_commands.rs
+++ b/src/client/endpoint_commands.rs
@@ -1,5 +1,6 @@
use std::collections::VecDeque;
use std::io;
+use std::time::{Duration, Instant};
use crate::api::client::ApiClientError;
use crate::api::schema::{Request, ResponseResult};
@@ -8,10 +9,14 @@ use crate::protocol::ClientMessage;
use super::shell::ClientShellEndpointError;
+const ENDPOINT_COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
+
struct InFlightCommand {
boot_id: String,
request_id: String,
response: Vec<u8>,
+ sent_at: Instant,
+ timed_out: bool,
}
pub(super) struct EndpointCommandResult {
@@ -52,10 +57,30 @@ impl EndpointCommands {
boot_id,
request_id,
response: Vec::new(),
+ sent_at: Instant::now(),
+ timed_out: false,
});
Ok(())
}
+ pub(super) fn expire(&mut self, now: Instant) -> Option<EndpointCommandResult> {
+ let command = self.in_flight.as_mut()?;
+ if command.timed_out
+ || now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT
+ {
+ return None;
+ }
+ command.timed_out = true;
+ Some(EndpointCommandResult {
+ boot_id: command.boot_id.clone(),
+ request_id: command.request_id.clone(),
+ result: Err(ClientShellEndpointError {
+ code: Some("endpoint_timeout".into()),
+ message: "this server did not respond to the action".into(),
+ }),
+ })
+ }
+
pub(super) fn receive_chunk(
&mut self,
response_boot_id: &str,
@@ -140,6 +165,8 @@ mod tests {
boot_id: "boot-a".into(),
request_id: "request-a".into(),
response: Vec::new(),
+ sent_at: Instant::now(),
+ timed_out: false,
}),
..EndpointCommands::default()
}
@@ -212,6 +239,38 @@ mod tests {
}
#[test]
+ fn in_flight_endpoint_command_expires_and_releases_the_lane() {
+ let mut commands = commands_with_in_flight();
+ let expired = commands
+ .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT)
+ .expect("expired endpoint command");
+
+ assert_eq!(expired.boot_id, "boot-a");
+ assert_eq!(expired.request_id, "request-a");
+ assert!(matches!(
+ expired.result,
+ Err(ClientShellEndpointError {
+ code: Some(code),
+ ..
+ }) if code == "endpoint_timeout"
+ ));
+ assert!(commands.in_flight.is_some());
+ assert!(commands
+ .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT)
+ .is_none());
+ let late_response = serde_json::to_vec(&SuccessResponse {
+ id: "request-a".into(),
+ result: ResponseResult::Ok {},
+ })
+ .unwrap();
+ assert!(commands
+ .receive_chunk("boot-a", "request-a", true, late_response)
+ .expect("late response releases the synchronized lane")
+ .is_some());
+ assert!(commands.in_flight.is_none());
+ }
+
+ #[test]
fn response_from_another_boot_is_rejected() {
let mut commands = commands_with_in_flight();
diff --git a/src/client/handshake.rs b/src/client/handshake.rs
index 2140516e..84dd6643 100644
--- a/src/client/handshake.rs
+++ b/src/client/handshake.rs
@@ -9,6 +9,11 @@ use tracing::debug;
use tracing::info;
use crate::ipc::LocalStream;
+use crate::protocol::endpoint::{
+ EndpointClientHello, EndpointServerWelcome, BLOB_CODEC_V1, ENDPOINT_HELLO_KIND,
+ ENDPOINT_PROTOCOL_GENERATION, ENDPOINT_WELCOME_KIND, INPUT_CODEC_V1, SNAPSHOT_CODEC_V1,
+ SURFACE_CODEC_V1,
+};
use crate::protocol::{
self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
};
@@ -112,10 +117,17 @@ fn set_handshake_recv_timeout(
.map_err(ClientError::ConnectionFailed)
}
+#[derive(Debug)]
+pub(super) struct HandshakeResult {
+ pub(super) encoding: RenderEncoding,
+ pub(super) endpoint_methods: Option<Vec<String>>,
+}
+
/// Performs the client→server handshake.
///
-/// Sends TerminalHello (or ClientShellHello) with the terminal size and protocol
-/// version, then reads the Welcome response.
+/// Direct terminal clients retain the same-install private protocol. Client-owned
+/// shells use the stable endpoint generation and negotiate whole codecs without
+/// comparing Herdr build versions.
pub(super) fn do_handshake(
stream: &mut LocalStream,
cols: u16,
@@ -126,14 +138,15 @@ pub(super) fn do_handshake(
shell_surface_size: Option<crate::protocol::ClientSurfaceSize>,
endpoint_keybindings: bool,
mouse_capture: bool,
-) -> Result<RenderEncoding, ClientError> {
+) -> Result<HandshakeResult, ClientError> {
stream
.set_nonblocking(false)
.map_err(ClientError::ConnectionFailed)?;
+ let endpoint_shell = shell_surface_size.is_some();
let hello = if let Some(surface_size) = shell_surface_size {
- ClientMessage::ClientShellHello {
- version: PROTOCOL_VERSION,
+ let hello = EndpointClientHello {
+ generation: ENDPOINT_PROTOCOL_GENERATION,
cell_width_px,
cell_height_px,
surface_size,
@@ -144,6 +157,16 @@ pub(super) fn do_handshake(
&& direct_graphics_profile_allowed(),
endpoint_keybindings,
mouse_capture,
+ snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()],
+ surface_codecs: vec![SURFACE_CODEC_V1.into()],
+ input_codecs: vec![INPUT_CODEC_V1.into()],
+ blob_codecs: vec![BLOB_CODEC_V1.into()],
+ };
+ ClientMessage::EndpointControl {
+ kind: ENDPOINT_HELLO_KIND.into(),
+ data: serde_json::to_string(&hello).map_err(|error| {
+ ClientError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidData, error))
+ })?,
}
} else {
ClientMessage::TerminalHello {
@@ -170,6 +193,54 @@ pub(super) fn do_handshake(
"failed to clear client handshake read timeout",
)?;
+ if endpoint_shell {
+ let ServerMessage::EndpointControl { kind, data } = welcome else {
+ return Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "server does not support the stable Herdr endpoint protocol; update this machine",
+ ),
+ )));
+ };
+ if kind != ENDPOINT_WELCOME_KIND {
+ return Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(io::ErrorKind::InvalidData, "expected endpoint welcome"),
+ )));
+ }
+ let welcome: EndpointServerWelcome = serde_json::from_str(&data).map_err(|error| {
+ ClientError::Protocol(protocol::FramingError::Io(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("invalid endpoint welcome: {error}"),
+ )))
+ })?;
+ if let Some(error) = welcome.error {
+ return Err(ClientError::HandshakeRejected {
+ version: welcome.generation,
+ error: error.message,
+ });
+ }
+ if welcome.generation != ENDPOINT_PROTOCOL_GENERATION
+ || welcome.snapshot_codec != SNAPSHOT_CODEC_V1
+ || welcome.surface_codec != SURFACE_CODEC_V1
+ || welcome.input_codec != INPUT_CODEC_V1
+ || welcome.blob_codec != BLOB_CODEC_V1
+ {
+ return Err(ClientError::HandshakeRejected {
+ version: welcome.generation,
+ error: "server has no compatible endpoint core; update this machine".into(),
+ });
+ }
+ info!(
+ generation = welcome.generation,
+ server_version = %welcome.server_version,
+ "endpoint handshake succeeded"
+ );
+ return Ok(HandshakeResult {
+ encoding: RenderEncoding::SemanticFrame,
+ endpoint_methods: Some(welcome.methods),
+ });
+ }
+
match welcome {
ServerMessage::Welcome {
version,
@@ -180,7 +251,10 @@ pub(super) fn do_handshake(
return Err(ClientError::HandshakeRejected { version, error });
}
info!(version, ?encoding, "handshake succeeded");
- Ok(encoding)
+ Ok(HandshakeResult {
+ encoding,
+ endpoint_methods: None,
+ })
}
_ => Err(ClientError::Protocol(protocol::FramingError::Io(
io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
diff --git a/src/client/mod.rs b/src/client/mod.rs
index f476f129..b7fdcabe 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -412,7 +412,7 @@ fn run_client_with_mode(
.is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings);
// Perform handshake while the stream is still in blocking mode.
- let negotiated_encoding = match do_handshake(
+ let handshake = match do_handshake(
&mut stream,
cols,
rows,
@@ -489,7 +489,8 @@ fn run_client_with_mode(
exact_cell_size,
should_quit,
loop_config,
- negotiated_encoding,
+ handshake.encoding,
+ handshake.endpoint_methods,
attach_escape,
)
.await
@@ -613,6 +614,45 @@ fn apply_client_shell_input_source_changes(
}
}
+fn install_client_shell_snapshot(
+ state: &mut ClientState,
+ snapshot: Box<crate::protocol::ClientShellSnapshot>,
+ write_stream: &mut LocalStream,
+ prefix_input_source: &mut impl crate::platform::PrefixInputSource,
+) -> Result<(), ClientError> {
+ let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell {
+ let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
+ shell.set_snapshot(snapshot);
+ let graphics_cleanup = shell.take_pending_graphics_cleanup();
+ let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1);
+ (
+ shell.compose(state.reported_size.0, state.reported_size.1),
+ (previous_size != next_size).then(|| {
+ client_shell_resize_message(
+ shell,
+ state.reported_size.0,
+ state.reported_size.1,
+ state.reported_cell_size.0,
+ state.reported_cell_size.1,
+ state.pixel_geometry_exact,
+ )
+ }),
+ graphics_cleanup,
+ )
+ } else {
+ (None, None, Vec::new())
+ };
+ apply_client_shell_input_source_changes(state, prefix_input_source);
+ state.present_graphics(&graphics_cleanup);
+ if let Some(resize) = resize {
+ write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?;
+ }
+ if let Some(frame) = composed {
+ state.present_frame(frame);
+ }
+ Ok(())
+}
+
fn finish_client_shell_input(
state: &mut ClientState,
outcome: shell::ClientShellInput,
@@ -682,6 +722,7 @@ async fn run_client_loop(
should_quit: Arc<AtomicBool>,
config: ClientLoopConfig,
negotiated_encoding: RenderEncoding,
+ endpoint_methods: Option<Vec<String>>,
attach_escape: Option<AttachEscapeState>,
) -> Result<(), ClientError> {
#[cfg(windows)]
@@ -723,6 +764,7 @@ async fn run_client_loop(
};
if let Some(shell) = state.shell.as_mut() {
shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px);
+ shell.set_endpoint_methods(endpoint_methods);
}
debug!(?negotiated_encoding, "client render encoding active");
let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active));
@@ -1218,42 +1260,13 @@ async fn run_client_loop(
}
}
ClientLoopEvent::ServerMessage(msg) => match *msg {
- ServerMessage::ClientShellSnapshot(snapshot) => {
- let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell
- {
- let previous_size =
- shell.surface_size(state.reported_size.0, state.reported_size.1);
- shell.set_snapshot(snapshot);
- let graphics_cleanup = shell.take_pending_graphics_cleanup();
- let next_size =
- shell.surface_size(state.reported_size.0, state.reported_size.1);
- (
- shell.compose(state.reported_size.0, state.reported_size.1),
- (previous_size != next_size).then(|| {
- client_shell_resize_message(
- shell,
- state.reported_size.0,
- state.reported_size.1,
- state.reported_cell_size.0,
- state.reported_cell_size.1,
- state.pixel_geometry_exact,
- )
- }),
- graphics_cleanup,
- )
- } else {
- (None, None, Vec::new())
- };
- apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source);
- state.present_graphics(&graphics_cleanup);
- if let Some(resize) = resize {
- if let Err(err) = write_to_server(&mut write_stream, &resize) {
- return Err(ClientError::ConnectionLost(err));
- }
- }
- if let Some(frame) = composed {
- state.present_frame(frame);
- }
+ ServerMessage::ClientShellSnapshot(_) => {
+ return Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "server sent an unnegotiated binary endpoint snapshot",
+ ),
+ )));
}
ServerMessage::PaneSurface(surface) => {
let composed = if let Some(shell) = &mut state.shell {
@@ -1699,6 +1712,35 @@ async fn run_client_loop(
sync_client_shell_keyboard_report_all(&mut state)?;
}
}
+ ServerMessage::EndpointControl { kind, data } => {
+ if kind != crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND {
+ if kind.starts_with("shell.snapshot.") {
+ return Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "unsupported mandatory endpoint snapshot codec {kind:?}"
+ ),
+ ),
+ )));
+ }
+ debug!(%kind, "ignoring unknown endpoint control message");
+ continue;
+ }
+ let snapshot: crate::protocol::ClientShellSnapshot =
+ serde_json::from_str(&data).map_err(|error| {
+ ClientError::Protocol(protocol::FramingError::Io(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("invalid endpoint snapshot: {error}"),
+ )))
+ })?;
+ install_client_shell_snapshot(
+ &mut state,
+ Box::new(snapshot),
+ &mut write_stream,
+ &mut prefix_input_source,
+ )?;
+ }
ServerMessage::Welcome { .. } => {
debug!("received unexpected Welcome in main loop");
}
@@ -1719,10 +1761,20 @@ async fn run_client_loop(
.detached_process_children
.retain_mut(|child| child.try_wait().ok().flatten().is_none());
if state.shell.is_some() {
+ let expired_endpoint = endpoint_commands.expire(now);
let (effects, outcome, frame) = {
let shell = state.shell.as_mut().expect("checked shell mode");
- let (effects, notification_repaint) = shell.tick_notifications(now);
let mut outcome = shell.tick_selection_autoscroll(now);
+ if let Some(expired) = expired_endpoint {
+ let (repaint, actions) = shell.handle_endpoint_result(
+ &expired.boot_id,
+ &expired.request_id,
+ expired.result,
+ );
+ outcome.repaint |= repaint;
+ outcome.actions.extend(actions);
+ }
+ let (effects, notification_repaint) = shell.tick_notifications(now);
outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now);
let frame = outcome
.repaint
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index efc9aae9..34c780da 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -227,11 +227,13 @@ impl ClientShellState {
if action == crate::protocol::ClientShellCommandAction::Popup {
self.popup_pending = true;
self.popup_pending_deadline = None;
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::CommandInvoke(params),
PendingEndpointKind::PopupCommand,
outcome,
- );
+ ) {
+ self.popup_pending = false;
+ }
} else {
self.push_endpoint_method(
crate::api::schema::Method::CommandInvoke(params),
@@ -323,7 +325,7 @@ impl ClientShellState {
self.word_selection_generation = self.word_selection_generation.saturating_add(1);
let generation = self.word_selection_generation;
self.pending_word_selection = Some(generation);
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::PaneSelectionRead(
crate::api::schema::PaneSelectionReadParams {
pane_id: hit.pane_id.clone(),
@@ -345,7 +347,9 @@ impl ClientShellState {
generation,
},
outcome,
- );
+ ) {
+ self.pending_word_selection = None;
+ }
}
pub(super) fn push_endpoint_method(
@@ -356,15 +360,65 @@ impl ClientShellState {
self.push_endpoint_method_with_kind(method, PendingEndpointKind::Generic, outcome);
}
+ fn push_endpoint_notice(
+ &mut self,
+ kind: ClientEndpointNoticeKind,
+ code: impl Into<String>,
+ title: impl Into<String>,
+ body: impl Into<String>,
+ ) -> bool {
+ let key = ClientEndpointNoticeKey {
+ boot_id: self
+ .snapshot
+ .as_deref()
+ .map(|snapshot| snapshot.boot_id.clone())
+ .unwrap_or_else(|| "disconnected".to_owned()),
+ kind,
+ code: code.into(),
+ };
+ let body = body.into();
+ if kind == ClientEndpointNoticeKind::Rejected {
+ if self
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| notice.key == key && notice.body == body)
+ {
+ return false;
+ }
+ } else if !self.endpoint_notice_seen.insert(key.clone()) {
+ return false;
+ }
+ self.visible_endpoint_notice = Some(ClientVisibleEndpointNotice {
+ key,
+ title: title.into(),
+ body,
+ deadline: std::time::Instant::now() + std::time::Duration::from_secs(8),
+ });
+ true
+ }
+
pub(super) fn push_endpoint_method_with_kind(
&mut self,
method: crate::api::schema::Method,
kind: PendingEndpointKind,
outcome: &mut ClientShellInput,
- ) {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return;
- };
+ ) -> bool {
+ if self.snapshot.is_none() {
+ return false;
+ }
+ let method_name = crate::api::api_method_name(&method).to_owned();
+ if !self.supports_endpoint_method(&method) {
+ outcome.repaint |= self.push_endpoint_notice(
+ ClientEndpointNoticeKind::Unsupported,
+ method_name.clone(),
+ "Action unavailable",
+ format!(
+ "This server does not support {method_name} yet. Update and restart it to enable this action."
+ ),
+ );
+ return false;
+ }
+ let snapshot = self.snapshot.as_deref().expect("checked snapshot");
let confirmation_workspace_id = match &method {
crate::api::schema::Method::TabClose(target) => snapshot
.tabs
@@ -385,6 +439,7 @@ impl ClientShellState {
request_id.clone(),
PendingEndpointRequest {
boot_id: snapshot.boot_id.clone(),
+ method_name,
confirmation_workspace_id,
kind,
},
@@ -396,14 +451,16 @@ impl ClientShellState {
method,
}),
});
+ true
}
pub(crate) fn receive_endpoint_error(&mut self, message: String) -> bool {
- if self.endpoint_error.as_deref() == Some(message.as_str()) {
- return false;
- }
- self.endpoint_error = Some(message);
- true
+ self.push_endpoint_notice(
+ ClientEndpointNoticeKind::Rejected,
+ "paste_rejected",
+ "Paste rejected",
+ message,
+ )
}
pub(crate) fn handle_endpoint_result(
@@ -423,12 +480,49 @@ impl ClientShellState {
{
return (false, Vec::new());
}
+ if result.is_ok() {
+ let timeout_key = ClientEndpointNoticeKey {
+ boot_id: boot_id.to_owned(),
+ kind: ClientEndpointNoticeKind::Timeout,
+ code: pending.method_name.clone(),
+ };
+ self.endpoint_notice_seen.remove(&timeout_key);
+ }
+ if let Err(error) = &result {
+ let code = error.code.as_deref().unwrap_or("invalid_response");
+ if !matches!(
+ code,
+ "confirmation_required" | "stale_content" | "stale_target"
+ ) {
+ let (kind, notice_code, title, body) = match code {
+ "endpoint_timeout" => (
+ ClientEndpointNoticeKind::Timeout,
+ pending.method_name.clone(),
+ "Server timed out",
+ format!("This server did not respond to {}.", pending.method_name),
+ ),
+ "server_unavailable" => (
+ ClientEndpointNoticeKind::Unavailable,
+ "server".to_owned(),
+ "Server unavailable",
+ error.message.clone(),
+ ),
+ _ => (
+ ClientEndpointNoticeKind::Rejected,
+ format!("{}:{code}", pending.method_name),
+ "Action rejected",
+ error.message.clone(),
+ ),
+ };
+ self.push_endpoint_notice(kind, notice_code, title, body);
+ }
+ }
match pending.kind {
PendingEndpointKind::Generic => {}
PendingEndpointKind::ProductAnnouncementDismiss { version, id } => {
return match result {
Ok(_) => (false, Vec::new()),
- Err(error) => {
+ Err(_) => {
let key = (version.clone(), id.clone());
if self.dismissed_product_announcement.as_ref() == Some(&key) {
self.dismissed_product_announcement = None;
@@ -447,7 +541,6 @@ impl ClientShellState {
));
}
}
- self.endpoint_error = Some(error.message);
(true, Vec::new())
}
};
@@ -455,7 +548,7 @@ impl ClientShellState {
PendingEndpointKind::ReleaseNotesDismiss => {
return match result {
Ok(_) => (false, Vec::new()),
- Err(error) => {
+ Err(_) => {
if self.overlay.is_none() {
if let Some(notes) = self
.snapshot
@@ -467,7 +560,6 @@ impl ClientShellState {
));
}
}
- self.endpoint_error = Some(error.message);
(true, Vec::new())
}
};
@@ -479,10 +571,9 @@ impl ClientShellState {
Some(std::time::Instant::now() + std::time::Duration::from_secs(1));
(false, Vec::new())
}
- Err(error) => {
+ Err(_) => {
self.popup_pending = false;
self.popup_pending_deadline = None;
- self.endpoint_error = Some(error.message);
(true, Vec::new())
}
};
@@ -517,10 +608,7 @@ impl ClientShellState {
Some("endpoint returned an unexpected selection result".to_owned());
(true, fallback())
}
- Err(error) => {
- self.endpoint_error = Some(error.message);
- (true, fallback())
- }
+ Err(_) => (true, fallback()),
};
}
PendingEndpointKind::WordSelection {
@@ -551,10 +639,7 @@ impl ClientShellState {
);
return (true, Vec::new());
}
- Err(error) => {
- self.endpoint_error = Some(error.message);
- return (true, Vec::new());
- }
+ Err(_) => return (true, Vec::new()),
};
let Some((start_col, end_col)) =
crate::app::actions::word_bounds_at_column(&row_text, col)
@@ -640,10 +725,7 @@ impl ClientShellState {
self.url_click_consumes_until_up = completed_before_release;
(false, Vec::new())
}
- Err(error) => {
- self.endpoint_error = Some(error.message);
- (true, replay_action(replay))
- }
+ Err(_) => (true, replay_action(replay)),
};
}
PendingEndpointKind::CopyMotion {
@@ -673,10 +755,7 @@ impl ClientShellState {
Some("endpoint returned an unexpected copy-motion result".to_owned());
(true, false)
}
- Err(error) => {
- self.endpoint_error = Some(error.message);
- (true, false)
- }
+ Err(_) => (true, false),
};
self.complete_copy_operation(session_generation, continue_queue, &mut outcome);
return (repaint || outcome.repaint, outcome.actions);
@@ -731,9 +810,8 @@ impl ClientShellState {
Some("endpoint returned an unexpected copy-search result".to_owned());
(true, false)
}
- Err(error) => {
+ Err(_) => {
self.cancel_deferred_copy_after_search(generation);
- self.endpoint_error = Some(error.message);
(true, false)
}
};
@@ -748,10 +826,7 @@ impl ClientShellState {
Some("endpoint returned an unexpected config reload result".to_owned());
true
}
- Err(error) => {
- self.endpoint_error = Some(error.message);
- true
- }
+ Err(_) => true,
};
return (repaint, Vec::new());
}
@@ -778,10 +853,7 @@ impl ClientShellState {
}
true
}
- Err(error) => {
- self.endpoint_error = Some(error.message);
- true
- }
+ Err(_) => true,
};
(repaint, Vec::new())
}
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index 6432f3e1..04ca995c 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -269,7 +269,10 @@ impl ClientShellState {
restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref());
self.hits.notification_toast = Rect::default();
let has_config_diagnostic = self.config_diagnostic.is_some();
- if has_config_diagnostic || self.visible_notification.is_some() {
+ if has_config_diagnostic
+ || self.visible_endpoint_notice.is_some()
+ || self.visible_notification.is_some()
+ {
let cursor = frame.cursor.clone();
let mut composed = frame.to_ratatui_buffer()?;
if let Some(diagnostic) = self.config_diagnostic.as_deref() {
@@ -285,7 +288,25 @@ impl ClientShellState {
&self.config.palette,
);
}
- if let Some(notification) = self.visible_notification.as_ref() {
+ if let Some(notice) = self.visible_endpoint_notice.as_ref() {
+ self.hits.notification_toast = if layout.mobile_header.is_empty() {
+ notifications::render_endpoint_notice(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ notice,
+ u16::from(has_config_diagnostic),
+ &self.config.palette,
+ )
+ } else {
+ notifications::render_mobile_endpoint_notice_banner(
+ &mut composed,
+ Rect::new(0, 0, cols, rows),
+ notice,
+ has_config_diagnostic,
+ &self.config.palette,
+ )
+ };
+ } else if let Some(notification) = self.visible_notification.as_ref() {
self.hits.notification_toast = if layout.mobile_header.is_empty() {
notifications::render_visible_notification(
&mut composed,
diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs
index 0273cefb..1c2993b7 100644
--- a/src/client/shell/config.rs
+++ b/src/client/shell/config.rs
@@ -200,16 +200,8 @@ impl ClientShellConfig {
};
config.keys.command = commands
.iter()
- .map(|command| crate::config::CommandKeybindConfig {
- key: if command.binding_labels.len() == 1 {
- crate::config::BindingConfig::One(command.binding_labels[0].clone())
- } else {
- crate::config::BindingConfig::Many(command.binding_labels.clone())
- },
- // The client never executes this field; preserve the opaque endpoint ID
- // through the shared config collision resolver.
- command: command.command_id.clone(),
- action_type: match command.action {
+ .filter_map(|command| {
+ let action_type = match command.action {
crate::protocol::ClientShellCommandAction::Shell => {
crate::config::CommandKeybindType::Shell
}
@@ -222,10 +214,22 @@ impl ClientShellConfig {
crate::protocol::ClientShellCommandAction::PluginAction => {
crate::config::CommandKeybindType::PluginAction
}
- },
- description: command.description.clone(),
- width: None,
- height: None,
+ crate::protocol::ClientShellCommandAction::Unknown => return None,
+ };
+ Some(crate::config::CommandKeybindConfig {
+ key: if command.binding_labels.len() == 1 {
+ crate::config::BindingConfig::One(command.binding_labels[0].clone())
+ } else {
+ crate::config::BindingConfig::Many(command.binding_labels.clone())
+ },
+ // The client never executes this field; preserve the opaque endpoint ID
+ // through the shared config collision resolver.
+ command: command.command_id.clone(),
+ action_type,
+ description: command.description.clone(),
+ width: None,
+ height: None,
+ })
})
.collect();
config
@@ -236,6 +240,9 @@ impl ClientShellConfig {
};
if self.keybinding_source == ClientShellKeybindingSource::Endpoint {
for command in commands {
+ let Ok(action) = command.action.try_into() else {
+ continue;
+ };
keybinds
.keybinds
.custom_commands
@@ -245,7 +252,7 @@ impl ClientShellConfig {
)?,
label: command.binding_label.clone(),
command: command.command_id.clone(),
- action: command.action.into(),
+ action,
description: command.description.clone(),
width: None,
height: None,
diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs
index c39a7507..32a17bf4 100644
--- a/src/client/shell/copy_mode.rs
+++ b/src/client/shell/copy_mode.rs
@@ -797,7 +797,9 @@ impl ClientShellState {
}
};
self.copy_operation_in_flight = true;
- self.push_endpoint_method_with_kind(method, kind, outcome);
+ if !self.push_endpoint_method_with_kind(method, kind, outcome) {
+ self.copy_operation_in_flight = false;
+ }
return;
}
}
diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs
index 1fc23414..8a512d3a 100644
--- a/src/client/shell/graphics.rs
+++ b/src/client/shell/graphics.rs
@@ -39,6 +39,7 @@ impl ClientShellState {
|| self.mode != ClientShellMode::Terminal
|| self.endpoint_error.is_some()
|| self.config_diagnostic.is_some()
+ || self.visible_endpoint_notice.is_some()
|| self.visible_notification.is_some()
|| self.copy_feedback.is_some()
|| self
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index 437ec4ed..612319e2 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -78,14 +78,20 @@ impl ClientShellState {
self.pane_scroll_targets
.insert(pane_id.clone(), offset_from_bottom);
self.pane_scroll_in_flight.insert(pane_id.clone(), serial);
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::PaneScroll(crate::api::schema::PaneScrollParams {
pane_id: pane_id.clone(),
offset_from_bottom: offset_from_bottom as u64,
}),
- PendingEndpointKind::PaneScroll { pane_id, serial },
+ PendingEndpointKind::PaneScroll {
+ pane_id: pane_id.clone(),
+ serial,
+ },
outcome,
- );
+ ) {
+ self.pane_scroll_targets.remove(&pane_id);
+ self.pane_scroll_in_flight.remove(&pane_id);
+ }
}
pub(super) fn complete_pane_scroll(
@@ -120,10 +126,9 @@ impl ClientShellState {
Some("endpoint returned an unexpected pane-scroll result".to_owned());
true
}
- Err(error) => {
+ Err(_) => {
self.pane_scroll_queued.remove(&pane_id);
self.pane_scroll_targets.remove(&pane_id);
- self.endpoint_error = Some(error.message);
true
}
};
@@ -893,6 +898,14 @@ impl ClientShellState {
return;
}
}
+ if self.visible_endpoint_notice.is_some()
+ && mouse.kind == MouseEventKind::Down(MouseButton::Left)
+ && super::contains(self.hits.notification_toast, point)
+ {
+ self.visible_endpoint_notice = None;
+ outcome.repaint = true;
+ return;
+ }
if self.overlay.is_none()
&& self.mode == ClientShellMode::Terminal
&& self
diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs
index 0310e57d..43fe566a 100644
--- a/src/client/shell/notifications.rs
+++ b/src/client/shell/notifications.rs
@@ -1,13 +1,16 @@
use super::*;
use ratatui::{
+ style::Color,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
-pub(super) fn render_mobile_notification_banner(
+fn render_mobile_notice_banner(
buffer: &mut Buffer,
area: Rect,
- notification: &ClientVisibleNotification,
+ title: &str,
+ body: Option<&str>,
+ dot_color: Color,
offset_for_warning: bool,
palette: &Palette,
) -> Rect {
@@ -23,6 +26,49 @@ pub(super) fn render_mobile_notification_banner(
let background = palette.surface0;
Clear.render(rect, buffer);
buffer.set_style(rect, Style::default().bg(background));
+ let mut x = rect.x;
+ for (text, style) in [
+ (" ", Style::default().bg(background)),
+ ("●", Style::default().fg(dot_color).bg(background)),
+ (" ", Style::default().bg(background)),
+ (
+ title,
+ Style::default()
+ .fg(palette.text)
+ .bg(background)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ] {
+ x = super::render::put_segment(buffer, x, rect.y, rect.right(), text, style);
+ }
+ if let Some(body) = body.filter(|body| !body.is_empty()) {
+ x = super::render::put_segment(
+ buffer,
+ x,
+ rect.y,
+ rect.right(),
+ " · ",
+ Style::default().fg(palette.overlay0).bg(background),
+ );
+ super::render::put_text(
+ buffer,
+ x,
+ rect.y,
+ rect.right().saturating_sub(x),
+ body,
+ Style::default().fg(palette.overlay0).bg(background),
+ );
+ }
+ rect
+}
+
+pub(super) fn render_mobile_notification_banner(
+ buffer: &mut Buffer,
+ area: Rect,
+ notification: &ClientVisibleNotification,
+ offset_for_warning: bool,
+ palette: &Palette,
+) -> Rect {
let event = &notification.event;
let title = match event.kind {
SemanticNotificationKind::NeedsAttention => event
@@ -45,84 +91,55 @@ pub(super) fn render_mobile_notification_banner(
palette.accent
}
};
- let mut x = rect.x;
- x = super::render::put_segment(
- buffer,
- x,
- rect.y,
- rect.right(),
- " ",
- Style::default().bg(background),
- );
- x = super::render::put_segment(
- buffer,
- x,
- rect.y,
- rect.right(),
- "●",
- Style::default().fg(dot_color).bg(background),
- );
- x = super::render::put_segment(
+ render_mobile_notice_banner(
buffer,
- x,
- rect.y,
- rect.right(),
- " ",
- Style::default().bg(background),
- );
- x = super::render::put_segment(
- buffer,
- x,
- rect.y,
- rect.right(),
+ area,
&title,
- Style::default()
- .fg(palette.text)
- .bg(background)
- .add_modifier(Modifier::BOLD),
- );
- if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) {
- x = super::render::put_segment(
- buffer,
- x,
- rect.y,
- rect.right(),
- " · ",
- Style::default().fg(palette.overlay0).bg(background),
- );
- super::render::put_text(
- buffer,
- x,
- rect.y,
- rect.right().saturating_sub(x),
- body,
- Style::default().fg(palette.overlay0).bg(background),
- );
- }
- rect
+ event.body.as_deref(),
+ dot_color,
+ offset_for_warning,
+ palette,
+ )
}
-pub(super) fn render_visible_notification(
+pub(super) fn render_mobile_endpoint_notice_banner(
buffer: &mut Buffer,
area: Rect,
- notification: &ClientVisibleNotification,
- default_position: crate::config::ToastHerdrPosition,
+ notice: &ClientVisibleEndpointNotice,
+ offset_for_warning: bool,
+ palette: &Palette,
+) -> Rect {
+ render_mobile_notice_banner(
+ buffer,
+ area,
+ &notice.title,
+ Some(&notice.body),
+ palette.red,
+ offset_for_warning,
+ palette,
+ )
+}
+
+fn render_notification_card(
+ buffer: &mut Buffer,
+ area: Rect,
+ title: &str,
+ body: &str,
+ position: crate::config::ToastHerdrPosition,
top_offset: u16,
+ dot_color: Color,
palette: &Palette,
) -> Rect {
if area.is_empty() {
return Rect::default();
}
- let event = &notification.event;
- let body = event.body.as_deref().unwrap_or_default();
- let content_width = unicode_width::UnicodeWidthStr::width(event.title.as_str())
+ let content_width = unicode_width::UnicodeWidthStr::width(title)
.max(unicode_width::UnicodeWidthStr::width(body))
.saturating_add(6);
let width = u16::try_from(content_width)
.unwrap_or(u16::MAX)
.min(area.width);
let height: u16 = if body.is_empty() { 3 } else { 4 }.min(area.height);
- let position = event.position.unwrap_or(default_position);
let x = match position {
crate::config::ToastHerdrPosition::TopLeft
| crate::config::ToastHerdrPosition::BottomLeft => area.x,
@@ -148,19 +165,10 @@ pub(super) fn render_visible_notification(
let inner = block.inner(rect);
block.render(rect, buffer);
Paragraph::new(Line::from(vec![
- Span::styled(
- "●",
- Style::default().fg(match event.kind {
- SemanticNotificationKind::NeedsAttention => palette.red,
- SemanticNotificationKind::Finished => palette.blue,
- SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
- palette.accent
- }
- }),
- ),
+ Span::styled("●", Style::default().fg(dot_color)),
Span::raw(" "),
Span::styled(
- event.title.as_str(),
+ title,
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
@@ -185,6 +193,60 @@ pub(super) fn render_visible_notification(
rect
}
+pub(super) fn render_visible_notification(
+ buffer: &mut Buffer,
+ area: Rect,
+ notification: &ClientVisibleNotification,
+ default_position: crate::config::ToastHerdrPosition,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ let event = &notification.event;
+ let dot_color = match event.kind {
+ SemanticNotificationKind::NeedsAttention => palette.red,
+ SemanticNotificationKind::Finished => palette.blue,
+ SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
+ palette.accent
+ }
+ };
+ render_notification_card(
+ buffer,
+ area,
+ &event.title,
+ event.body.as_deref().unwrap_or_default(),
+ event.position.unwrap_or(default_position),
+ top_offset,
+ dot_color,
+ palette,
+ )
+}
+
+pub(super) fn render_endpoint_notice(
+ buffer: &mut Buffer,
+ area: Rect,
+ notice: &ClientVisibleEndpointNotice,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ render_notification_card(
+ buffer,
+ area,
+ &notice.title,
+ &notice.body,
+ crate::config::ToastHerdrPosition::TopRight,
+ top_offset,
+ match notice.key.kind {
+ ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => {
+ palette.red
+ }
+ ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => {
+ palette.yellow
+ }
+ },
+ palette,
+ )
+}
+
impl ClientShellState {
pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
let Some(notification) = self.visible_notification.take() else {
@@ -246,6 +308,14 @@ impl ClientShellState {
self.visible_notification = None;
repaint = true;
}
+ if self
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|visible| now >= visible.deadline)
+ {
+ self.visible_endpoint_notice = None;
+ repaint = true;
+ }
let pending = std::mem::take(&mut self.pending_notifications);
let mut effects = Vec::new();
diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs
index 95a437fe..d90d371f 100644
--- a/src/client/shell/settings.rs
+++ b/src/client/shell/settings.rs
@@ -233,11 +233,15 @@ impl ClientShellState {
settings.integration_messages.clear();
}
}
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::IntegrationList(crate::api::schema::EmptyParams::default()),
PendingEndpointKind::IntegrationList,
outcome,
- );
+ ) {
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ settings.loading_integrations = false;
+ }
+ }
}
fn install_recommended_integrations(&mut self, outcome: &mut ClientShellInput) {
@@ -260,15 +264,22 @@ impl ClientShellState {
settings.installing_integrations = true;
settings.integration_messages.clear();
}
- self.pending_integration_installs = targets.len();
+ self.pending_integration_installs = 0;
for target in targets {
- self.push_endpoint_method_with_kind(
+ if self.push_endpoint_method_with_kind(
crate::api::schema::Method::IntegrationInstall(
crate::api::schema::IntegrationInstallParams { target },
),
PendingEndpointKind::IntegrationInstall,
outcome,
- );
+ ) {
+ self.pending_integration_installs += 1;
+ }
+ }
+ if self.pending_integration_installs == 0 {
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ settings.installing_integrations = false;
+ }
}
outcome.repaint = true;
}
@@ -296,7 +307,7 @@ impl ClientShellState {
"endpoint returned an unexpected integration list result".into(),
);
}
- Err(error) => self.endpoint_error = Some(error.message),
+ Err(_) => {}
}
}
(true, Vec::new())
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index 81173c18..69e77dc5 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -636,10 +636,33 @@ pub(super) enum PendingEndpointKind {
pub(super) struct PendingEndpointRequest {
pub(super) boot_id: String,
+ pub(super) method_name: String,
pub(super) confirmation_workspace_id: Option<String>,
pub(super) kind: PendingEndpointKind,
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(super) enum ClientEndpointNoticeKind {
+ Unsupported,
+ Rejected,
+ Timeout,
+ Unavailable,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub(super) struct ClientEndpointNoticeKey {
+ pub(super) boot_id: String,
+ pub(super) kind: ClientEndpointNoticeKind,
+ pub(super) code: String,
+}
+
+pub(super) struct ClientVisibleEndpointNotice {
+ pub(super) key: ClientEndpointNoticeKey,
+ pub(super) title: String,
+ pub(super) body: String,
+ pub(super) deadline: std::time::Instant,
+}
+
pub(crate) struct ClientShellEndpointError {
pub code: Option<String>,
pub message: String,
@@ -836,10 +859,16 @@ pub(crate) struct ClientShellState {
pub(super) popup_pending: bool,
pub(super) popup_pending_deadline: Option<std::time::Instant>,
pub(super) next_request_id: u64,
+ /// Methods advertised by this endpoint. `None` is used only by local tests
+ /// and legacy construction paths; negotiated endpoint connections always
+ /// install an explicit set.
+ pub(super) endpoint_methods: Option<HashSet<String>>,
pub(super) pending_requests: HashMap<String, PendingEndpointRequest>,
pub(super) pending_integration_installs: usize,
pub(super) pending_notifications: Vec<ClientPendingNotification>,
pub(super) visible_notification: Option<ClientVisibleNotification>,
+ pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>,
+ pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>,
pub(super) outer_focused: Option<bool>,
pub(super) ascii_input_source_active: bool,
pub(super) pending_input_source_changes: Vec<bool>,
@@ -971,10 +1000,13 @@ impl ClientShellState {
popup_pending: false,
popup_pending_deadline: None,
next_request_id: 1,
+ endpoint_methods: None,
pending_requests: HashMap::new(),
pending_integration_installs: 0,
pending_notifications: Vec::new(),
visible_notification: None,
+ endpoint_notice_seen: HashSet::new(),
+ visible_endpoint_notice: None,
outer_focused: None,
ascii_input_source_active: false,
pending_input_source_changes: Vec::new(),
@@ -987,6 +1019,16 @@ impl ClientShellState {
}
}
+ pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) {
+ self.endpoint_methods = methods.map(|methods| methods.into_iter().collect());
+ }
+
+ pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool {
+ self.endpoint_methods
+ .as_ref()
+ .is_none_or(|methods| methods.contains(crate::api::api_method_name(method)))
+ }
+
fn focused_tab_count(&self) -> usize {
let Some(snapshot) = self.snapshot.as_deref() else {
return 0;
@@ -1072,7 +1114,10 @@ impl ClientShellState {
}
}
- pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) {
+ pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) {
+ snapshot
+ .commands
+ .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown);
if self.snapshot.as_ref().is_some_and(|current| {
current.boot_id == snapshot.boot_id && snapshot.revision < current.revision
}) {
@@ -1154,6 +1199,8 @@ impl ClientShellState {
self.pending_integration_installs = 0;
self.pending_notifications.clear();
self.visible_notification = None;
+ self.endpoint_notice_seen.clear();
+ self.visible_endpoint_notice = None;
self.endpoint_error = None;
self.navigate_workspace_id = None;
self.overlay = self
diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs
index 10486f50..784fbcd8 100644
--- a/src/client/shell/surface_patch.rs
+++ b/src/client/shell/surface_patch.rs
@@ -66,6 +66,8 @@ fn fast_path_blocker(
Some("client_surface_patch.fallback.endpoint_error")
} else if state.config_diagnostic.is_some() {
Some("client_surface_patch.fallback.config_diagnostic")
+ } else if state.visible_endpoint_notice.is_some() {
+ Some("client_surface_patch.fallback.endpoint_notice")
} else if state.visible_notification.is_some() {
Some("client_surface_patch.fallback.notification")
} else if state.copy_feedback.is_some() {
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index 0ee5aa3f..055b594d 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -983,6 +983,36 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() {
}
#[test]
+fn unavailable_worktree_create_does_not_wedge_the_overlay() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut prepare = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree),
+ &mut prepare,
+ );
+ let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else {
+ panic!("new worktree should prepare through worktree.list");
+ };
+ state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None)));
+ state.set_endpoint_methods(Some(vec!["worktree.list".into()]));
+ state.handle_input_bytes(b"feature/unavailable");
+
+ let submit = state.handle_input_bytes(b"\r");
+
+ assert!(submit.actions.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| notice.key.code == "worktree.create"));
+}
+
+#[test]
fn worktree_open_filters_and_clicks_a_stable_public_entry() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs
index 5d2d8243..5b0fced7 100644
--- a/src/client/shell/tests/keybindings_settings.rs
+++ b/src/client/shell/tests/keybindings_settings.rs
@@ -437,6 +437,60 @@ fn plugin_command_carries_client_owned_selection_coordinates() {
}
#[test]
+fn unavailable_endpoint_method_is_disabled_without_disconnect() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.set_endpoint_methods(Some(vec!["pane.focus".into()]));
+ let mut outcome = ClientShellInput::default();
+
+ state.push_endpoint_method(
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: "missing".into(),
+ }),
+ &mut outcome,
+ );
+
+ assert!(outcome.actions.is_empty());
+ assert!(outcome.repaint);
+ let notice = state
+ .visible_endpoint_notice
+ .as_ref()
+ .expect("unsupported action notice");
+ assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Unsupported);
+ assert_eq!(notice.key.code, "workspace.focus");
+ assert!(notice.body.contains("This server"));
+ assert!(state.endpoint_error.is_none());
+
+ let mut repeated = ClientShellInput::default();
+ state.push_endpoint_method(
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: "missing".into(),
+ }),
+ &mut repeated,
+ );
+ assert!(repeated.actions.is_empty());
+ assert!(!repeated.repaint);
+
+ state.compose(106, 20).expect("endpoint notice frame");
+ assert!(!state.hits.notification_toast.is_empty());
+
+ state.handle_input_bytes(b"x");
+ assert!(state.visible_endpoint_notice.is_some());
+
+ let hit = state.hits.notification_toast;
+ let dismissed =
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x,
+ row: hit.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(dismissed.repaint);
+ assert!(state.visible_endpoint_notice.is_none());
+}
+
+#[test]
fn generic_endpoint_failures_and_control_errors_are_visible() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
@@ -461,17 +515,98 @@ fn generic_endpoint_failures_and_control_errors_are_visible() {
);
assert!(repaint);
assert!(actions.is_empty());
+ let notice = state
+ .visible_endpoint_notice
+ .as_ref()
+ .expect("rejected action notice");
+ assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Rejected);
+ assert_eq!(notice.key.code, "workspace.focus:not_found");
+ assert_eq!(notice.body, "workspace no longer exists");
+ assert!(state.endpoint_error.is_none());
+
+ assert!(state.receive_endpoint_error("Paste rejected: too large".into()));
+ let notice = state
+ .visible_endpoint_notice
+ .as_ref()
+ .expect("paste rejection notice");
+ assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Rejected);
+ assert_eq!(notice.key.code, "paste_rejected");
+ assert_eq!(notice.body, "Paste rejected: too large");
+ assert!(state.receive_endpoint_error("Paste rejected again".into()));
assert_eq!(
- state.endpoint_error.as_deref(),
- Some("workspace no longer exists")
+ state
+ .visible_endpoint_notice
+ .as_ref()
+ .map(|notice| notice.body.as_str()),
+ Some("Paste rejected again")
);
+}
- assert!(state.receive_endpoint_error("Paste rejected: too large".into()));
+#[test]
+fn endpoint_timeout_is_a_deduplicated_server_notice() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ let mut outcome = ClientShellInput::default();
+ state.push_endpoint_method(
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: "work".into(),
+ }),
+ &mut outcome,
+ );
+ let request_id = match &outcome.actions[..] {
+ [ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
+ other => panic!("expected endpoint request, got {other:?}"),
+ };
+
+ let (repaint, actions) = state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_timeout".into()),
+ message: "transport timeout".into(),
+ }),
+ );
+
+ assert!(repaint);
+ assert!(actions.is_empty());
+ let notice = state
+ .visible_endpoint_notice
+ .as_ref()
+ .expect("timeout notice");
+ assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Timeout);
+ assert_eq!(notice.key.code, "workspace.focus");
+ assert!(notice.body.contains("workspace.focus"));
+ assert!(state.endpoint_error.is_none());
+}
+
+#[test]
+fn endpoint_notice_dedupe_resets_for_a_new_server_boot() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_endpoint_methods(Some(Vec::new()));
+ let method = || {
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: "work".into(),
+ })
+ };
+ let mut first = ClientShellInput::default();
+ state.push_endpoint_method(method(), &mut first);
+ assert!(first.repaint);
+
+ let mut next_snapshot = snapshot();
+ next_snapshot.boot_id = "boot-2".into();
+ state.set_snapshot(Box::new(next_snapshot));
+ let mut second = ClientShellInput::default();
+ state.push_endpoint_method(method(), &mut second);
+
+ assert!(second.repaint);
assert_eq!(
- state.endpoint_error.as_deref(),
- Some("Paste rejected: too large")
+ state
+ .visible_endpoint_notice
+ .as_ref()
+ .map(|notice| notice.key.boot_id.as_str()),
+ Some("boot-2")
);
- assert!(!state.receive_endpoint_error("Paste rejected: too large".into()));
}
#[test]
diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs
index 40ba0bd6..2e2901e8 100644
--- a/src/client/shell/tests/startup_overlays.rs
+++ b/src/client/shell/tests/startup_overlays.rs
@@ -455,6 +455,41 @@ fn onboarding_completion_persists_and_opens_endpoint_integrations() {
}
#[test]
+fn unavailable_integration_list_does_not_wedge_settings() {
+ let mut config =
+ ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true);
+ config.local_config_path = std::env::temp_dir().join(format!(
+ "herdr-client-onboarding-unavailable-{}-{}.toml",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos()
+ ));
+ let mut state = ClientShellState::new(config);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.set_endpoint_methods(Some(Vec::new()));
+
+ let outcome = state.handle_input_bytes(b"\r");
+
+ assert!(outcome.actions.is_empty());
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
+ section: ClientSettingsSection::Integrations,
+ loading_integrations: false,
+ ..
+ }))
+ ));
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| notice.key.code == "integration.list"));
+ let _ = std::fs::remove_file(&state.config.local_config_path);
+}
+
+#[test]
fn startup_config_diagnostics_are_client_rendered_and_persist_until_replaced() {
let config = ClientShellConfig::from_config(&Config::default())
.with_startup_config_diagnostic(Some("local config warning".into()));
@@ -558,6 +593,7 @@ fn endpoint_reload_result_does_not_override_snapshot_diagnostic_authority() {
"reload-1".into(),
PendingEndpointRequest {
boot_id: "boot-1".into(),
+ method_name: "server.reload_config".into(),
confirmation_workspace_id: None,
kind: PendingEndpointKind::ReloadConfig,
},
diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs
index 792cd248..bcadf1c8 100644
--- a/src/client/shell/worktrees.rs
+++ b/src/client/shell/worktrees.rs
@@ -276,7 +276,7 @@ impl ClientShellState {
create.creating = true;
create.error = None;
let workspace_id = create.source_workspace_id.clone();
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams {
workspace_id: Some(workspace_id),
cwd: None,
@@ -289,7 +289,11 @@ impl ClientShellState {
}),
PendingEndpointKind::WorktreeCreate,
outcome,
- );
+ ) {
+ if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() {
+ create.creating = false;
+ }
+ }
outcome.repaint = true;
}
@@ -328,7 +332,7 @@ impl ClientShellState {
open.selected = index;
open.opening = true;
open.error = None;
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::WorktreeOpen(crate::api::schema::WorktreeOpenParams {
workspace_id: Some(workspace_id),
cwd: None,
@@ -340,7 +344,11 @@ impl ClientShellState {
}),
PendingEndpointKind::WorktreeOpen,
outcome,
- );
+ ) {
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() {
+ open.opening = false;
+ }
+ }
outcome.repaint = true;
}
@@ -355,7 +363,7 @@ impl ClientShellState {
let forced = remove.force_confirmation;
remove.removing = true;
remove.error = None;
- self.push_endpoint_method_with_kind(
+ if !self.push_endpoint_method_with_kind(
crate::api::schema::Method::WorktreeRemove(crate::api::schema::WorktreeRemoveParams {
workspace_id,
force: forced,
@@ -363,7 +371,11 @@ impl ClientShellState {
}),
PendingEndpointKind::WorktreeRemove { forced },
outcome,
- );
+ ) {
+ if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() {
+ remove.removing = false;
+ }
+ }
outcome.repaint = true;
}
@@ -511,11 +523,8 @@ impl ClientShellState {
PendingEndpointKind::PrepareWorktreeCreate { .. }
| PendingEndpointKind::PrepareWorktreeOpen { .. }
| PendingEndpointKind::PrepareWorktreeRemove { .. },
- Err(error),
- ) => {
- self.endpoint_error = Some(error.message);
- true
- }
+ Err(_),
+ ) => true,
(_, Ok(_)) => {
self.endpoint_error =
Some("endpoint returned an unexpected worktree result".to_owned());
@@ -535,11 +544,8 @@ impl ClientShellState {
| PendingEndpointKind::PaneLinkActivate { .. }
| PendingEndpointKind::CopyMotion { .. }
| PendingEndpointKind::CopySearch { .. },
- Err(error),
- ) => {
- self.endpoint_error = Some(error.message);
- true
- }
+ Err(_),
+ ) => true,
}
}
}
diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs
index 7d7bcc8f..a46af2b1 100644
--- a/src/client/terminal_sessions.rs
+++ b/src/client/terminal_sessions.rs
@@ -90,10 +90,11 @@ fn connect_terminal_session_stream(
};
match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) {
- Ok(RenderEncoding::TerminalAnsi) => {}
- Ok(encoding) => {
+ Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {}
+ Ok(handshake) => {
eprintln!(
- "herdr: terminal session observe negotiated unsupported encoding {encoding:?}"
+ "herdr: terminal session observe negotiated unsupported encoding {:?}",
+ handshake.encoding
);
std::process::exit(1);
}
diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs
new file mode 100644
index 00000000..b2955ec8
--- /dev/null
+++ b/src/protocol/endpoint.rs
@@ -0,0 +1,275 @@
+//! Stable endpoint compatibility contract for client-owned shells.
+//!
+//! The endpoint generation is intentionally independent from the private
+//! binary protocol used by same-install CLI, direct-terminal, and handoff
+//! paths. Generation 1 is the compatibility floor for Local, SSH, and Cloud
+//! shell endpoints and must remain available indefinitely unless retired for a
+//! security reason. New JSON fields must be optional or have serde defaults;
+//! new enum values need an `Unknown` fallback. Unknown named controls are
+//! optional and ignored unless negotiated as part of the core.
+
+use serde::{Deserialize, Serialize};
+
+use super::{ClientShellSnapshot, ClientSurfaceSize, ServerMessage};
+
+pub const ENDPOINT_PROTOCOL_GENERATION: u32 = 1;
+pub const ENDPOINT_HELLO_KIND: &str = "endpoint.hello.v1";
+pub const ENDPOINT_WELCOME_KIND: &str = "endpoint.welcome.v1";
+pub const SNAPSHOT_CODEC_V1: &str = "shell.snapshot.v1";
+pub const ENDPOINT_SNAPSHOT_KIND: &str = SNAPSHOT_CODEC_V1;
+pub const SURFACE_CODEC_V1: &str = "shell.surface.v1";
+pub const INPUT_CODEC_V1: &str = "shell.input.semantic.v1";
+pub const BLOB_CODEC_V1: &str = "shell.blob.v1";
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EndpointClientHello {
+ pub generation: u32,
+ pub cell_width_px: u32,
+ pub cell_height_px: u32,
+ pub surface_size: ClientSurfaceSize,
+ pub pixel_mouse: bool,
+ pub direct_graphics: bool,
+ pub endpoint_keybindings: bool,
+ pub mouse_capture: bool,
+ #[serde(default)]
+ pub snapshot_codecs: Vec<String>,
+ #[serde(default)]
+ pub surface_codecs: Vec<String>,
+ #[serde(default)]
+ pub input_codecs: Vec<String>,
+ #[serde(default)]
+ pub blob_codecs: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EndpointHandshakeError {
+ pub code: String,
+ pub message: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EndpointServerWelcome {
+ pub generation: u32,
+ pub server_version: String,
+ pub snapshot_codec: String,
+ pub surface_codec: String,
+ pub input_codec: String,
+ pub blob_codec: String,
+ #[serde(default)]
+ pub methods: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub error: Option<EndpointHandshakeError>,
+}
+
+pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result<ServerMessage> {
+ Ok(ServerMessage::EndpointControl {
+ kind: ENDPOINT_SNAPSHOT_KIND.into(),
+ data: serde_json::to_string(snapshot)?,
+ })
+}
+
+impl EndpointClientHello {
+ pub fn supports_required_codecs(&self) -> bool {
+ self.snapshot_codecs
+ .iter()
+ .any(|codec| codec == SNAPSHOT_CODEC_V1)
+ && self
+ .surface_codecs
+ .iter()
+ .any(|codec| codec == SURFACE_CODEC_V1)
+ && self
+ .input_codecs
+ .iter()
+ .any(|codec| codec == INPUT_CODEC_V1)
+ && self.blob_codecs.iter().any(|codec| codec == BLOB_CODEC_V1)
+ }
+}
+
+impl EndpointServerWelcome {
+ pub fn compatible(methods: Vec<String>) -> Self {
+ Self {
+ generation: ENDPOINT_PROTOCOL_GENERATION,
+ server_version: crate::build_info::version(),
+ snapshot_codec: SNAPSHOT_CODEC_V1.into(),
+ surface_codec: SURFACE_CODEC_V1.into(),
+ input_codec: INPUT_CODEC_V1.into(),
+ blob_codec: BLOB_CODEC_V1.into(),
+ methods,
+ error: None,
+ }
+ }
+
+ pub fn incompatible(code: &str, message: impl Into<String>) -> Self {
+ Self {
+ generation: ENDPOINT_PROTOCOL_GENERATION,
+ server_version: crate::build_info::version(),
+ snapshot_codec: SNAPSHOT_CODEC_V1.into(),
+ surface_codec: SURFACE_CODEC_V1.into(),
+ input_codec: INPUT_CODEC_V1.into(),
+ blob_codec: BLOB_CODEC_V1.into(),
+ methods: Vec::new(),
+ error: Some(EndpointHandshakeError {
+ code: code.into(),
+ message: message.into(),
+ }),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn hello() -> EndpointClientHello {
+ EndpointClientHello {
+ generation: ENDPOINT_PROTOCOL_GENERATION,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: ClientSurfaceSize { cols: 80, rows: 24 },
+ pixel_mouse: true,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: true,
+ snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()],
+ surface_codecs: vec![SURFACE_CODEC_V1.into()],
+ input_codecs: vec![INPUT_CODEC_V1.into()],
+ blob_codecs: vec![BLOB_CODEC_V1.into()],
+ }
+ }
+
+ fn snapshot() -> ClientShellSnapshot {
+ ClientShellSnapshot {
+ boot_id: "boot".into(),
+ revision: 1,
+ config_diagnostic: None,
+ product_announcement: None,
+ update_available: None,
+ update_install_command: "herdr update".into(),
+ server_keybindings_toml: None,
+ latest_release_notes_available: false,
+ integration_updates_available: false,
+ worktree_directory: String::new(),
+ release_notes: None,
+ focused_workspace_id: None,
+ focused_tab_id: None,
+ focused_pane_id: None,
+ tab_bar_right: Vec::new(),
+ tab_bar_right_separator: String::new(),
+ agent_view_label: None,
+ agent_order: Vec::new(),
+ workspaces: Vec::new(),
+ tabs: Vec::new(),
+ panes: Vec::new(),
+ agents: Vec::new(),
+ commands: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn hello_ignores_future_named_fields() {
+ let mut value = serde_json::to_value(hello()).unwrap();
+ value["future_feature"] = serde_json::json!({"enabled": true});
+ let decoded: EndpointClientHello = serde_json::from_value(value).unwrap();
+ assert_eq!(decoded, hello());
+ }
+
+ #[test]
+ fn frozen_generation_one_handshake_decodes() {
+ let hello: EndpointClientHello = serde_json::from_str(include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/tests/fixtures/endpoint-hello-v1.json"
+ )))
+ .unwrap();
+ assert!(hello.supports_required_codecs());
+
+ let welcome: EndpointServerWelcome = serde_json::from_str(include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/tests/fixtures/endpoint-welcome-v1.json"
+ )))
+ .unwrap();
+ assert_eq!(welcome.generation, ENDPOINT_PROTOCOL_GENERATION);
+ assert_eq!(welcome.snapshot_codec, SNAPSHOT_CODEC_V1);
+ assert_eq!(welcome.surface_codec, SURFACE_CODEC_V1);
+ assert_eq!(welcome.input_codec, INPUT_CODEC_V1);
+ assert_eq!(welcome.blob_codec, BLOB_CODEC_V1);
+ }
+
+ #[test]
+ fn frozen_generation_one_snapshot_decodes() {
+ let snapshot: ClientShellSnapshot = serde_json::from_str(include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/tests/fixtures/endpoint-snapshot-v1.json"
+ )))
+ .unwrap();
+ assert_eq!(snapshot.boot_id, "boot-v1");
+ assert_eq!(
+ snapshot.workspaces[0].agent_status,
+ crate::api::schema::AgentStatus::Unknown
+ );
+ }
+
+ #[test]
+ fn snapshot_message_uses_named_json_control() {
+ let snapshot = snapshot();
+ let ServerMessage::EndpointControl { kind, data } = snapshot_message(&snapshot).unwrap()
+ else {
+ panic!("snapshot should use endpoint control");
+ };
+ assert_eq!(kind, ENDPOINT_SNAPSHOT_KIND);
+ let decoded: ClientShellSnapshot = serde_json::from_str(&data).unwrap();
+ assert_eq!(decoded, snapshot);
+ }
+
+ #[test]
+ fn snapshot_json_tolerates_future_fields_and_command_actions() {
+ let mut snapshot = match snapshot_message(&snapshot()).unwrap() {
+ ServerMessage::EndpointControl { data, .. } => {
+ serde_json::from_str::<serde_json::Value>(&data).unwrap()
+ }
+ _ => unreachable!(),
+ };
+ snapshot["future_projection"] = serde_json::json!({"enabled": true});
+ snapshot["commands"] = serde_json::json!([{
+ "command_id": "future",
+ "binding_label": "x",
+ "binding_labels": ["x"],
+ "action": "FutureAction",
+ "description": null
+ }]);
+
+ let decoded: ClientShellSnapshot = serde_json::from_value(snapshot).unwrap();
+ assert_eq!(
+ decoded.commands[0].action,
+ crate::protocol::ClientShellCommandAction::Unknown
+ );
+ }
+
+ #[test]
+ fn required_codecs_are_explicit() {
+ let mut value = hello();
+ assert!(value.supports_required_codecs());
+ value.snapshot_codecs.clear();
+ assert!(!value.supports_required_codecs());
+
+ let mut value = hello();
+ value.surface_codecs.clear();
+ assert!(!value.supports_required_codecs());
+
+ let mut value = hello();
+ value.input_codecs.clear();
+ assert!(!value.supports_required_codecs());
+
+ let mut value = hello();
+ value.blob_codecs.clear();
+ assert!(!value.supports_required_codecs());
+ }
+
+ #[test]
+ fn welcome_ignores_future_named_fields() {
+ let welcome = EndpointServerWelcome::compatible(vec!["pane.close".into()]);
+ let mut value = serde_json::to_value(&welcome).unwrap();
+ value["future_service"] = serde_json::json!("v2");
+ let decoded: EndpointServerWelcome = serde_json::from_value(value).unwrap();
+ assert_eq!(decoded, welcome);
+ }
+}
diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs
index 0cdeecbb..7ee6e8de 100644
--- a/src/protocol/mod.rs
+++ b/src/protocol/mod.rs
@@ -1,5 +1,6 @@
//! Shared wire protocol and presentation encoding code.
+pub mod endpoint;
pub(crate) mod render_ansi;
mod wire;
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index be084aa6..cbca829a 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -1,7 +1,11 @@
//! Wire protocol for herdr server/client communication.
//!
//! Defines the message types, framing, version negotiation, and safety
-//! constraints for the binary protocol over Unix domain sockets.
+//! constraints for the binary protocol over local sockets.
+//!
+//! `PROTOCOL_VERSION` still guards same-install direct-terminal and internal
+//! operations. Client-owned shells negotiate the independent stable endpoint
+//! contract in [`super::endpoint`].
use std::collections::HashMap;
use std::io::{self, Read, Write};
@@ -449,6 +453,9 @@ impl ClientInputEvent {
}
/// Messages sent from the client to the server over the client protocol socket.
+///
+/// Variant order is frozen for endpoint generation 1. Add compatible endpoint
+/// behavior through `EndpointControl` or advertised API methods, not new enum variants.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
/// Direct terminal handshake: announces protocol version and terminal dimensions.
@@ -597,6 +604,12 @@ pub enum ClientMessage {
/// Update this client's shell mouse-capture preference after config reload.
ClientShellMouseCapture { enabled: bool },
+
+ /// Extensible named control message for the stable client-owned endpoint protocol.
+ ///
+ /// This variant is append-only. Its bincode tag and two-string payload are part
+ /// of endpoint generation 1 and must not change.
+ EndpointControl { kind: String, data: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -860,7 +873,26 @@ impl FrameData {
}
}
-/// Initial resource projection used by the experimental client-owned shell.
+fn deserialize_client_shell_agent_status<'de, D>(
+ deserializer: D,
+) -> Result<crate::api::schema::AgentStatus, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ if !deserializer.is_human_readable() {
+ return crate::api::schema::AgentStatus::deserialize(deserializer);
+ }
+ let value = String::deserialize(deserializer)?;
+ Ok(match value.as_str() {
+ "idle" => crate::api::schema::AgentStatus::Idle,
+ "working" => crate::api::schema::AgentStatus::Working,
+ "blocked" => crate::api::schema::AgentStatus::Blocked,
+ "done" => crate::api::schema::AgentStatus::Done,
+ _ => crate::api::schema::AgentStatus::Unknown,
+ })
+}
+
+/// Initial resource projection used by the stable client-owned shell.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientShellSnapshot {
/// Changes whenever the endpoint process restarts.
@@ -921,6 +953,9 @@ pub enum ClientShellCommandAction {
Pane,
Popup,
PluginAction,
+ /// A future endpoint action kind that this client cannot execute.
+ #[serde(other)]
+ Unknown,
}
impl From<crate::config::CustomCommandAction> for ClientShellCommandAction {
@@ -934,13 +969,16 @@ impl From<crate::config::CustomCommandAction> for ClientShellCommandAction {
}
}
-impl From<ClientShellCommandAction> for crate::config::CustomCommandAction {
- fn from(action: ClientShellCommandAction) -> Self {
+impl TryFrom<ClientShellCommandAction> for crate::config::CustomCommandAction {
+ type Error = ();
+
+ fn try_from(action: ClientShellCommandAction) -> Result<Self, Self::Error> {
match action {
- ClientShellCommandAction::Shell => Self::Shell,
- ClientShellCommandAction::Pane => Self::Pane,
- ClientShellCommandAction::Popup => Self::Popup,
- ClientShellCommandAction::PluginAction => Self::PluginAction,
+ ClientShellCommandAction::Shell => Ok(Self::Shell),
+ ClientShellCommandAction::Pane => Ok(Self::Pane),
+ ClientShellCommandAction::Popup => Ok(Self::Popup),
+ ClientShellCommandAction::PluginAction => Ok(Self::PluginAction),
+ ClientShellCommandAction::Unknown => Err(()),
}
}
}
@@ -973,6 +1011,7 @@ pub struct ClientShellWorkspace {
pub tokens: Vec<(String, String)>,
pub worktree: Option<ClientShellWorktree>,
pub focused: bool,
+ #[serde(deserialize_with = "deserialize_client_shell_agent_status")]
pub agent_status: crate::api::schema::AgentStatus,
}
@@ -992,6 +1031,7 @@ pub struct ClientShellTab {
pub custom_label: bool,
pub zoomed: bool,
pub focused: bool,
+ #[serde(deserialize_with = "deserialize_client_shell_agent_status")]
pub agent_status: crate::api::schema::AgentStatus,
}
@@ -1018,6 +1058,7 @@ pub struct ClientShellAgent {
pub title: Option<String>,
pub terminal_title: Option<String>,
pub terminal_title_stripped: Option<String>,
+ #[serde(deserialize_with = "deserialize_client_shell_agent_status")]
pub agent_status: crate::api::schema::AgentStatus,
pub state_change_seq: u64,
pub state_labels: Vec<(String, String)>,
@@ -1271,6 +1312,9 @@ pub struct SemanticNotification {
}
/// Messages sent from the server to the client over the client protocol socket.
+///
+/// Variant order is frozen for endpoint generation 1. Add compatible endpoint
+/// behavior through `EndpointControl`, and ignore unrecognized named controls.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServerMessage {
/// Handshake response: server acknowledges (or rejects) the client.
@@ -1386,6 +1430,12 @@ pub enum ServerMessage {
/// Incremental terminal-cell update for a previously committed pane surface.
PaneSurfacePatch(PaneSurfacePatch),
+
+ /// Extensible named control message for the stable client-owned endpoint protocol.
+ ///
+ /// This variant is append-only. Its bincode tag and two-string payload are part
+ /// of endpoint generation 1 and must not change.
+ EndpointControl { kind: String, data: String },
}
// ---------------------------------------------------------------------------
@@ -1659,6 +1709,17 @@ pub fn check_client_version(client_version: u32) -> VersionCheck {
mod tests {
use super::*;
use ratatui::style::{Color, Modifier};
+ use sha2::{Digest, Sha256};
+
+ fn encoded_sha256(value: &impl Serialize) -> String {
+ let encoded = bincode::serde::encode_to_vec(value, bincode::config::standard()).unwrap();
+ format!("{:x}", Sha256::digest(encoded))
+ }
+
+ // These digests freeze representative generation-1 bincode payloads. A mismatch
+ // requires a new named codec; do not update a v1 digest to bless a wire change.
+ // They do not detect appended enum variants, so every type reachable from a v1
+ // payload is also append-closed.
// ---- Round-trip: ClientMessage ----
@@ -1697,6 +1758,29 @@ mod tests {
}
#[test]
+ fn endpoint_control_roundtrip() {
+ let msg = ClientMessage::EndpointControl {
+ kind: "endpoint.hello.v1".into(),
+ data: r#"{"generation":1}"#.into(),
+ };
+ 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);
+ assert_eq!(
+ bincode::serde::encode_to_vec(
+ ClientMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ },
+ bincode::config::standard(),
+ )
+ .unwrap(),
+ [20, 0, 0]
+ );
+ }
+
+ #[test]
fn client_shell_resize_roundtrip() {
let msg = ClientMessage::ClientShellResize {
cell_width_px: 8,
@@ -1708,6 +1792,10 @@ mod tests {
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "676d6376202750e72c45ff511e256b6154d3d20c0ee088d3792fa3a69d9704b9"
+ );
}
#[test]
@@ -1806,6 +1894,51 @@ mod tests {
8
);
assert_eq!(
+ tag(&ClientMessage::GraphicsTransmissionResult {
+ transfer_id: 1,
+ image_id: 2,
+ success: true,
+ }),
+ 9
+ );
+ assert_eq!(
+ tag(&ClientMessage::GraphicsTransmissionStarted {
+ transfer_id: 1,
+ image_id: 2,
+ }),
+ 10
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: ClientSurfaceSize { cols: 80, rows: 29 },
+ pixel_mouse: false,
+ }),
+ 12
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellPaneInput {
+ pane_id: "pane".into(),
+ events: Vec::new(),
+ }),
+ 13
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellPopupInput {
+ terminal_id: "popup".into(),
+ events: Vec::new(),
+ }),
+ 14
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellEndpointRequest {
+ boot_id: "boot".into(),
+ request: "{}".into(),
+ }),
+ 15
+ );
+ assert_eq!(
tag(&ClientMessage::AttachMouse {
kind: ClientMouseKind::Down(ClientMouseButton::Left),
position: ClientMousePosition::Cell { column: 10, row: 5 },
@@ -1826,6 +1959,13 @@ mod tests {
tag(&ClientMessage::ClientShellMouseCapture { enabled: true }),
19
);
+ assert_eq!(
+ tag(&ClientMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ }),
+ 20
+ );
}
#[test]
@@ -1871,6 +2011,10 @@ mod tests {
bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
.expect("decode targeted semantic input");
assert_eq!(decoded, message);
+ assert_eq!(
+ encoded_sha256(&message),
+ "f558384bb53dfd2baf1fa72e1709d88be6891da79e51f88513905afc085065e6"
+ );
let ClientMessage::ClientShellPaneInput { events, .. } = decoded else {
panic!("expected targeted semantic input");
};
@@ -1977,6 +2121,10 @@ mod tests {
bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
.expect("decode endpoint request");
assert_eq!(decoded, request);
+ assert_eq!(
+ encoded_sha256(&request),
+ "de5693585a01f6b0d5ee07c51b6ddf79ee9f67dbf183255822d31f35210f5ffb"
+ );
let response = ServerMessage::ClientShellEndpointResponseChunk {
boot_id: "boot-a".into(),
@@ -1990,7 +2138,12 @@ mod tests {
bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
.expect("decode endpoint response");
assert_eq!(decoded, response);
+ assert_eq!(
+ encoded_sha256(&response),
+ "bc14dbb5263d3097fe6d3e70a4b6d71aa9c2fa4ae3206d692a7182512bffdd1d"
+ );
}
+
#[test]
fn client_clipboard_image_roundtrip() {
let msg = ClientMessage::ClipboardImage {
@@ -2002,6 +2155,10 @@ mod tests {
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "1c02110be0671faf318b3f4d8f507748d5a0a98cd474832669c5290d97ef19ab"
+ );
}
#[test]
@@ -2203,6 +2360,10 @@ mod tests {
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "7c016f7b21ddb5ac79212cf65a968b93eb292b5305b941263e89ffaa40158ee3"
+ );
match decoded {
ServerMessage::PaneSurface(surface) => {
assert_eq!(surface.frame.cells[2].hyperlink, Some(0));
@@ -2246,6 +2407,162 @@ mod tests {
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(decoded, msg);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "0814b99a1dc6eaf7918424aa416c066509cbfb73b72344a809c27cde78cb6dbd"
+ );
+ }
+
+ #[test]
+ fn client_shell_graphics_payload_codec_is_frozen() {
+ let key = SurfaceGraphicsAssetKey {
+ source: SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ image_id: 7,
+ },
+ image_width: 2,
+ image_height: 1,
+ format: SurfaceGraphicsFormat::Rgba,
+ data_len: 8,
+ data_fingerprint: 42,
+ };
+ let message = ServerMessage::PaneSurface(PaneSurfaceFrame {
+ boot_id: "boot-1".into(),
+ projection_revision: 2,
+ surface_revision: 3,
+ frame: FrameData {
+ cells: Vec::new(),
+ width: 0,
+ height: 0,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: Vec::new(),
+ splits: Vec::new(),
+ popup: None,
+ graphics: SurfaceGraphicsScene {
+ assets: vec![SurfaceGraphicsAsset {
+ key: key.clone(),
+ data: vec![255, 0, 0, 255, 0, 255, 0, 255],
+ }],
+ placements: vec![SurfaceGraphicsPlacement {
+ asset: key,
+ logical_placement_id: 9,
+ x: 1,
+ y: 2,
+ cols: 2,
+ rows: 1,
+ source_x: 0,
+ source_y: 0,
+ source_width: 2,
+ source_height: 1,
+ x_offset: 0,
+ y_offset: 0,
+ z: -1,
+ scrollback_offset: 0,
+ }],
+ retained_assets: Vec::new(),
+ },
+ });
+
+ assert_eq!(
+ encoded_sha256(&message),
+ "49c4efec0f1456c8ca4112ddf6ead1ab75d0224007576c2ccc18c3fca55a69f0"
+ );
+ }
+
+ #[test]
+ fn server_endpoint_control_tag_is_frozen() {
+ let message = ServerMessage::EndpointControl {
+ kind: "endpoint.welcome.v1".into(),
+ data: r#"{"generation":1}"#.into(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap();
+ assert_eq!(encoded.first(), Some(&20));
+ assert_eq!(
+ bincode::serde::encode_to_vec(
+ ServerMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ },
+ bincode::config::standard(),
+ )
+ .unwrap(),
+ [20, 0, 0]
+ );
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(decoded, message);
+ }
+
+ #[test]
+ fn client_shell_server_message_tags_are_frozen() {
+ fn tag(message: &ServerMessage) -> u8 {
+ *bincode::serde::encode_to_vec(message, bincode::config::standard())
+ .unwrap()
+ .first()
+ .expect("encoded server message should include enum tag")
+ }
+
+ let empty_frame = || PaneSurfaceFrame {
+ boot_id: "boot".into(),
+ projection_revision: 1,
+ surface_revision: 1,
+ frame: FrameData {
+ cells: Vec::new(),
+ width: 0,
+ height: 0,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: Vec::new(),
+ splits: Vec::new(),
+ popup: None,
+ graphics: SurfaceGraphicsScene::default(),
+ };
+ assert_eq!(tag(&ServerMessage::PaneSurface(empty_frame())), 13);
+ assert_eq!(
+ tag(&ServerMessage::ClientShellError {
+ message: String::new(),
+ }),
+ 15
+ );
+ assert_eq!(
+ tag(&ServerMessage::ClientShellKeyboardReportAll { enabled: false }),
+ 17
+ );
+ assert_eq!(
+ tag(&ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id: String::new(),
+ request_id: String::new(),
+ final_chunk: true,
+ data: Vec::new(),
+ }),
+ 18
+ );
+ assert_eq!(
+ tag(&ServerMessage::PaneSurfacePatch(PaneSurfacePatch {
+ boot_id: String::new(),
+ projection_revision: 0,
+ base_surface_revision: 0,
+ surface_revision: 0,
+ rows: Vec::new(),
+ panes: Vec::new(),
+ cursor: None,
+ })),
+ 19
+ );
+ assert_eq!(
+ tag(&ServerMessage::EndpointControl {
+ kind: String::new(),
+ data: String::new(),
+ }),
+ 20
+ );
}
#[test]
@@ -2411,6 +2728,10 @@ mod tests {
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
+ assert_eq!(
+ encoded_sha256(&msg),
+ "28a420f92e0e05e6760a8c140baf307c360c6d1b1aa68027481b324f87e22c44"
+ );
}
#[test]
diff --git a/src/remote/attach.rs b/src/remote/attach.rs
index 525fc7ce..641aad00 100644
--- a/src/remote/attach.rs
+++ b/src/remote/attach.rs
@@ -182,7 +182,6 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
ensure_remote_server_ready(
&remote_ssh,
&prepared_remote.remote_herdr,
- prepared_remote.installed_or_replaced,
prepared_remote.stop_after_install_approved,
remote.live_handoff,
)?;
@@ -395,7 +394,6 @@ struct RemoteReleaseAsset {
struct PreparedRemoteHerdr {
remote_herdr: RemoteHerdr,
- installed_or_replaced: bool,
stop_after_install_approved: bool,
}
@@ -653,18 +651,16 @@ fn prepare_remote_herdr(
if override_binary.is_none() {
for candidate in &remote_binary_candidates {
- if remote_binary_matches(ssh, candidate).unwrap_or(false) {
+ if remote_binary_supports_endpoint(ssh, candidate).unwrap_or(false) {
return Ok(PreparedRemoteHerdr {
remote_herdr: candidate.clone(),
- installed_or_replaced: false,
stop_after_install_approved: false,
});
}
}
- if remote_binary_matches(ssh, &remote_herdr)? {
+ if remote_binary_supports_endpoint(ssh, &remote_herdr)? {
return Ok(PreparedRemoteHerdr {
remote_herdr,
- installed_or_replaced: false,
stop_after_install_approved: false,
});
}
@@ -692,18 +688,17 @@ fn prepare_remote_herdr(
source.cleanup();
install_result?;
- if !remote_binary_matches(ssh, &remote_herdr)? {
+ if !remote_binary_supports_endpoint(ssh, &remote_herdr)? {
return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it did not report version {}",
+ "installed remote herdr at {}, but it does not support endpoint generation {}",
remote_herdr.shell_path,
- current_version()
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
)));
}
warn_if_remote_bin_not_on_path(ssh)?;
Ok(PreparedRemoteHerdr {
remote_herdr,
- installed_or_replaced: true,
stop_after_install_approved,
})
}
@@ -864,24 +859,30 @@ fn is_mise_shim_path(path: &str) -> bool {
path.ends_with("/mise/shims/herdr")
}
-fn remote_binary_matches(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
+fn remote_client_status(
+ ssh: &RemoteSsh,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<Option<RemoteClientStatusJson>> {
let command = format!(
- "test -x {0} && {0} --version && {0} status client --json",
+ "test -x {0} && {0} status client --json",
remote_herdr.shell_path
);
let output = ssh.sh_output(&command)?;
if !output.status.success() {
- return Ok(false);
+ return Ok(None);
}
+ Ok(parse_client_status_json(&String::from_utf8_lossy(
+ &output.stdout,
+ )))
+}
- let stdout = String::from_utf8_lossy(&output.stdout);
- let mut lines = stdout.lines();
- let version = lines.next().unwrap_or_default().trim();
- let status = lines.next().unwrap_or_default();
- Ok(version == format!("herdr {}", current_version())
- && parse_client_status_json(status)
- .map(|status| status.protocol == CURRENT_PROTOCOL)
- .unwrap_or(false))
+fn remote_binary_supports_endpoint(
+ ssh: &RemoteSsh,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<bool> {
+ Ok(remote_client_status(ssh, remote_herdr)?
+ .and_then(|status| status.endpoint_protocol_generation)
+ == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION))
}
fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
@@ -984,7 +985,7 @@ fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
enum RemoteServerStatus {
Running {
version: Option<String>,
- protocol: Option<u32>,
+ endpoint_protocol_generation: Option<u32>,
live_handoff: bool,
detached_server_daemon: bool,
},
@@ -993,10 +994,8 @@ enum RemoteServerStatus {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoteServerRestartReason {
- ProtocolMismatch,
+ EndpointProtocolMissing,
DaemonDetachMissing,
- BinaryUpdated,
- VersionMismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1009,14 +1008,13 @@ enum RemoteInstallRunningServerPlan {
fn ensure_remote_server_ready(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
- remote_binary_changed: bool,
stop_after_install_approved: bool,
live_handoff_enabled: bool,
) -> io::Result<()> {
let status = remote_server_status(ssh, remote_herdr)?;
let RemoteServerStatus::Running {
version,
- protocol,
+ endpoint_protocol_generation,
live_handoff,
detached_server_daemon,
} = status
@@ -1024,12 +1022,9 @@ fn ensure_remote_server_ready(
return Ok(());
};
- let Some(reason) = remote_server_restart_reason(
- version.as_deref(),
- protocol,
- detached_server_daemon,
- remote_binary_changed,
- ) else {
+ let Some(reason) =
+ remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon)
+ else {
return Ok(());
};
@@ -1048,30 +1043,23 @@ fn ensure_remote_server_ready(
return Ok(());
}
- if confirm_remote_server_stop(ssh.target(), version.as_deref(), protocol, reason)? {
+ if confirm_remote_server_stop(ssh.target(), version.as_deref(), reason)? {
stop_remote_server(ssh, remote_herdr)?;
}
Ok(())
}
fn remote_server_restart_reason(
- version: Option<&str>,
- protocol: Option<u32>,
+ endpoint_protocol_generation: Option<u32>,
detached_server_daemon: bool,
- remote_binary_changed: bool,
) -> Option<RemoteServerRestartReason> {
- if protocol != Some(CURRENT_PROTOCOL) {
- return Some(RemoteServerRestartReason::ProtocolMismatch);
+ if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
+ {
+ return Some(RemoteServerRestartReason::EndpointProtocolMissing);
}
if !detached_server_daemon {
return Some(RemoteServerRestartReason::DaemonDetachMissing);
}
- if version != Some(current_version().as_str()) {
- return Some(RemoteServerRestartReason::VersionMismatch);
- }
- if remote_binary_changed {
- return Some(RemoteServerRestartReason::BinaryUpdated);
- }
None
}
@@ -1109,7 +1097,7 @@ fn confirm_remote_install_with_running_server(
};
let RemoteServerStatus::Running {
version,
- protocol,
+ endpoint_protocol_generation,
live_handoff,
detached_server_daemon,
} = &status
@@ -1117,10 +1105,8 @@ fn confirm_remote_install_with_running_server(
return Ok(false);
};
let plan = remote_install_running_server_plan(
- version.as_deref(),
- *protocol,
+ *endpoint_protocol_generation,
*detached_server_daemon,
- true,
*live_handoff,
live_handoff_enabled,
);
@@ -1187,19 +1173,14 @@ fn confirm_remote_install_with_running_server(
}
fn remote_install_running_server_plan(
- version: Option<&str>,
- protocol: Option<u32>,
+ endpoint_protocol_generation: Option<u32>,
detached_server_daemon: bool,
- remote_binary_changed: bool,
live_handoff: bool,
live_handoff_enabled: bool,
) -> RemoteInstallRunningServerPlan {
- let Some(reason) = remote_server_restart_reason(
- version,
- protocol,
- detached_server_daemon,
- remote_binary_changed,
- ) else {
+ let Some(reason) =
+ remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon)
+ else {
return RemoteInstallRunningServerPlan::KeepRunning;
};
@@ -1226,14 +1207,18 @@ fn remote_server_status(
#[derive(Debug, Deserialize)]
struct RemoteClientStatusJson {
- protocol: u32,
+ #[serde(default)]
+ version: Option<String>,
+ #[serde(default)]
+ protocol: Option<u32>,
+ #[serde(default)]
+ endpoint_protocol_generation: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RemoteServerStatusJson {
running: bool,
version: Option<String>,
- protocol: Option<u32>,
capabilities: Option<RemoteServerCapabilitiesJson>,
}
@@ -1242,10 +1227,21 @@ struct RemoteServerCapabilitiesJson {
live_handoff: bool,
#[serde(default)]
detached_server_daemon: bool,
+ #[serde(default)]
+ endpoint_protocol_generation: Option<u32>,
}
fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
- serde_json::from_str(status).ok()
+ status
+ .lines()
+ .rev()
+ .filter(|line| !line.trim().is_empty())
+ .filter_map(|line| serde_json::from_str::<RemoteClientStatusJson>(line).ok())
+ .find(|status| {
+ status.version.is_some()
+ || status.protocol.is_some()
+ || status.endpoint_protocol_generation.is_some()
+ })
}
fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> {
@@ -1262,7 +1258,9 @@ fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatu
Ok(RemoteServerStatus::Running {
version: parsed.version,
- protocol: parsed.protocol,
+ endpoint_protocol_generation: capabilities
+ .as_ref()
+ .and_then(|capabilities| capabilities.endpoint_protocol_generation),
live_handoff: capabilities
.as_ref()
.is_some_and(|capabilities| capabilities.live_handoff),
@@ -1275,13 +1273,12 @@ fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatu
fn confirm_remote_server_stop(
target: &str,
version: Option<&str>,
- _protocol: Option<u32>,
reason: RemoteServerRestartReason,
) -> io::Result<bool> {
if !io::stdin().is_terminal() {
- if reason == RemoteServerRestartReason::ProtocolMismatch {
+ if reason == RemoteServerRestartReason::EndpointProtocolMissing {
return Err(io::Error::other(format!(
- "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it"
+ "remote herdr server on {target} needs one final update before this client can attach; run from an interactive terminal to approve updating it"
)));
}
@@ -1299,28 +1296,20 @@ fn confirm_remote_server_stop(
eprintln!();
match reason {
- RemoteServerRestartReason::ProtocolMismatch => {
- eprintln!("the remote server must stop before this client can attach.");
- }
- RemoteServerRestartReason::DaemonDetachMissing => {
+ RemoteServerRestartReason::EndpointProtocolMissing => {
eprintln!(
- "the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client."
+ "the remote server predates Herdr's stable endpoint protocol and must update before this client can attach."
);
}
- RemoteServerRestartReason::BinaryUpdated => {
- eprintln!(
- "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary."
- );
- }
- RemoteServerRestartReason::VersionMismatch => {
+ RemoteServerRestartReason::DaemonDetachMissing => {
eprintln!(
- "the remote server is still running a different herdr version. restart it so it uses the prepared binary."
+ "the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client."
);
}
}
- let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch {
- "stop the remote server and continue attaching? [Y/n] "
+ let prompt = if reason == RemoteServerRestartReason::EndpointProtocolMissing {
+ "update the remote server and continue attaching? [Y/n] "
} else {
"restart the remote server now? [y/N] "
};
@@ -1333,10 +1322,10 @@ fn confirm_remote_server_stop(
if answer == "y" || answer == "yes" {
return Ok(true);
}
- if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch {
+ if answer.is_empty() && reason == RemoteServerRestartReason::EndpointProtocolMissing {
return Ok(true);
}
- if reason == RemoteServerRestartReason::ProtocolMismatch {
+ if reason == RemoteServerRestartReason::EndpointProtocolMissing {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr server stop cancelled",
@@ -1346,14 +1335,25 @@ fn confirm_remote_server_stop(
Ok(false)
}
-fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let command = format!(
+fn remote_live_handoff_command(remote_herdr: &RemoteHerdr, protocol: u32, version: &str) -> String {
+ format!(
"{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
- remote_herdr.shell_path,
- remote_herdr.shell_path,
- CURRENT_PROTOCOL,
- current_version()
- );
+ remote_herdr.shell_path, remote_herdr.shell_path, protocol, version
+ )
+}
+
+fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let status = remote_client_status(ssh, remote_herdr)?.ok_or_else(|| {
+ io::Error::other("could not inspect the prepared remote herdr binary before live handoff")
+ })?;
+ let protocol = status.protocol.ok_or_else(|| {
+ io::Error::other("prepared remote herdr did not report its private protocol")
+ })?;
+ let version = status
+ .version
+ .filter(|version| !version.is_empty())
+ .ok_or_else(|| io::Error::other("prepared remote herdr did not report its version"))?;
+ let command = remote_live_handoff_command(remote_herdr, protocol, &version);
let output = ssh.sh_output(&command)?;
if !output.status.success() {
return Err(command_failed("remote server live handoff failed", &output));
@@ -2896,25 +2896,29 @@ mod tests {
}
#[test]
- fn parse_client_status_json_reads_protocol() {
- assert_eq!(
- parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#)
- .map(|status| status.protocol),
- Some(8)
+ fn parse_client_status_json_reads_last_json_record() {
+ let status = parse_client_status_json(
+ "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1}\n{\"wrapper\":true}\n",
+ )
+ .unwrap();
+ assert_eq!(status.version.as_deref(), Some("0.8.0"));
+ assert_eq!(status.protocol, Some(20));
+ assert_eq!(status.endpoint_protocol_generation, Some(1));
+ assert!(
+ parse_client_status_json(r#"{"endpoint_protocol_generation":"unknown"}"#).is_none()
);
- assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none());
}
#[test]
fn parse_remote_server_status_json_reads_running_server() {
assert_eq!(
parse_remote_server_status_json(
- r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true}}"#
+ r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1}}"#
)
.unwrap(),
RemoteServerStatus::Running {
version: Some("0.6.0".into()),
- protocol: Some(8),
+ endpoint_protocol_generation: Some(1),
live_handoff: true,
detached_server_daemon: true
}
@@ -2930,7 +2934,7 @@ mod tests {
.unwrap(),
RemoteServerStatus::Running {
version: Some("0.6.0".into()),
- protocol: Some(8),
+ endpoint_protocol_generation: None,
live_handoff: false,
detached_server_daemon: false
}
@@ -3105,21 +3109,19 @@ mod tests {
}
#[test]
- fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
+ fn remote_server_restart_reason_requires_one_update_for_pre_floor_server() {
assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(0), true, false),
- Some(RemoteServerRestartReason::ProtocolMismatch)
+ remote_server_restart_reason(None, true),
+ Some(RemoteServerRestartReason::EndpointProtocolMissing)
);
}
#[test]
- fn remote_server_restart_reason_allows_unchanged_compatible_server() {
+ fn remote_server_restart_reason_allows_compatible_server() {
assert_eq!(
remote_server_restart_reason(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
- true,
- false
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
+ true
),
None
);
@@ -3129,9 +3131,7 @@ mod tests {
fn remote_server_restart_reason_requires_restart_for_old_daemon() {
assert_eq!(
remote_server_restart_reason(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
- false,
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
false
),
Some(RemoteServerRestartReason::DaemonDetachMissing)
@@ -3139,52 +3139,12 @@ mod tests {
}
#[test]
- fn remote_server_restart_reason_requires_restart_after_helper_update() {
- assert_eq!(
- remote_server_restart_reason(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
- true,
- true
- ),
- Some(RemoteServerRestartReason::BinaryUpdated)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_offers_restart_for_version_mismatch() {
- assert_eq!(
- remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), true, false),
- Some(RemoteServerRestartReason::VersionMismatch)
- );
- assert_eq!(
- remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), true, false),
- Some(RemoteServerRestartReason::VersionMismatch)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_allows_current_server() {
- assert_eq!(
- remote_server_restart_reason(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
- true,
- false
- ),
- None
- );
- }
-
- #[test]
fn remote_install_plan_keeps_compatible_running_server() {
assert_eq!(
remote_install_running_server_plan(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
true,
false,
- false,
false
),
RemoteInstallRunningServerPlan::KeepRunning
@@ -3195,10 +3155,8 @@ mod tests {
fn remote_install_plan_requires_stop_for_old_daemon() {
assert_eq!(
remote_install_running_server_plan(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
+ Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
false,
- true,
false,
false
),
@@ -3209,53 +3167,39 @@ mod tests {
}
#[test]
- fn remote_install_plan_requires_stop_after_helper_update() {
- assert_eq!(
- remote_install_running_server_plan(
- Some(&current_version()),
- Some(CURRENT_PROTOCOL),
- true,
- true,
- false,
- false
- ),
- RemoteInstallRunningServerPlan::StopRequired(RemoteServerRestartReason::BinaryUpdated)
- );
- }
-
- #[test]
- fn remote_install_plan_requires_stop_for_incompatible_running_server() {
+ fn remote_install_plan_requires_stop_for_pre_floor_server() {
assert_eq!(
- remote_install_running_server_plan(
- Some("0.0.0"),
- Some(CURRENT_PROTOCOL),
- true,
- true,
- false,
- false
- ),
+ remote_install_running_server_plan(None, true, false, false),
RemoteInstallRunningServerPlan::StopRequired(
- RemoteServerRestartReason::VersionMismatch
+ RemoteServerRestartReason::EndpointProtocolMissing
)
);
}
#[test]
- fn remote_install_plan_uses_live_handoff_for_incompatible_running_server() {
+ fn remote_install_plan_uses_live_handoff_for_pre_floor_server() {
assert_eq!(
- remote_install_running_server_plan(
- Some("0.0.0"),
- Some(CURRENT_PROTOCOL),
- true,
- true,
- true,
- true
- ),
+ remote_install_running_server_plan(None, true, true, true),
RemoteInstallRunningServerPlan::LiveHandoff
);
}
#[test]
+ fn remote_live_handoff_uses_prepared_binary_identity() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let command = remote_live_handoff_command(&remote_herdr, 19, "0.7.9");
+ assert!(command.contains("--expected-protocol 19"));
+ assert!(command.contains("--expected-version 0.7.9"));
+ assert!(!command.contains(&format!(
+ "--expected-protocol {CURRENT_PROTOCOL} --expected-version {}",
+ current_version()
+ )));
+ }
+
+ #[test]
fn install_source_description_uses_override_binary() {
let platform = RemotePlatform {
os: "linux",
diff --git a/src/remote/host_unix.rs b/src/remote/host_unix.rs
index e04b0e00..48b20e0b 100644
--- a/src/remote/host_unix.rs
+++ b/src/remote/host_unix.rs
@@ -56,11 +56,16 @@ fn ensure_remote_server_running() -> io::Result<()> {
Duration::from_millis(500),
)?
.ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
- if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) {
+ if status
+ .capabilities
+ .as_ref()
+ .and_then(|capabilities| capabilities.endpoint_protocol_generation)
+ == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
+ {
return Ok(());
}
return Err(io::Error::other(
- "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it",
+ "remote herdr server needs one final update before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve it",
));
}
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index 1813ebd9..bd364a70 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -150,19 +150,22 @@ fn validate_running_server_compatibility() -> io::Result<()> {
)));
};
- if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) {
+ let endpoint_generation = status
+ .capabilities
+ .as_ref()
+ .and_then(|capabilities| capabilities.endpoint_protocol_generation);
+ if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) {
return Ok(());
}
Err(io::Error::other(format!(
- "Herdr was updated, but this session is still running the old server.\n\nserver: v{} protocol {}\nclient: v{} protocol {}\n\n{}",
+ "This session predates Herdr's stable endpoint protocol and needs one final server update.\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}",
status.version.as_deref().unwrap_or("unknown"),
- status
- .protocol
+ endpoint_generation
.map(|value| value.to_string())
- .unwrap_or_else(|| "unknown".to_string()),
+ .unwrap_or_else(|| "unavailable".to_string()),
crate::build_info::version(),
- crate::protocol::PROTOCOL_VERSION,
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
crate::session::active_restart_after_update_guidance()
)))
}
diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs
index eeb49c7e..86243e31 100644
--- a/src/server/client_commands.rs
+++ b/src/server/client_commands.rs
@@ -12,47 +12,56 @@ pub(crate) const MAX_ENDPOINT_BOOT_ID_BYTES: usize = 128;
pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128;
const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024;
+const CLIENT_SHELL_METHODS: &[&str] = &[
+ "command.invoke",
+ "integration.install",
+ "integration.list",
+ "layout.set_split_ratio",
+ "pane.close",
+ "pane.copy_motion",
+ "pane.copy_search",
+ "pane.edit_scrollback",
+ "pane.focus",
+ "pane.focus_direction",
+ "pane.input.set",
+ "pane.link.activate",
+ "pane.rename",
+ "pane.resize",
+ "pane.scroll",
+ "pane.selection.read",
+ "pane.split",
+ "pane.swap",
+ "pane.zoom",
+ "product_announcement.dismiss",
+ "release_notes.dismiss",
+ "server.reload_config",
+ "tab.close",
+ "tab.create",
+ "tab.focus",
+ "tab.move",
+ "tab.rename",
+ "workspace.close",
+ "workspace.create",
+ "workspace.focus",
+ "workspace.move",
+ "workspace.move_block",
+ "workspace.rename",
+ "worktree.create",
+ "worktree.list",
+ "worktree.open",
+ "worktree.remove",
+];
+
+pub(crate) fn supported_client_shell_method_names() -> &'static [&'static str] {
+ CLIENT_SHELL_METHODS
+}
+
+pub(crate) fn supports_client_shell_method_name(method: &str) -> bool {
+ CLIENT_SHELL_METHODS.contains(&method)
+}
+
pub(crate) fn supports_client_shell_method(method: &Method) -> bool {
- matches!(
- method,
- Method::CommandInvoke(_)
- | Method::IntegrationInstall(_)
- | Method::IntegrationList(_)
- | Method::LayoutSetSplitRatio(_)
- | Method::PaneClose(_)
- | Method::PaneCopyMotion(_)
- | Method::PaneCopySearch(_)
- | Method::PaneEditScrollback(_)
- | Method::PaneFocus(_)
- | Method::PaneFocusDirection(_)
- | Method::PaneInputSet(_)
- | Method::PaneLinkActivate(_)
- | Method::PaneRename(_)
- | Method::PaneResize(_)
- | Method::PaneScroll(_)
- | Method::PaneSelectionRead(_)
- | Method::PaneSplit(_)
- | Method::PaneSwap(_)
- | Method::PaneZoom(_)
- | Method::ProductAnnouncementDismiss(_)
- | Method::ReleaseNotesDismiss(_)
- | Method::ServerReloadConfig(_)
- | Method::TabClose(_)
- | Method::TabCreate(_)
- | Method::TabFocus(_)
- | Method::TabMove(_)
- | Method::TabRename(_)
- | Method::WorkspaceClose(_)
- | Method::WorkspaceCreate(_)
- | Method::WorkspaceFocus(_)
- | Method::WorkspaceMove(_)
- | Method::WorkspaceMoveBlock(_)
- | Method::WorkspaceRename(_)
- | Method::WorktreeCreate(_)
- | Method::WorktreeList(_)
- | Method::WorktreeOpen(_)
- | Method::WorktreeRemove(_)
- )
+ supports_client_shell_method_name(crate::api::api_method_name(method))
}
pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) -> String {
@@ -134,8 +143,159 @@ pub(crate) fn spawn_response_waiter(
#[cfg(test)]
mod tests {
+ use std::collections::{BTreeMap, BTreeSet};
+
+ use sha2::{Digest, Sha256};
+
use super::*;
+ fn collect_schema_refs(value: &serde_json::Value, refs: &mut BTreeSet<String>) {
+ match value {
+ serde_json::Value::Object(object) => {
+ if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) {
+ if let Some(name) = reference.rsplit('/').next() {
+ refs.insert(name.to_owned());
+ }
+ }
+ for value in object.values() {
+ collect_schema_refs(value, refs);
+ }
+ }
+ serde_json::Value::Array(values) => {
+ for value in values {
+ collect_schema_refs(value, refs);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn normalized_wire_schema(value: &serde_json::Value) -> serde_json::Value {
+ match value {
+ serde_json::Value::Object(object) => serde_json::Value::Object(
+ object
+ .iter()
+ .filter(|(key, _)| {
+ !matches!(
+ key.as_str(),
+ "description" | "examples" | "readOnly" | "title" | "writeOnly"
+ )
+ })
+ .map(|(key, value)| (key.clone(), normalized_wire_schema(value)))
+ .collect(),
+ ),
+ serde_json::Value::Array(values) => {
+ serde_json::Value::Array(values.iter().map(normalized_wire_schema).collect())
+ }
+ _ => value.clone(),
+ }
+ }
+
+ fn endpoint_method_shape_digests() -> BTreeMap<String, String> {
+ let schema = serde_json::to_value(schemars::schema_for!(crate::api::schema::Request))
+ .expect("request schema");
+ let definitions = schema
+ .get("$defs")
+ .and_then(serde_json::Value::as_object)
+ .expect("request definitions");
+ let branches = schema
+ .get("oneOf")
+ .and_then(serde_json::Value::as_array)
+ .expect("request method branches");
+ let mut digests = BTreeMap::new();
+
+ for method in CLIENT_SHELL_METHODS {
+ let branch = branches
+ .iter()
+ .find(|branch| {
+ branch
+ .pointer("/properties/method/const")
+ .and_then(serde_json::Value::as_str)
+ == Some(method)
+ })
+ .unwrap_or_else(|| panic!("missing request schema branch for {method}"));
+ let mut referenced_names = BTreeSet::new();
+ collect_schema_refs(branch, &mut referenced_names);
+ let mut visited_names = BTreeSet::new();
+ let mut selected_definitions = serde_json::Map::new();
+ while let Some(name) = referenced_names.pop_first() {
+ if !visited_names.insert(name.clone()) {
+ continue;
+ }
+ let definition = definitions
+ .get(&name)
+ .unwrap_or_else(|| panic!("missing schema definition {name} for {method}"));
+ collect_schema_refs(definition, &mut referenced_names);
+ selected_definitions.insert(name, normalized_wire_schema(definition));
+ }
+ let shape = serde_json::json!({
+ "request": normalized_wire_schema(branch),
+ "definitions": selected_definitions,
+ });
+ let bytes = serde_json::to_vec(&shape).expect("method shape json");
+ digests.insert(method.to_string(), format!("{:x}", Sha256::digest(bytes)));
+ }
+
+ digests
+ }
+
+ #[test]
+ fn advertised_client_shell_method_shapes_stay_at_the_v1_contract() {
+ let expected: BTreeMap<String, String> = serde_json::from_str(include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/tests/fixtures/endpoint-method-shapes-v1.json"
+ )))
+ .expect("endpoint method shape fixture");
+ let actual = endpoint_method_shape_digests();
+
+ assert_eq!(
+ actual,
+ expected,
+ "an existing endpoint method changed shape; add load-bearing behavior as a new advertised method or explicitly gate new fields"
+ );
+ }
+
+ #[test]
+ fn advertised_client_shell_methods_are_sorted_unique_and_in_schema() {
+ assert!(CLIENT_SHELL_METHODS
+ .windows(2)
+ .all(|pair| pair[0] < pair[1]));
+
+ fn collect_method_constants(value: &serde_json::Value, methods: &mut Vec<String>) {
+ match value {
+ serde_json::Value::Object(object) => {
+ if let Some(method) = object
+ .get("const")
+ .and_then(serde_json::Value::as_str)
+ .filter(|value| value.contains('.'))
+ {
+ methods.push(method.to_owned());
+ }
+ for value in object.values() {
+ collect_method_constants(value, methods);
+ }
+ }
+ serde_json::Value::Array(values) => {
+ for value in values {
+ collect_method_constants(value, methods);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ let schema = serde_json::to_value(schemars::schema_for!(crate::api::schema::Request))
+ .expect("request schema");
+ let mut schema_methods = Vec::new();
+ collect_method_constants(&schema, &mut schema_methods);
+ for method in CLIENT_SHELL_METHODS {
+ assert!(
+ schema_methods.iter().any(|candidate| candidate == method),
+ "advertised endpoint method {method:?} is absent from the request schema"
+ );
+ }
+ }
+
#[test]
fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() {
assert!(supports_client_shell_method(&Method::ServerReloadConfig(
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);
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 6bae0a98..1cf8ebd4 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -1934,12 +1934,17 @@ impl HeadlessServer {
connection.shell_projection_revision,
config_diagnostic,
);
- connection.shell_snapshot = Some(snapshot.clone());
+ let snapshot_message = match crate::protocol::endpoint::snapshot_message(&snapshot)
+ {
+ Ok(message) => message,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to encode endpoint snapshot");
+ return false;
+ }
+ };
+ connection.shell_snapshot = Some(snapshot);
self.clients.insert(client_id, connection);
- self.send_to_client(
- client_id,
- ServerMessage::ClientShellSnapshot(Box::new(snapshot)),
- );
+ self.send_to_client(client_id, snapshot_message);
self.foreground_client_id = Some(client_id);
if first_app_client {
self.app.mark_git_status_refresh_due(Instant::now());
@@ -2374,6 +2379,26 @@ impl HeadlessServer {
}
foreground_changed || runtime.scroll_metrics() != scroll_before
}
+ ServerEvent::ClientShellEndpointRequestError {
+ client_id,
+ boot_id,
+ request_id,
+ code,
+ message,
+ } => {
+ let Some(client) = self.clients.get(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell) {
+ self.remove_client_and_resize_if_needed(client_id);
+ return true;
+ }
+ let message = crate::server::client_commands::error_message(
+ boot_id, request_id, code, message,
+ );
+ self.send_to_client(client_id, message);
+ false
+ }
ServerEvent::ClientShellEndpointRequest {
client_id,
boot_id,
diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs
index cab79de2..19581abc 100644
--- a/src/server/headless/render.rs
+++ b/src/server/headless/render.rs
@@ -387,11 +387,18 @@ impl HeadlessServer {
client.shell_projection_revision =
client.shell_projection_revision.saturating_add(1);
candidate.revision = client.shell_projection_revision;
- let message = ServerMessage::ClientShellSnapshot(Box::new(candidate.clone()));
+ let message = match crate::protocol::endpoint::snapshot_message(&candidate) {
+ Ok(message) => message,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to encode endpoint snapshot");
+ broken_clients.push(client_id);
+ continue;
+ }
+ };
let framed = match Self::frame_server_message(&message) {
Ok(framed) => framed,
Err(err) => {
- warn!(client_id, err = %err, "failed to frame client shell replacement");
+ warn!(client_id, err = %err, "failed to frame endpoint snapshot");
broken_clients.push(client_id);
continue;
}
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index f5c33f6a..93c7420c 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -3,6 +3,14 @@ use super::*;
#[path = "pane_graphics.rs"]
mod pane_graphics_tests;
+fn client_shell_snapshot(message: ServerMessage) -> Box<crate::protocol::ClientShellSnapshot> {
+ let ServerMessage::EndpointControl { kind, data } = message else {
+ panic!("expected client shell snapshot");
+ };
+ assert_eq!(kind, crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND);
+ Box::new(serde_json::from_str(&data).expect("decode client shell snapshot"))
+}
+
fn test_headless_server() -> HeadlessServer {
test_headless_server_with_event_hub(api::EventHub::default())
}
@@ -671,6 +679,33 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() {
shutdown_test_runtimes(&mut server);
}
+#[test]
+fn terminal_client_endpoint_request_error_removes_client() {
+ let mut server = test_headless_server();
+ let (writer, _control_rx, _render_rx) = test_client_writer();
+ let client_id = 42;
+ assert!(!server.handle_server_event(ServerEvent::ClientConnected {
+ client_id,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ writer,
+ }));
+
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequestError {
+ client_id,
+ boot_id: "boot".into(),
+ request_id: "request".into(),
+ code: "unsupported_method",
+ message: "unsupported".into(),
+ })
+ );
+ assert!(!server.clients.contains_key(&client_id));
+}
+
#[tokio::test]
async fn client_shell_receives_metadata_then_shell_free_pane_surface() {
let mut server = test_headless_server();
@@ -713,25 +748,23 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() {
writer,
})
);
- match read_server_message(control_rx.recv().expect("shell snapshot")) {
- ServerMessage::ClientShellSnapshot(snapshot) => {
- assert_eq!(snapshot.workspaces.len(), 1);
- assert_eq!(snapshot.workspaces[0].label, "shell-only-label");
- assert_eq!(
- snapshot.config_diagnostic.as_deref(),
- Some("endpoint config warning")
- );
- assert_eq!(
- snapshot.product_announcement.as_ref().map(|announcement| (
- announcement.version.as_str(),
- announcement.id.as_str(),
- announcement.preview,
- )),
- Some(("0.8.2", "client-shell", true))
- );
- }
- other => panic!("expected client shell snapshot, got {other:?}"),
- }
+ let snapshot = client_shell_snapshot(read_server_message(
+ control_rx.recv().expect("shell snapshot"),
+ ));
+ assert_eq!(snapshot.workspaces.len(), 1);
+ assert_eq!(snapshot.workspaces[0].label, "shell-only-label");
+ assert_eq!(
+ snapshot.config_diagnostic.as_deref(),
+ Some("endpoint config warning")
+ );
+ assert_eq!(
+ snapshot.product_announcement.as_ref().map(|announcement| (
+ announcement.version.as_str(),
+ announcement.id.as_str(),
+ announcement.preview,
+ )),
+ Some(("0.8.2", "client-shell", true))
+ );
server.render_and_stream();
let initial_surface = match read_server_message(render_rx.recv().expect("pane surface")) {
@@ -1132,11 +1165,9 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
writer: local_writer,
})
);
- let ServerMessage::ClientShellSnapshot(local_snapshot) =
- read_server_message(local_control.recv().expect("local shell snapshot"))
- else {
- panic!("expected local shell snapshot");
- };
+ let local_snapshot = client_shell_snapshot(read_server_message(
+ local_control.recv().expect("local shell snapshot"),
+ ));
assert_eq!(
local_snapshot.config_diagnostic.as_deref(),
Some("theme warning")
@@ -1157,11 +1188,9 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() {
writer: endpoint_writer,
})
);
- let ServerMessage::ClientShellSnapshot(endpoint_snapshot) =
- read_server_message(endpoint_control.recv().expect("endpoint shell snapshot"))
- else {
- panic!("expected endpoint shell snapshot");
- };
+ let endpoint_snapshot = client_shell_snapshot(read_server_message(
+ endpoint_control.recv().expect("endpoint shell snapshot"),
+ ));
assert_eq!(
endpoint_snapshot.config_diagnostic.as_deref(),
Some("server keybinding warning\ntheme warning")
@@ -1199,10 +1228,10 @@ async fn client_shell_replaces_projection_and_focuses_stable_ids() {
writer,
})
);
- let initial_revision = match read_server_message(control_rx.recv().expect("initial snapshot")) {
- ServerMessage::ClientShellSnapshot(snapshot) => snapshot.revision,
- other => panic!("expected initial shell snapshot, got {other:?}"),
- };
+ let initial_revision = client_shell_snapshot(read_server_message(
+ control_rx.recv().expect("initial snapshot"),
+ ))
+ .revision;
let _ = server.app.handle_api_request(crate::api::schema::Request {
id: "test.client.shell.workspace.focus".into(),
@@ -1213,10 +1242,9 @@ async fn client_shell_replaces_projection_and_focuses_stable_ids() {
assert_eq!(server.app.state.active, Some(1));
server.render_and_stream();
- let replacement = match read_server_message(control_rx.recv().expect("replacement snapshot")) {
- ServerMessage::ClientShellSnapshot(snapshot) => snapshot,
- other => panic!("expected replacement shell snapshot, got {other:?}"),
- };
+ let replacement = client_shell_snapshot(read_server_message(
+ control_rx.recv().expect("replacement snapshot"),
+ ));
assert!(replacement.revision > initial_revision);
assert_eq!(
replacement.focused_workspace_id.as_deref(),
@@ -1441,9 +1469,8 @@ async fn client_shell_streams_and_targets_popup_terminal_content() {
writer,
})
);
- assert!(matches!(
- read_server_message(control_rx.recv().expect("shell snapshot")),
- ServerMessage::ClientShellSnapshot(_)
+ let _snapshot = client_shell_snapshot(read_server_message(
+ control_rx.recv().expect("shell snapshot"),
));
server.render_and_stream();
@@ -4553,7 +4580,7 @@ fn update_notification_is_semantic_for_system_delivery() {
assert_eq!(notification.title, "Herdr v9.9.9 available");
assert_eq!(
notification.body.as_deref(),
- Some("detach, run `herdr update`, then follow its restart guidance")
+ Some("detach, run `herdr update`, then run Herdr again to reconnect")
);
}
other => panic!("expected semantic update notification, got {other:?}"),
diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs
index e533bd03..15b23bf8 100644
--- a/src/server/render_scale_benchmark.rs
+++ b/src/server/render_scale_benchmark.rs
@@ -16,6 +16,7 @@ const ROWS: u16 = 40;
const SAMPLE_COUNT: usize = 40;
const WARMUP_COUNT: usize = 5;
const CARDINALITIES: [usize; 3] = [1, 15, 50];
+const CLIENT_CARDINALITIES: [usize; 2] = [1, 4];
#[derive(Clone, Copy)]
struct StageStats {
@@ -210,9 +211,52 @@ fn print_profiles(label: &str, build: fn(usize) -> Vec<Workspace>) {
print_stage("combined pipeline", &rows, |stats| stats.total);
}
+fn profile_snapshot_encoding(
+ build: fn(usize) -> Vec<Workspace>,
+ count: usize,
+ client_count: usize,
+) -> StageStats {
+ let pipeline = RenderPipeline::new(build(count));
+ let run = || {
+ let started = Instant::now();
+ let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None);
+ for client_index in 0..client_count {
+ let mut snapshot = template.clone();
+ snapshot.revision = client_index as u64 + 1;
+ let message = crate::protocol::endpoint::snapshot_message(&snapshot)
+ .expect("benchmark snapshot should serialize");
+ black_box(
+ bincode::serde::encode_to_vec(message, bincode::config::standard())
+ .expect("benchmark snapshot message should frame"),
+ );
+ }
+ started.elapsed()
+ };
+ for _ in 0..WARMUP_COUNT {
+ black_box(run());
+ }
+ summarize((0..SAMPLE_COUNT).map(|_| run()).collect())
+}
+
+fn print_snapshot_encoding_profiles(label: &str, build: fn(usize) -> Vec<Workspace>) {
+ println!("{label} snapshot projection + JSON framing");
+ println!(" panes clients median_us p95_us max_us");
+ for count in CARDINALITIES {
+ for client_count in CLIENT_CARDINALITIES {
+ let stats = profile_snapshot_encoding(build, count, client_count);
+ println!(
+ " {count:>10} {client_count:>7} {:>9} {:>6} {:>6}",
+ stats.median_us, stats.p95_us, stats.max_us
+ );
+ }
+ }
+}
+
#[tokio::test(flavor = "current_thread")]
#[ignore = "manual client-rendered pipeline scaling profile"]
async fn render_scale_profile() {
print_profiles("background workspaces (one pane each)", workspaces);
+ print_snapshot_encoding_profiles("background workspaces", workspaces);
print_profiles("active panes (one workspace)", active_panes);
+ print_snapshot_encoding_profiles("active panes", active_panes);
}
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
index 5f6dc078..e54c4369 100644
--- a/src/ui/release_notes.rs
+++ b/src/ui/release_notes.rs
@@ -307,7 +307,7 @@ mod tests {
assert_eq!(line_text(&lines[0]), " ● update ready");
assert_eq!(
line_text(&lines[1]),
- " detach, run herdr update, then follow its restart guidance"
+ " detach, run herdr update, then run Herdr again to reconnect"
);
assert_eq!(lines[0].spans[1].style.fg, Some(palette.accent));
assert_eq!(lines[0].spans[2].style.fg, Some(palette.text));
@@ -331,7 +331,7 @@ mod tests {
assert_eq!(line_text(&lines[0].1), " ● update ready");
assert_eq!(
line_text(&lines[1].1),
- " detach, run herdr update, then follow its restart guidance"
+ " detach, run herdr update, then run Herdr again to reconnect"
);
assert_eq!(line_text(&lines[2].1), "");
assert_eq!(line_text(&lines[3].1), " ADDED");
diff --git a/src/update.rs b/src/update.rs
index a15c030f..879726fc 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -197,6 +197,8 @@ impl AssetRef {
#[derive(Deserialize)]
struct UpdateManifest {
version: String,
+ #[cfg(not(windows))]
+ endpoint_generation: Option<u32>,
/// Thin-client protocol spoken by this release, when advertised by the manifest.
#[cfg(not(windows))]
protocol: Option<u32>,
@@ -236,6 +238,7 @@ struct PreviewManifest {
commit: String,
built_at: String,
protocol: u32,
+ endpoint_generation: Option<u32>,
notes: String,
assets: BTreeMap<String, AssetRef>,
#[serde(default)]
@@ -248,6 +251,7 @@ struct PreviewBuildMetadata {
commit: String,
built_at: String,
protocol: u32,
+ endpoint_generation: Option<u32>,
assets: BTreeMap<String, AssetRef>,
}
@@ -306,6 +310,8 @@ struct ReleaseInfo {
commit: Option<String>,
#[cfg(not(windows))]
target_protocol: Option<u32>,
+ #[cfg(not(windows))]
+ target_endpoint_generation: Option<u32>,
download_url: String,
sha256: Option<String>,
#[cfg(windows)]
@@ -415,6 +421,8 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
commit: None,
#[cfg(not(windows))]
target_protocol: manifest.protocol,
+ #[cfg(not(windows))]
+ target_endpoint_generation: manifest.endpoint_generation,
download_url,
sha256: Some(sha256),
#[cfg(windows)]
@@ -475,6 +483,7 @@ fn release_info_from_preview_manifest(
|| archived.commit != manifest.commit
|| archived.built_at != manifest.built_at
|| archived.protocol != manifest.protocol
+ || archived.endpoint_generation != manifest.endpoint_generation
{
tracing::warn!(
build_id,
@@ -502,6 +511,8 @@ fn release_info_from_preview_manifest(
commit: Some(manifest.commit.clone()),
#[cfg(not(windows))]
target_protocol: Some(manifest.protocol),
+ #[cfg(not(windows))]
+ target_endpoint_generation: manifest.endpoint_generation,
download_url,
sha256: asset.sha256.clone(),
#[cfg(windows)]
@@ -825,10 +836,14 @@ fn update_requires_server_restart(
server: &crate::api::RuntimeStatus,
release: &ReleaseInfo,
) -> bool {
- match (server.protocol, release.target_protocol) {
- (Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol,
- _ => true,
- }
+ let Some(target_generation) = release.target_endpoint_generation else {
+ return true;
+ };
+ server
+ .capabilities
+ .as_ref()
+ .and_then(|capabilities| capabilities.endpoint_protocol_generation)
+ != Some(target_generation)
}
#[cfg(not(windows))]
@@ -903,6 +918,7 @@ enum RunningServerUpdateAction {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(not(windows))]
enum RunningServerUpdateOutcome {
+ CompatibleServerKept,
RestartDeferred,
Stopped,
LiveHandoffComplete,
@@ -1229,7 +1245,12 @@ fn prompt_to_complete_plain_update(
decisions: &[RunningServerUpdateDecision],
release: &ReleaseInfo,
) -> Result<bool, String> {
- if decisions.is_empty() {
+ let plans: Vec<&RunningServerUpdatePlan> = decisions
+ .iter()
+ .filter(|decision| decision.plan.requires_server_restart)
+ .map(|decision| &decision.plan)
+ .collect();
+ if plans.is_empty() {
return Ok(true);
}
@@ -1237,8 +1258,6 @@ fn prompt_to_complete_plain_update(
return Ok(false);
}
- let plans: Vec<&RunningServerUpdatePlan> =
- decisions.iter().map(|decision| &decision.plan).collect();
let (singular, plural) = target_group_nouns(&plans);
let noun = if plans.len() == 1 { singular } else { plural };
eprintln!(
@@ -1288,7 +1307,9 @@ fn mark_plain_update_stop_decisions(
decisions
.into_iter()
.map(|mut decision| {
- decision.action = RunningServerUpdateAction::StopOldServer;
+ if decision.plan.requires_server_restart {
+ decision.action = RunningServerUpdateAction::StopOldServer;
+ }
decision
})
.collect()
@@ -1719,7 +1740,10 @@ fn apply_running_session_update_decisions(
for decision in decisions {
let outcome = match decision.action {
- RunningServerUpdateAction::None => RunningServerUpdateOutcome::RestartDeferred,
+ RunningServerUpdateAction::None if decision.plan.requires_server_restart => {
+ RunningServerUpdateOutcome::RestartDeferred
+ }
+ RunningServerUpdateAction::None => RunningServerUpdateOutcome::CompatibleServerKept,
RunningServerUpdateAction::StopOldServer => {
stop_running_server_for_update(&decision.plan)?;
RunningServerUpdateOutcome::Stopped
@@ -1760,6 +1784,24 @@ fn print_running_session_update_outcomes(
for outcome in outcomes {
match outcome.outcome {
+ RunningServerUpdateOutcome::CompatibleServerKept => {
+ eprintln!(
+ "{} {} kept running server v{}.",
+ outcome.target_noun,
+ outcome.session_label,
+ version_label(outcome.server_version.as_deref())
+ );
+ match &outcome.attach_command {
+ Some(command) => eprintln!(
+ "Run `{command}` to reconnect with the updated client. Restart the server later only if you need server-side changes from {}.",
+ release.label()
+ ),
+ None => eprintln!(
+ "Reconnect with the same socket override to use the updated client. Restart the server later only if you need server-side changes from {}.",
+ release.label()
+ ),
+ }
+ }
RunningServerUpdateOutcome::LiveHandoffComplete => {
if let Some(command) = &outcome.attach_command {
eprintln!(
@@ -1858,19 +1900,19 @@ pub(crate) fn update_install_command() -> &'static str {
pub(crate) fn update_install_instruction(install_command: &str) -> String {
match install_command {
HERDR_UPDATE_COMMAND => {
- "detach, run `herdr update`, then follow its restart guidance".to_string()
+ "detach, run `herdr update`, then run Herdr again to reconnect".to_string()
}
HOMEBREW_UPDATE_COMMAND => {
- "detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready".to_string()
+ "detach, run `brew update && brew upgrade herdr`, then run Herdr again to reconnect"
+ .to_string()
}
MISE_UPDATE_COMMAND => {
- "detach, run `mise upgrade herdr`, then restart this Herdr session when ready"
- .to_string()
+ "detach, run `mise upgrade herdr`, then run Herdr again to reconnect".to_string()
}
NIX_UPDATE_COMMAND => {
- "detach, update through Nix, then restart this Herdr session when ready".to_string()
+ "detach, update through Nix, then run Herdr again to reconnect".to_string()
}
- command => format!("detach, run `{command}`, then restart this Herdr session when ready"),
+ command => format!("detach, run `{command}`, then run Herdr again to reconnect"),
}
}
@@ -2146,7 +2188,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
eprintln!("installed {}", release.label());
print_outdated_integration_notice_with_updated_binary(&updated_exe);
eprintln!(
- "Restart any running Herdr sessions to use {}.",
+ "Start Herdr again to use the updated client. Running servers remain active; restart them later only if you need server-side changes from {}.",
release.label()
);
}
@@ -2432,6 +2474,9 @@ mod tests {
build_id: None,
commit: None,
target_protocol,
+ target_endpoint_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
download_url: "https://example.com/herdr".to_string(),
sha256: None,
notes_body: "### Changed\n- One".to_string(),
@@ -2711,15 +2756,15 @@ mod tests {
fn update_install_instruction_distinguishes_install_from_restart() {
assert_eq!(
update_install_instruction(HERDR_UPDATE_COMMAND),
- "detach, run `herdr update`, then follow its restart guidance"
+ "detach, run `herdr update`, then run Herdr again to reconnect"
);
assert_eq!(
update_install_instruction(HOMEBREW_UPDATE_COMMAND),
- "detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready"
+ "detach, run `brew update && brew upgrade herdr`, then run Herdr again to reconnect"
);
assert_eq!(
update_install_instruction(MISE_UPDATE_COMMAND),
- "detach, run `mise upgrade herdr`, then restart this Herdr session when ready"
+ "detach, run `mise upgrade herdr`, then run Herdr again to reconnect"
);
}
@@ -2796,41 +2841,60 @@ mod tests {
}
#[test]
- fn update_requires_server_restart_when_target_protocol_differs_or_unknown() {
- let server = crate::api::RuntimeStatus {
+ fn update_requires_server_restart_only_without_the_endpoint_baseline() {
+ let release = fake_release("0.5.6", Some(4));
+ let compatible = crate::api::RuntimeStatus {
version: Some("0.5.5".to_string()),
protocol: Some(2),
- capabilities: None,
+ capabilities: Some(crate::api::schema::ServerCapabilities {
+ live_handoff: true,
+ detached_server_daemon: true,
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
+ }),
};
- let compatible_release = ReleaseInfo {
- version: Version::parse("0.5.6").unwrap(),
- identity: "0.5.6".to_string(),
- channel: UpdateChannel::Stable,
- build_id: None,
- commit: None,
- target_protocol: Some(2),
- download_url: "https://example.com/herdr".to_string(),
- sha256: None,
- notes_body: "### Changed\n- One".to_string(),
+ let missing_baseline = crate::api::RuntimeStatus {
+ capabilities: None,
+ ..compatible.clone()
};
- let incompatible_release = ReleaseInfo {
- target_protocol: Some(4),
- ..compatible_release.clone()
+ let incompatible_generation = crate::api::RuntimeStatus {
+ capabilities: Some(crate::api::schema::ServerCapabilities {
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + 1,
+ ),
+ ..compatible.capabilities.clone().unwrap()
+ }),
+ ..compatible.clone()
};
+
+ assert!(!update_requires_server_restart(&compatible, &release));
+ assert!(update_requires_server_restart(&missing_baseline, &release));
+ assert!(update_requires_server_restart(
+ &incompatible_generation,
+ &release
+ ));
+
let unknown_release = ReleaseInfo {
- target_protocol: None,
- ..compatible_release.clone()
+ target_endpoint_generation: None,
+ ..release.clone()
};
-
- assert!(!update_requires_server_restart(
- &server,
- &compatible_release
+ assert!(update_requires_server_restart(
+ &compatible,
+ &unknown_release
));
assert!(update_requires_server_restart(
- &server,
- &incompatible_release
+ &missing_baseline,
+ &unknown_release
));
- assert!(update_requires_server_restart(&server, &unknown_release));
+
+ let future_release = ReleaseInfo {
+ target_endpoint_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + 1,
+ ),
+ ..release
+ };
+ assert!(update_requires_server_restart(&compatible, &future_release));
}
#[test]
@@ -2857,6 +2921,9 @@ mod tests {
capabilities: Some(crate::api::schema::ServerCapabilities {
live_handoff: true,
detached_server_daemon: true,
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
}),
},
};
@@ -2970,9 +3037,8 @@ mod tests {
let work_client_socket = crate::session::client_socket_path_for(Some("work"));
fs::create_dir_all(work_client_socket.parent().unwrap()).unwrap();
let work_client_listener = UnixListener::bind(&work_client_socket).unwrap();
- let release = fake_release("9.8.7", Some(77));
- let err = plan_running_server_updates(&release).unwrap_err();
+ let err = plan_running_server_updates(&fake_release("9.8.7", Some(77))).unwrap_err();
drop(work_client_listener);
let _ = fs::remove_dir_all(config_home);
@@ -3051,6 +3117,9 @@ mod tests {
build_id: None,
commit: None,
target_protocol: Some(3),
+ target_endpoint_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
download_url: "https://example.com/herdr".to_string(),
sha256: None,
notes_body: "### Changed\n- One".to_string(),
@@ -3085,6 +3154,50 @@ mod tests {
}
#[test]
+ fn noninteractive_plain_update_completes_with_compatible_server() {
+ assert!(
+ !io::stdin().is_terminal(),
+ "this test relies on noninteractive test stdin"
+ );
+ let release = fake_release("9.8.7", Some(77));
+ let plan = RunningServerUpdatePlan {
+ target: RunningUpdateTarget {
+ name: Some("work".to_string()),
+ label: "work".to_string(),
+ stop_command: "herdr session stop work".to_string(),
+ attach_command: Some("herdr session attach work".to_string()),
+ socket_path: crate::session::api_socket_path_for(Some("work")),
+ client_socket_path: crate::session::client_socket_path_for(Some("work")),
+ must_be_running: true,
+ },
+ requires_server_restart: false,
+ server: crate::api::RuntimeStatus {
+ version: Some("9.8.6".to_string()),
+ protocol: Some(76),
+ capabilities: Some(crate::api::schema::ServerCapabilities {
+ live_handoff: true,
+ detached_server_daemon: true,
+ endpoint_protocol_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
+ }),
+ },
+ };
+ let decisions = confirm_running_server_update_action(
+ vec![plan],
+ &release,
+ SelfUpdateOptions {
+ live_handoff: false,
+ },
+ )
+ .unwrap();
+
+ assert!(prompt_to_complete_plain_update(&decisions, &release).unwrap());
+ let decisions = mark_plain_update_stop_decisions(decisions);
+ assert_eq!(decisions[0].action, RunningServerUpdateAction::None);
+ }
+
+ #[test]
fn client_protocol_server_is_running_at_detects_live_socket() {
let socket_path = unique_test_socket_path("client-live");
let listener = UnixListener::bind(&socket_path).unwrap();
@@ -3186,6 +3299,9 @@ mod tests {
build_id: None,
commit: None,
target_protocol: Some(77),
+ target_endpoint_generation: Some(
+ crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
+ ),
download_url: "https://example.com/herdr".to_string(),
sha256: None,
notes_body: "### Changed\n- One".to_string(),
@@ -3296,6 +3412,7 @@ mod tests {
let json = "{\n\
\"version\": \"0.2.0\",\n\
\"protocol\": 4,\n\
+ \"endpoint_generation\": 1,\n\
\"notes\": \"### Changed\\n- One\",\n\
\"announcement\": {\n\
\"id\": \"keymap-v2\",\n\
@@ -3310,6 +3427,7 @@ mod tests {
let manifest: UpdateManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.version, "0.2.0");
assert_eq!(manifest.protocol, Some(4));
+ assert_eq!(manifest.endpoint_generation, Some(1));
assert_eq!(manifest.assets.len(), 2);
assert_eq!(
manifest