Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/next/website/src/content/docs/connecting-machines.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ Local opens immediately on startup without waiting for SSH connections. Selectin

When a connection is lost, the last workspace and agent state remains visible but dimmed. That is cached information, not live state. Input and navigation into those cached panes stay disabled until a fresh connection and matching screen arrive. Reconnecting never takes selection away from the machine you are using.

Stopping an updated server with `herdr server stop` or `herdr session stop <name>` leaves it stopped. Saved reconnects, Retry, wake, and selecting the machine do not start it. Clients show **stopped**, including clients that were offline when the stop was accepted. Click **Start** beside that saved machine or in the mobile machine list. Other clients reconnect after it starts. A stop requested during startup or live handoff is rejected; retry after that operation finishes. A network failure, crash, or operating-system shutdown does not establish an accepted API stop. When the host is reachable and both server listeners are absent, clients show **offline** and offer the same explicit **Start** action. An unreachable host or a server still starting does not offer Start.

This guarantee requires updating both the running server and the remote Herdr executable used for SSH bridges. Compatible older servers remain connectable, but older host executables cannot enforce the same stop guarantee or provide the new Start action. Explicit `herdr`, `herdr --remote`, machine setup, and configured service startup still authorize a start. Herdr does not override an external service manager's restart policy. A Windows SSH start can still run outside the signed-in desktop, so tools requiring that desktop may remain unavailable.

## Rename, disable, or remove

Read profile IDs from the list rather than deriving them from labels or hostnames:
Expand Down
37 changes: 32 additions & 5 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ use crate::ipc::{
poll_local_stream_read, remove_socket_file_if_owned, set_local_stream_polling,
socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity,
};
use crate::server::shutdown::{ServerStop, ShutdownReason};
use crate::server::shutdown::ServerStop;
#[cfg(all(test, unix))]
use crate::server::shutdown::ShutdownReason;

#[cfg(test)]
mod subscription_socket_tests;
Expand Down Expand Up @@ -581,9 +583,9 @@ fn handle_request(

if matches!(&request.method, Method::ServerStop(_)) {
if let Some(server_stop) = server_stop {
server_stop.request(ShutdownReason::ApiStop {
caller: stop_caller,
});
if let Err(error) = server_stop.request_api_stop(stop_caller) {
return error_response_json(request.id, "server_unavailable", error.to_string());
}
return serde_json::to_string(&SuccessResponse {
id: request.id,
result: ResponseResult::Ok {},
Expand Down Expand Up @@ -1411,7 +1413,30 @@ mod tests {
#[test]
fn server_stop_control_bypasses_app_channel() {
let (tx, mut rx) = mpsc::unbounded_channel();
let stop = ServerStop::default();
let path = unique_test_path("startup-stop");
let mut startup = crate::server::lifecycle::lock(&path).unwrap();
let stop = ServerStop::for_test_path(path.clone(), startup.next_epoch().unwrap());
let started = std::time::Instant::now();
let busy = handle_request(
Request {
id: "startup_stop".into(),
method: Method::ServerStop(crate::api::schema::EmptyParams::default()),
},
&tx,
None,
Some(&stop),
None,
None,
);
assert!(started.elapsed() < Duration::from_secs(1));
let busy: serde_json::Value = serde_json::from_str(&busy).unwrap();
assert_eq!(busy["error"]["code"], "server_unavailable");
assert!(busy["error"]["message"].as_str().unwrap().contains("busy"));
assert!(!stop.is_requested());
assert!(!crate::server::lifecycle::read(&path).unwrap().stopped);
assert!(rx.try_recv().is_err());
startup.commit(false).unwrap();
drop(startup);
let response = handle_request(
Request {
id: "priority_stop".into(),
Expand Down Expand Up @@ -1449,6 +1474,8 @@ mod tests {
let rejected: serde_json::Value = serde_json::from_str(&rejected).unwrap();
assert_eq!(rejected["error"]["code"], "server_unavailable");
assert!(rx.try_recv().is_err());
drop(stop);
crate::server::lifecycle::remove_test_receipt(&path);
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions src/cli/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ struct ClientStatusJson {
endpoint_protocol_generation: u32,
endpoint_capabilities: Vec<&'static str>,
remote_host_bridge: bool,
remote_host_control_v1: bool,
remote_bridge_idle_timeout: bool,
binary: String,
session: Option<String>,
Expand Down Expand Up @@ -301,6 +302,7 @@ fn client_status_json() -> ClientStatusJson {
crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY,
],
remote_host_bridge: true,
remote_host_control_v1: true,
remote_bridge_idle_timeout: crate::platform::REMOTE_BRIDGE_IDLE_TIMEOUT_SUPPORTED,
binary: current_exe_label(),
session: crate::session::active_name(),
Expand Down
2 changes: 2 additions & 0 deletions src/client/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ pub(crate) enum ClientEndpointStatus {
Connecting,
Online,
Reconnecting,
Stopped(u64),
Offline(u64),
Attention,
Disabled,
}
Expand Down
50 changes: 49 additions & 1 deletion src/client/endpoint/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ struct ReconnectState {
generation: Option<u64>,
ready_since: Option<Instant>,
retry_after_resume: bool,
start_epoch: Option<u64>,
}

impl ReconnectState {
Expand All @@ -75,6 +76,7 @@ impl ReconnectState {
generation: None,
ready_since: None,
retry_after_resume: false,
start_epoch: None,
}
}
}
Expand Down Expand Up @@ -210,6 +212,7 @@ impl EndpointSupervisors {
self.next_generation = self.next_generation.saturating_add(1);
let endpoint_id = endpoint_id.clone();
let target = state.target.clone();
let start_epoch = state.start_epoch.take();
let event_tx = event_tx.clone();
let shutdown = self.shutdown.clone();
tokio::spawn(async move {
Expand All @@ -221,6 +224,7 @@ impl EndpointSupervisors {
let result = tokio::task::spawn_blocking(move || {
connect_once(
&target,
start_epoch,
options,
endpoint_id,
generation,
Expand All @@ -234,7 +238,16 @@ impl EndpointSupervisors {
Ok(Err(error)) => EndpointSupervisorEvent::Status {
endpoint_id: task_endpoint_id,
generation,
status: if failure_needs_attention(&error) {
status: if let Some(inactive) = error
.get_ref()
.and_then(|error| error.downcast_ref::<crate::remote::HostStatus>())
{
if inactive.state == crate::remote::HostState::Stopped {
ClientEndpointStatus::Stopped(inactive.epoch)
} else {
ClientEndpointStatus::Offline(inactive.epoch)
}
} else if failure_needs_attention(&error) {
ClientEndpointStatus::Attention
} else {
ClientEndpointStatus::Reconnecting
Expand Down Expand Up @@ -272,6 +285,7 @@ impl EndpointSupervisors {
let retry_after_resume = std::mem::take(&mut state.retry_after_resume);
match status {
ClientEndpointStatus::Online => {
state.start_epoch = None;
if endpoint_id.is_local() {
state.attempts = 0;
}
Expand All @@ -287,6 +301,10 @@ impl EndpointSupervisors {
state.ready_since = None;
state.next_attempt = None;
}
ClientEndpointStatus::Stopped(_) | ClientEndpointStatus::Offline(_) => {
state.ready_since = None;
state.next_attempt = Some(now + Duration::from_secs(2));
}
ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => {
// A brief maintenance wake can complete a handshake without restoring the link.
if state.ready_since.take().is_some_and(|ready| {
Expand All @@ -312,6 +330,9 @@ impl EndpointSupervisors {
);
}
}
if state.start_epoch.is_some() {
state.next_attempt = Some(now);
}
true
}

Expand Down Expand Up @@ -417,6 +438,23 @@ impl EndpointSupervisors {
true
}

pub(crate) fn start_now(
&mut self,
endpoint_id: &ClientEndpointId,
epoch: u64,
now: Instant,
) -> bool {
let Some(state) = self.endpoints.get_mut(endpoint_id) else {
return false;
};
if !state.in_flight && state.next_attempt.is_none() {
return false;
}
state.start_epoch = Some(epoch);
state.next_attempt = Some(now);
true
}

pub(crate) fn disconnected(
&mut self,
endpoint_id: &ClientEndpointId,
Expand All @@ -440,6 +478,7 @@ impl Drop for EndpointSupervisors {

fn connect_once(
target: &ConnectTarget,
start_epoch: Option<u64>,
options: EndpointConnectOptions,
endpoint_id: ClientEndpointId,
generation: u64,
Expand Down Expand Up @@ -474,6 +513,15 @@ fn connect_once(
(stream, Box::new(()))
}
ConnectTarget::Ssh(profile) => {
if let Some(epoch) = start_epoch {
crate::remote::start_saved_ssh(
profile.id.as_str(),
&profile.target,
&profile.session,
epoch,
Arc::clone(&cancelled),
)?;
}
let connected = crate::remote::connect_saved_ssh(
profile.id.as_str(),
&profile.target,
Expand Down
3 changes: 3 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,9 @@ async fn run_client_loop(
}
}
if let Some(shell) = state.shell.as_mut() {
for (endpoint_id, epoch) in shell.take_machine_starts() {
supervisors.start_now(&endpoint_id, epoch, now);
}
for endpoint_id in shell.take_machine_retries() {
supervisors.retry_now(&endpoint_id, now);
}
Expand Down
5 changes: 5 additions & 0 deletions src/client/shell/endpoint_sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,11 @@ fn render_endpoint_row(
"! auth".to_owned()
} else if endpoint.status == ClientEndpointStatus::Attention {
"! error".to_owned()
} else if matches!(
endpoint.status,
ClientEndpointStatus::Stopped(_) | ClientEndpointStatus::Offline(_)
) {
format!("{state} · Start")
} else if endpoint.endpoint_id.is_local() {
String::new()
} else if state.is_empty() {
Expand Down
2 changes: 2 additions & 0 deletions src/client/shell/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,8 @@ pub(super) fn endpoint_status_presentation(
ClientEndpointStatus::Connecting => ("◐", "connecting", palette.yellow),
ClientEndpointStatus::Online => ("●", "online", palette.green),
ClientEndpointStatus::Reconnecting => ("◐", "reconnecting", palette.yellow),
ClientEndpointStatus::Stopped(_) => ("■", "stopped", palette.overlay0),
ClientEndpointStatus::Offline(_) => ("■", "offline", palette.overlay0),
ClientEndpointStatus::Attention => ("!", "attention", palette.red),
ClientEndpointStatus::Disabled => ("·", "disabled", palette.overlay0),
}
Expand Down
36 changes: 33 additions & 3 deletions src/client/shell/machine_diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub(super) struct MachineDiagnostics {
hover: Option<ClientEndpointId>,
/// Retries the client loop has not yet handed to the endpoint supervisors.
retry_requests: Vec<ClientEndpointId>,
start_requests: Vec<(ClientEndpointId, u64)>,
/// Requested retries whose failure should reopen the fresh diagnostic.
retry_reports: HashSet<ClientEndpointId>,
}
Expand All @@ -25,7 +26,13 @@ impl MachineDiagnostics {
palette: &Palette,
style: Style,
) -> Style {
if self.hover.as_ref() == Some(&endpoint.endpoint_id) && machine_retryable(endpoint) {
if self.hover.as_ref() == Some(&endpoint.endpoint_id)
&& (machine_retryable(endpoint)
|| matches!(
endpoint.status,
ClientEndpointStatus::Stopped(_) | ClientEndpointStatus::Offline(_)
))
{
style
.bg(palette.active_row_bg)
.add_modifier(Modifier::REVERSED)
Expand Down Expand Up @@ -115,6 +122,24 @@ impl ClientShellState {
std::mem::take(&mut self.machine_diagnostics.retry_requests)
}

pub(crate) fn take_machine_starts(&mut self) -> Vec<(ClientEndpointId, u64)> {
std::mem::take(&mut self.machine_diagnostics.start_requests)
}

pub(super) fn start_machine(&mut self, id: &ClientEndpointId) -> bool {
let Some(ClientEndpointStatus::Stopped(epoch) | ClientEndpointStatus::Offline(epoch)) =
self.endpoint_status(id)
else {
return false;
};
self.machine_diagnostics
.start_requests
.push((id.clone(), epoch));
self.machine_diagnostics.retry_reports.insert(id.clone());
self.set_endpoint_status(id, ClientEndpointStatus::Connecting);
true
}

/// Reports a failed connection attempt when the user asked for it.
pub(crate) fn report_machine_retry(&mut self, id: &ClientEndpointId) -> bool {
let requested = self.machine_diagnostics.retry_reports.remove(id);
Expand Down Expand Up @@ -146,7 +171,12 @@ impl ClientShellState {
.find(|hit| contains(hit.status_badge, (mouse.column, mouse.row)))
.filter(|hit| {
self.endpoints.iter().any(|endpoint| {
endpoint.endpoint_id == hit.endpoint_id && machine_retryable(endpoint)
endpoint.endpoint_id == hit.endpoint_id
&& (machine_retryable(endpoint)
|| matches!(
endpoint.status,
ClientEndpointStatus::Stopped(_) | ClientEndpointStatus::Offline(_)
))
})
})
.map(|hit| hit.endpoint_id.clone());
Expand All @@ -166,7 +196,7 @@ impl ClientShellState {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
return false;
}
outcome.repaint |= self.retry_machine(&id);
outcome.repaint |= self.start_machine(&id) || self.retry_machine(&id);
true
}

Expand Down
20 changes: 20 additions & 0 deletions src/client/shell/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str {
ClientEndpointStatus::Connecting => "connecting",
ClientEndpointStatus::Online => "online",
ClientEndpointStatus::Reconnecting => "reconnecting",
ClientEndpointStatus::Stopped(_) => "stopped",
ClientEndpointStatus::Offline(_) => "offline",
ClientEndpointStatus::Attention => "attention",
ClientEndpointStatus::Disabled => "disabled",
}
Expand Down Expand Up @@ -608,6 +610,21 @@ fn mobile_items(
background,
target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())),
});
if matches!(
endpoint.status,
ClientEndpointStatus::Stopped(_) | ClientEndpointStatus::Offline(_)
) {
items.push(MobileItem {
lines: vec![Line::from(Span::styled(
format!(" Start {}", endpoint.label),
Style::default().fg(palette.text).bg(background),
))],
background,
target: Some(ClientMobileTarget::StartMachine(
endpoint.endpoint_id.clone(),
)),
});
}
}
}
let agents = super::aggregate_navigation::aggregate_agent_rows(
Expand Down Expand Up @@ -1003,6 +1020,9 @@ impl ClientShellState {
.find(|(rect, _)| super::contains(*rect, point))
.map(|(_, target)| target.clone());
match target {
Some(ClientMobileTarget::StartMachine(endpoint_id)) => {
outcome.repaint |= self.start_machine(&endpoint_id);
}
Some(ClientMobileTarget::Machine(endpoint_id)) => {
if endpoint_id == self.active_endpoint_id {
self.mode = ClientShellMode::Terminal;
Expand Down
1 change: 1 addition & 0 deletions src/client/shell/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub(super) struct ClientShellLayout {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ClientMobileTarget {
Machine(ClientEndpointId),
StartMachine(ClientEndpointId),
NewWorkspace,
Workspace {
endpoint_id: ClientEndpointId,
Expand Down
Loading
Loading