feat: add stable client endpoint compatibility

This commit is contained in:
Ogulcan Celik
2026-09-02 02:09:44 +03:00
parent 18e69891dc
commit 4d683cee2c
45 changed files with 2063 additions and 549 deletions
+10
View File
@@ -80,6 +80,16 @@ Examples:
- Sidebar layout, token placement, colors, selection, modals, mouse/viewport state: TUI/client.
- Workspace/tab/pane remain shared session organization for now, but avoid making them mandatory identity for unrelated runtime features.
### Stable client endpoint contract
The client-owned TUI endpoint generation is independent from the private same-install protocol. Generation 1 is the compatibility floor for Local, SSH, and Cloud connections and must remain available unless retired for a security reason.
- Named core codecs are immutable. Do not add, remove, reorder, or reinterpret fields or enum variants reachable from a published codec. Introduce a new codec name and keep the old codec as a fallback instead.
- Keep baseline JSON handshake and snapshot fields required. New JSON fields must be optional or have field-specific defaults; new enum values need an `Unknown` fallback where older clients can safely ignore them.
- Add server features through advertised API methods and optional snapshot data when possible. A missing optional feature must disable only that action, not reject the connection.
- Frozen endpoint fixtures, bincode digests, and wire-tag tests are compatibility contracts. Never update a generation-1 expectation merely to bless a wire change; create and negotiate a new codec.
- Existing-value digests cannot detect an appended enum variant. Review every enum reachable from a frozen codec as append-closed even when tests remain green.
## Maintainer Workflow
This section applies only to verified maintainers as defined under Scope and
+9
View File
@@ -10478,6 +10478,15 @@
"default": false,
"type": "boolean"
},
"endpoint_protocol_generation": {
"description": "Stable client-owned endpoint generation supported by this server.",
"format": "uint32",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"live_handoff": {
"type": "boolean"
}
@@ -63,7 +63,7 @@ Then attach with:
herdr --remote workbox
```
Remote attach supports Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a matching `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no matching binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows is not supported as the remote host.
Remote attach supports Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. Local and remote versions do not need to match once both support the stable endpoint generation. If no compatible binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows is not supported as the remote host.
By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket.
@@ -76,7 +76,7 @@ herdr --remote workbox
For any remote authentication failure, verify plain SSH access first with `ssh workbox`, then run `herdr --remote workbox` again.
By default, remote attach uses the normal restart/stop flow if it needs to replace or restart a running remote server. To opt into experimental live handoff for a supported running remote server, pass `--handoff`:
A version difference alone does not replace or restart a running remote server. Remote attach uses the restart/stop flow only for a server that predates the stable endpoint generation or the detached-daemon baseline. To opt into experimental live handoff when that one-time upgrade is needed, pass `--handoff`:
```bash
herdr --remote workbox --handoff
@@ -934,7 +934,14 @@ Errors look like this:
## Protocol stability
Herdr has a protocol version for client/server compatibility. Protocol changes
are reviewed for release compatibility.
The client-rendered Herdr UI uses a stable endpoint generation for local and SSH
servers. Client and server builds do not need to match. During connection setup,
they agree on the core snapshot, screen, input, and blob codecs, and the server advertises
the API methods it supports. A missing method disables only that action on that
machine; it does not disconnect the UI. Servers from before endpoint generation
1 need one final update.
Check the server protocol with `ping` or `herdr status` before depending on new behavior. Handle unknown fields gracefully.
The numbered binary protocol remains for same-install and internal operations,
including direct terminal attach and live handoff. Check `ping` or `herdr status`
before using those operations across different builds. JSON API clients should
ignore unknown fields and handle unsupported methods as normal errors.
+1 -1
View File
@@ -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;
+3
View File
@@ -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>,
}
+7
View File
@@ -707,6 +707,12 @@ fn scroll_changed_subscription_event_round_trips() {
assert_eq!(restored, event);
}
#[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 {
@@ -717,6 +723,7 @@ fn success_response_round_trips() {
capabilities: Some(ServerCapabilities {
live_handoff: true,
detached_server_daemon: true,
endpoint_protocol_generation: Some(1),
}),
},
};
+5 -1
View File
@@ -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,
+45 -3
View File
@@ -98,6 +98,10 @@ 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, " ");
@@ -130,6 +134,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 +145,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,6 +208,20 @@ 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() {
@@ -214,6 +245,7 @@ struct ClientStatusJson {
version: String,
channel: &'static str,
protocol: u32,
endpoint_protocol_generation: u32,
binary: String,
session: Option<String>,
}
@@ -226,6 +258,7 @@ struct ServerStatusJson {
protocol: Option<u32>,
capabilities: Option<ServerCapabilitiesJson>,
compatible: Option<bool>,
endpoint_compatible: Option<bool>,
socket: String,
session: Option<String>,
restart_needed: Option<bool>,
@@ -235,6 +268,7 @@ struct ServerStatusJson {
struct ServerCapabilitiesJson {
live_handoff: bool,
detached_server_daemon: bool,
endpoint_protocol_generation: Option<u32>,
}
#[derive(Serialize)]
@@ -247,6 +281,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,8 +303,14 @@ 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),
@@ -281,6 +322,7 @@ 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),
+80 -6
View File
@@ -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"),
+80 -38
View File
@@ -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");
}
+19 -6
View File
@@ -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(
@@ -361,10 +365,18 @@ impl ClientShellState {
method: crate::api::schema::Method,
kind: PendingEndpointKind,
outcome: &mut ClientShellInput,
) {
) -> bool {
let Some(snapshot) = self.snapshot.as_deref() else {
return;
return false;
};
if !self.supports_endpoint_method(&method) {
self.endpoint_error = Some(format!(
"This action is unavailable on this machine (missing {}).",
crate::api::api_method_name(&method)
));
outcome.repaint = true;
return false;
}
let confirmation_workspace_id = match &method {
crate::api::schema::Method::TabClose(target) => snapshot
.tabs
@@ -396,6 +408,7 @@ impl ClientShellState {
method,
}),
});
true
}
pub(crate) fn receive_endpoint_error(&mut self, message: String) -> bool {
+22 -15
View File
@@ -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,
+3 -1
View File
@@ -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;
}
}
+9 -3
View File
@@ -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(
+16 -5
View File
@@ -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;
}
+19 -1
View File
@@ -836,6 +836,10 @@ 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>,
@@ -971,6 +975,7 @@ 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(),
@@ -987,6 +992,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 +1087,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
}) {
@@ -982,6 +982,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
.endpoint_error
.as_deref()
.is_some_and(|error| error.contains("missing worktree.create")));
}
#[test]
fn worktree_open_filters_and_clicks_a_stable_public_entry() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
@@ -436,6 +436,28 @@ 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_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);
assert_eq!(
state.endpoint_error.as_deref(),
Some("This action is unavailable on this machine (missing workspace.focus).")
);
}
#[test]
fn generic_endpoint_failures_and_control_errors_are_visible() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
@@ -454,6 +454,41 @@ fn onboarding_completion_persists_and_opens_endpoint_integrations() {
std::fs::remove_file(path).expect("remove onboarding config");
}
#[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
.endpoint_error
.as_deref()
.is_some_and(|error| error.contains("missing 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())
+18 -6
View File
@@ -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;
}
+4 -3
View File
@@ -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);
}
+275
View File
@@ -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);
}
}
+1
View File
@@ -1,5 +1,6 @@
//! Shared wire protocol and presentation encoding code.
pub mod endpoint;
pub(crate) mod render_ansi;
mod wire;
+329 -8
View File
@@ -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 ----
@@ -1696,6 +1757,29 @@ mod tests {
assert_eq!(msg, decoded);
}
#[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 {
@@ -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]
@@ -1805,6 +1893,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),
@@ -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]
+130 -186
View File
@@ -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::EndpointProtocolMissing => {
eprintln!(
"the remote server predates Herdr's stable endpoint protocol and must update before this client can attach."
);
}
RemoteServerRestartReason::DaemonDetachMissing => {
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."
);
}
RemoteServerRestartReason::BinaryUpdated => {
eprintln!(
"the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary."
);
}
RemoteServerRestartReason::VersionMismatch => {
eprintln!(
"the remote server is still running a different herdr version. restart it so it uses the prepared binary."
);
}
}
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,62 +3131,20 @@ 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)
);
}
#[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,52 +3167,38 @@ mod tests {
}
#[test]
fn remote_install_plan_requires_stop_after_helper_update() {
fn remote_install_plan_requires_stop_for_pre_floor_server() {
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() {
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 {
+7 -2
View File
@@ -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",
));
}
+9 -6
View File
@@ -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()
)))
}
+90 -40
View File
@@ -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 {
@@ -136,6 +145,47 @@ pub(crate) fn spawn_response_waiter(
mod tests {
use super::*;
#[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(
+373 -104
View File
@@ -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 {
@@ -1431,6 +1611,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");
@@ -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")
@@ -1649,6 +1852,72 @@ mod tests {
assert!(server_event_rx.try_recv().is_err());
}
#[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");
+23 -5
View File
@@ -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,19 @@ impl HeadlessServer {
}
foreground_changed || runtime.scroll_metrics() != scroll_before
}
ServerEvent::ClientShellEndpointRequestError {
client_id,
boot_id,
request_id,
code,
message,
} => {
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,
+9 -2
View File
@@ -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;
}
+40 -40
View File
@@ -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())
}
@@ -713,25 +721,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 +1138,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 +1161,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 +1201,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 +1215,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 +1442,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();
+44
View File
@@ -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);
}
+3
View File
@@ -2857,6 +2857,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,
),
}),
},
};
+14 -2
View File
@@ -398,7 +398,11 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" compatible: yes"),
full_stdout.contains(" private_protocol_compatible: yes"),
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" endpoint_compatible: yes"),
"stdout: {full_stdout}"
);
assert!(
@@ -422,7 +426,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
server_stdout.contains("protocol: 22"),
server_stdout.contains("private_protocol: 22"),
"stdout: {server_stdout}"
);
@@ -437,6 +441,10 @@ fn status_commands_report_client_and_server_versions() {
client_stdout.contains("protocol: 22"),
"stdout: {client_stdout}"
);
assert!(
client_stdout.contains("endpoint_protocol_generation: 1"),
"stdout: {client_stdout}"
);
assert!(
client_stdout.contains("binary: "),
"stdout: {client_stdout}"
@@ -445,9 +453,11 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(full_json["client"]["protocol"], 22);
assert_eq!(full_json["client"]["endpoint_protocol_generation"], 1);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
assert_eq!(full_json["server"]["endpoint_compatible"], true);
assert_eq!(
full_json["server"]["socket"],
socket_path.display().to_string()
@@ -460,10 +470,12 @@ fn status_commands_report_client_and_server_versions() {
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(server_json["protocol"], 22);
assert_eq!(server_json["compatible"], true);
assert_eq!(server_json["endpoint_compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(client_json["protocol"], 22);
assert_eq!(client_json["endpoint_protocol_generation"], 1);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));
+3 -2
View File
@@ -18,8 +18,9 @@ use support::{
cleanup_test_base, client_shell_handshake, read_server_message, register_runtime_dir,
register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap,
wait_for_message_variant, wait_for_message_variants, wait_for_socket, wait_until,
CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
SERVER_MESSAGE_SEMANTIC_NOTIFICATION, SERVER_MESSAGE_SERVER_SHUTDOWN,
CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE,
SERVER_MESSAGE_PANE_SURFACE_PATCH, SERVER_MESSAGE_SEMANTIC_NOTIFICATION,
SERVER_MESSAGE_SERVER_SHUTDOWN,
};
fn unique_test_dir() -> PathBuf {
+4 -3
View File
@@ -14,8 +14,9 @@ use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}
use serde_json::{json, Value};
use support::{
cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid,
unregister_spawned_herdr_pid, CURRENT_PROTOCOL, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
unregister_spawned_herdr_pid, CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL,
SERVER_MESSAGE_ENDPOINT_CONTROL, SERVER_MESSAGE_PANE_SURFACE,
SERVER_MESSAGE_PANE_SURFACE_PATCH,
};
fn unique_test_dir() -> PathBuf {
@@ -444,7 +445,7 @@ fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool {
let slice = deadline.saturating_duration_since(Instant::now());
match read_server_variant(stream, slice) {
Ok(SERVER_MESSAGE_PANE_SURFACE | SERVER_MESSAGE_PANE_SURFACE_PATCH) => return true,
Ok(SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT) => {}
Ok(SERVER_MESSAGE_ENDPOINT_CONTROL) => {}
Ok(_) => {}
Err(err) if is_timeout(&err) => {}
Err(_) => return false,
+1 -1
View File
@@ -16,7 +16,7 @@ use serde_json::Value;
use support::{
cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir,
register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, wait_for_disconnect,
wait_for_socket, wait_until, CURRENT_PROTOCOL,
wait_for_socket, wait_until, CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL,
};
const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false
+25
View File
@@ -0,0 +1,25 @@
{
"generation": 1,
"cell_width_px": 8,
"cell_height_px": 16,
"surface_size": {
"cols": 80,
"rows": 24
},
"pixel_mouse": true,
"direct_graphics": false,
"endpoint_keybindings": false,
"mouse_capture": true,
"snapshot_codecs": [
"shell.snapshot.v1"
],
"surface_codecs": [
"shell.surface.v1"
],
"input_codecs": [
"shell.input.semantic.v1"
],
"blob_codecs": [
"shell.blob.v1"
]
}
+106
View File
@@ -0,0 +1,106 @@
{
"boot_id": "boot-v1",
"revision": 7,
"config_diagnostic": "endpoint warning",
"product_announcement": {
"version": "1.0.0",
"id": "announcement-v1",
"title": "Announcement",
"body": "Body",
"preview": false
},
"update_available": "1.0.1",
"update_install_command": "herdr update",
"server_keybindings_toml": "[keys]",
"latest_release_notes_available": true,
"integration_updates_available": true,
"worktree_directory": "/worktrees",
"release_notes": {
"version": "1.0.1",
"body": "Notes",
"preview": true
},
"focused_workspace_id": "w1",
"focused_tab_id": "w1:t1",
"focused_pane_id": "w1:p1",
"tab_bar_right": [
{
"text": "host",
"accent": true
}
],
"tab_bar_right_separator": " | ",
"agent_view_label": "focus",
"agent_order": ["w1:p1"],
"workspaces": [
{
"workspace_id": "w1",
"active_tab_id": "w1:t1",
"new_workspace_cwd": "/repo",
"number": 1,
"label": "repo",
"custom_label": false,
"branch": "main",
"git_ahead_behind": [1, 2],
"tokens": [["model", "opus"]],
"worktree": {
"key": "repo/main",
"label": "main",
"is_linked_worktree": true
},
"focused": true,
"agent_status": "future_status_from_new_server"
}
],
"tabs": [
{
"tab_id": "w1:t1",
"workspace_id": "w1",
"number": 1,
"label": "main",
"custom_label": false,
"zoomed": false,
"focused": true,
"agent_status": "working"
}
],
"panes": [
{
"pane_id": "w1:p1",
"workspace_id": "w1",
"tab_id": "w1:t1",
"label": "shell",
"cwd": "/repo",
"foreground_cwd": "/repo",
"focused": true,
"right_click_passthrough": false
}
],
"agents": [
{
"pane_id": "w1:p1",
"workspace_id": "w1",
"tab_id": "w1:t1",
"name": "reviewer",
"display_agent": "Claude",
"agent": "claude",
"title": "Review",
"terminal_title": "Claude Review",
"terminal_title_stripped": "Claude Review",
"agent_status": "blocked",
"state_change_seq": 9,
"state_labels": [["blocked", "waiting"]],
"tokens": [["task", "review"]],
"focused": true
}
],
"commands": [
{
"command_id": "command-v1",
"binding_label": "prefix+x",
"binding_labels": ["prefix+x"],
"action": "Shell",
"description": "Command"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"generation": 1,
"server_version": "0.8.2",
"snapshot_codec": "shell.snapshot.v1",
"surface_codec": "shell.surface.v1",
"input_codec": "shell.input.semantic.v1",
"blob_codec": "shell.blob.v1",
"methods": [
"pane.focus"
]
}
+39 -32
View File
@@ -14,7 +14,7 @@ use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}
use support::{
cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid,
send_client_shell_shift_enter, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap,
wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_ENDPOINT_CONTROL,
SERVER_MESSAGE_SERVER_SHUTDOWN,
};
@@ -863,22 +863,17 @@ fn live_handoff_preserves_pane_process_io() {
assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0);
assert_eq!(unsafe { libc::kill(second_child_pid as libc::pid_t, 0) }, 0);
let protocol = request(
&api_socket,
serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
)["result"]["protocol"]
.as_u64()
.unwrap() as u32;
let endpoint_generation = support::CURRENT_ENDPOINT_PROTOCOL_GENERATION;
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
let (server_protocol, error) =
client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
let (server_generation, error) =
client_shell_handshake(&mut client_stream, endpoint_generation, 54, 23).unwrap();
assert_eq!(server_generation, endpoint_generation);
assert!(error.is_none(), "client shell handshake failed: {error:?}");
assert!(
wait_for_message_variant(
&mut client_stream,
Duration::from_secs(5),
SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
SERVER_MESSAGE_ENDPOINT_CONTROL,
)
.unwrap(),
"client shell should receive a complete snapshot before handoff"
@@ -953,9 +948,17 @@ fn live_handoff_preserves_pane_process_io() {
wait_for_output(&api_socket, &second_pane_id, "second:after-handoff-sec");
let mut reattached_shell = UnixStream::connect(&client_socket).unwrap();
let (server_protocol, error) =
client_shell_handshake(&mut reattached_shell, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
let (server_generation, error) = client_shell_handshake(
&mut reattached_shell,
support::CURRENT_ENDPOINT_PROTOCOL_GENERATION,
54,
23,
)
.unwrap();
assert_eq!(
server_generation,
support::CURRENT_ENDPOINT_PROTOCOL_GENERATION
);
assert!(error.is_none(), "reattached client shell failed: {error:?}");
wait_for_client_shell_bootstrap(&mut reattached_shell, Duration::from_secs(5))
.expect("fresh client shell should receive restored snapshot before pane content");
@@ -1030,12 +1033,6 @@ pathlib.Path({received:?}).write_text(data.hex())
));
support::wait_for_file(&ready_marker, Duration::from_secs(5));
let protocol = request(
&api_socket,
serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
)["result"]["protocol"]
.as_u64()
.unwrap() as u32;
assert_ok(request(
&api_socket,
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
@@ -1045,9 +1042,17 @@ pathlib.Path({received:?}).write_text(data.hex())
wait_for_socket(&client_socket, Duration::from_secs(5));
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
let (server_protocol, error) =
client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
let (server_generation, error) = client_shell_handshake(
&mut client_stream,
support::CURRENT_ENDPOINT_PROTOCOL_GENERATION,
54,
23,
)
.unwrap();
assert_eq!(
server_generation,
support::CURRENT_ENDPOINT_PROTOCOL_GENERATION
);
assert!(error.is_none(), "client shell handshake failed: {error:?}");
wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5))
.expect("client shell should receive restored state before sending input");
@@ -1124,12 +1129,6 @@ pathlib.Path({received:?}).write_text(data.hex())
));
support::wait_for_file(&ready_marker, Duration::from_secs(5));
let protocol = request(
&api_socket,
serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
)["result"]["protocol"]
.as_u64()
.unwrap() as u32;
assert_ok(request(
&api_socket,
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
@@ -1139,9 +1138,17 @@ pathlib.Path({received:?}).write_text(data.hex())
wait_for_socket(&client_socket, Duration::from_secs(5));
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
let (server_protocol, error) =
client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
let (server_generation, error) = client_shell_handshake(
&mut client_stream,
support::CURRENT_ENDPOINT_PROTOCOL_GENERATION,
54,
23,
)
.unwrap();
assert_eq!(
server_generation,
support::CURRENT_ENDPOINT_PROTOCOL_GENERATION
);
assert!(error.is_none(), "client shell handshake failed: {error:?}");
wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5))
.expect("client shell should receive restored state before sending input");
+2 -1
View File
@@ -16,7 +16,8 @@ use support::{
cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir,
register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid,
wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_message_variants,
CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE,
SERVER_MESSAGE_PANE_SURFACE_PATCH,
};
fn unique_test_dir() -> PathBuf {
+76 -21
View File
@@ -14,13 +14,14 @@ static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
pub const CURRENT_PROTOCOL: u32 = 22;
pub const CURRENT_ENDPOINT_PROTOCOL_GENERATION: u32 = 1;
pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3;
pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 12;
pub const SERVER_MESSAGE_ENDPOINT_CONTROL: u32 = 20;
pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13;
pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14;
pub const SERVER_MESSAGE_PANE_SURFACE_PATCH: u32 = 19;
const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 11;
const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13;
const CLIENT_MESSAGE_ENDPOINT_CONTROL: u32 = 20;
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
let Some(pid) = pid else {
@@ -185,6 +186,25 @@ fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
buf
}
fn encode_string(value: &str) -> Vec<u8> {
let mut encoded = encode_varint_u32(value.len() as u32);
encoded.extend_from_slice(value.as_bytes());
encoded
}
fn decode_string(payload: &[u8], offset: &mut usize) -> Result<String, String> {
let (len, consumed) = decode_varint_u32(payload, *offset)?;
*offset += consumed;
let len = len as usize;
if *offset + len > payload.len() {
return Err("payload too short for string content".into());
}
let value = String::from_utf8(payload[*offset..*offset + len].to_vec())
.map_err(|err| err.to_string())?;
*offset += len;
Ok(value)
}
fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
let mut offset = 0;
let (variant, consumed) = decode_varint_u32(payload, offset)?;
@@ -225,10 +245,10 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
Ok((version, error))
}
fn finish_handshake(
fn read_handshake_response(
stream: &mut UnixStream,
hello_payload: &[u8],
) -> Result<(u32, Option<String>), String> {
) -> Result<Vec<u8>, String> {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.map_err(|e| e.to_string())?;
@@ -245,7 +265,7 @@ fn finish_handshake(
}
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
decode_welcome(&payload)
Ok(payload)
}
pub fn client_handshake(
@@ -265,30 +285,60 @@ pub fn client_handshake(
&[0], // pixel_mouse = false
],
);
finish_handshake(stream, &hello_payload)
let response = read_handshake_response(stream, &hello_payload)?;
decode_welcome(&response)
}
pub fn client_shell_handshake(
stream: &mut UnixStream,
version: u32,
endpoint_generation: u32,
surface_cols: u16,
surface_rows: u16,
) -> Result<(u32, Option<String>), String> {
let data = serde_json::json!({
"generation": endpoint_generation,
"cell_width_px": 8,
"cell_height_px": 16,
"surface_size": {"cols": surface_cols, "rows": surface_rows},
"pixel_mouse": false,
"direct_graphics": false,
"endpoint_keybindings": false,
"mouse_capture": false,
"snapshot_codecs": ["shell.snapshot.v1"],
"surface_codecs": ["shell.surface.v1"],
"input_codecs": ["shell.input.semantic.v1"],
"blob_codecs": ["shell.blob.v1"]
})
.to_string();
let hello_payload = encode_varint_enum(
CLIENT_MESSAGE_CLIENT_SHELL_HELLO,
&[
&encode_varint_u32(version),
&encode_varint_u32(8),
&encode_varint_u32(16),
&encode_varint_u16(surface_cols),
&encode_varint_u16(surface_rows),
&[0], // pixel mouse disabled
&[0], // direct graphics disabled
&[0], // client-owned keybindings
&[0], // mouse capture disabled
],
CLIENT_MESSAGE_ENDPOINT_CONTROL,
&[&encode_string("endpoint.hello.v1"), &encode_string(&data)],
);
finish_handshake(stream, &hello_payload)
let response = read_handshake_response(stream, &hello_payload)?;
let mut offset = 0;
let (variant, consumed) = decode_varint_u32(&response, offset)?;
offset += consumed;
if variant != SERVER_MESSAGE_ENDPOINT_CONTROL {
return Err(format!(
"expected EndpointControl (variant {SERVER_MESSAGE_ENDPOINT_CONTROL}), got variant {variant}"
));
}
let kind = decode_string(&response, &mut offset)?;
if kind != "endpoint.welcome.v1" {
return Err(format!("expected endpoint.welcome.v1, got {kind}"));
}
let data = decode_string(&response, &mut offset)?;
let value: serde_json::Value = serde_json::from_str(&data).map_err(|err| err.to_string())?;
let generation = value["generation"]
.as_u64()
.ok_or_else(|| "endpoint welcome omitted generation".to_owned())?
as u32;
let error = value["error"]
.as_object()
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
Ok((generation, error))
}
pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), String> {
@@ -407,7 +457,12 @@ pub fn wait_for_client_shell_bootstrap(
let mut saw_snapshot = false;
while Instant::now() < deadline {
match read_server_message(stream) {
Ok((SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, _)) => saw_snapshot = true,
Ok((SERVER_MESSAGE_ENDPOINT_CONTROL, payload)) => {
let mut offset = 0;
if decode_string(&payload, &mut offset).as_deref() == Ok("shell.snapshot.v1") {
saw_snapshot = true;
}
}
Ok((SERVER_MESSAGE_PANE_SURFACE, _)) if saw_snapshot => return Ok(()),
Ok((SERVER_MESSAGE_PANE_SURFACE, _)) => {
return Err("client shell pane surface arrived before its snapshot".into());