aboutsummaryrefslogtreecommitdiff
path: root/src/client/endpoint
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/endpoint')
-rw-r--r--src/client/endpoint/activation.rs1152
-rw-r--r--src/client/endpoint/activation/model.rs174
-rw-r--r--src/client/endpoint/activation/protocol.rs219
-rw-r--r--src/client/endpoint/activation_tests.rs1305
-rw-r--r--src/client/endpoint/catalog.rs578
-rw-r--r--src/client/endpoint/control.rs63
-rw-r--r--src/client/endpoint/health.rs99
-rw-r--r--src/client/endpoint/message_policy.rs124
-rw-r--r--src/client/endpoint/registry.rs629
-rw-r--r--src/client/endpoint/supervisor.rs492
-rw-r--r--src/client/endpoint/writer.rs400
11 files changed, 5235 insertions, 0 deletions
diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs
new file mode 100644
index 00000000..a5b63344
--- /dev/null
+++ b/src/client/endpoint/activation.rs
@@ -0,0 +1,1152 @@
+use std::time::{Duration, Instant};
+
+use super::{ClientEndpointId, ClientEndpointStatus, EndpointRegistry, EndpointSendOutcome};
+
+mod model;
+mod protocol;
+pub(crate) use model::{
+ ActivationBeginError, ActivationCompletion, ActivationRollback, EndpointActivationIntent,
+ PendingEndpointActivation, SurfaceActivationProgress,
+};
+use model::{ActivationEvidence, ActivationPhase, EndpointLease};
+use protocol::*;
+
+const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5);
+
+impl PendingEndpointActivation {
+ #[allow(clippy::too_many_arguments)]
+ pub(crate) fn begin(
+ shell: &crate::client::shell::ClientShellState,
+ endpoints: &mut EndpointRegistry,
+ target: ClientEndpointId,
+ focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ resize: crate::protocol::ClientMessage,
+ serial: u64,
+ now: Instant,
+ ) -> Result<Self, ActivationBeginError> {
+ resize_geometry(&resize).ok_or_else(|| {
+ ActivationBeginError::Preflight(
+ "endpoint activation did not include a surface resize".to_owned(),
+ )
+ })?;
+ let source_id = endpoints.active_id().clone();
+ let source_has_live_surface = endpoints
+ .connection(&source_id)
+ .is_some_and(|connection| connection.surface_active);
+ let (source, source_available) = if source_has_live_surface {
+ (
+ endpoint_lease(shell, endpoints, &source_id)
+ .map_err(ActivationBeginError::Preflight)?,
+ true,
+ )
+ } else {
+ (disconnected_endpoint_lease(shell, &source_id), false)
+ };
+ let target_lease =
+ endpoint_lease(shell, endpoints, &target).map_err(ActivationBeginError::Preflight)?;
+ let source_compatible = !source_available
+ || endpoints
+ .connection(&source_id)
+ .is_some_and(|connection| connection.negotiation.supports_surface_interest());
+ let target_compatible = endpoints
+ .connection(&target)
+ .is_some_and(|connection| connection.negotiation.supports_surface_interest());
+ if !source_compatible || !target_compatible {
+ return Err(ActivationBeginError::Preflight(
+ "endpoint must be updated before it can join the selected surface".into(),
+ ));
+ }
+
+ let source_is_target = source.endpoint_id == target_lease.endpoint_id;
+ // Validate every typed lifecycle and optional focus envelope before the first transport
+ // write. Any error above this line is guaranteed not to have changed either endpoint.
+ let source_release_request = (source_available && !source_is_target)
+ .then(|| {
+ surface_interest_request(
+ &source.boot_id,
+ format!("client-shell-surface:{serial}:off"),
+ false,
+ )
+ })
+ .transpose()
+ .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?;
+ surface_interest_request(
+ &target_lease.boot_id,
+ format!("client-shell-surface:{serial}:on"),
+ true,
+ )
+ .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?;
+ if let Some(target) = focus.as_ref() {
+ focus_request(
+ &target_lease.boot_id,
+ format!("client-shell-focus:{serial}:1"),
+ target,
+ )
+ .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?;
+ }
+
+ endpoints.freeze_input();
+ let mut activation = Self {
+ source,
+ source_available,
+ target: target_lease,
+ focus,
+ host_focused: shell.host_focus_baseline(),
+ resize: resize.clone(),
+ phase: ActivationPhase::ReleasingSource {
+ request_id: format!("client-shell-surface:{serial}:off"),
+ },
+ deadline: now + ACTIVATION_TIMEOUT,
+ epoch: serial,
+ next_focus_serial: 0,
+ rollback_error: None,
+ successor: None,
+ };
+
+ // Reconnecting the selected endpoint has no live source surface to release. All normal
+ // handoffs must make the source locally inactive before a target request is even sent.
+ if source_is_target || !source_available {
+ if let Err(error) = activation.start_target(endpoints, resize) {
+ return Err(ActivationBeginError::Partial {
+ activation: Box::new(activation),
+ error,
+ });
+ }
+ } else {
+ // `surface.set(false)` removes the viewer, but old servers only emit the PTY focus
+ // loss while the viewer is still active. Revoke it explicitly before source-off.
+ if endpoints.send_to(
+ &activation.source.endpoint_id,
+ &crate::protocol::ClientMessage::ClientShellFocus { focused: false },
+ ) != EndpointSendOutcome::Sent
+ {
+ return Err(ActivationBeginError::Partial {
+ activation: Box::new(activation),
+ error: "source endpoint focus revoke could not be sent".into(),
+ });
+ }
+ let request = source_release_request.expect("validated source release request");
+ if endpoints.send_to(&activation.source.endpoint_id, &request)
+ != EndpointSendOutcome::Sent
+ {
+ return Err(ActivationBeginError::Partial {
+ activation: Box::new(activation),
+ error: "source endpoint release could not be sent".into(),
+ });
+ }
+ // This is deliberately before the acknowledgement: the source is no longer viewed
+ // locally while its release is in flight, and pane input is consequently blocked.
+ endpoints.set_surface_active(&activation.source.endpoint_id, false);
+ }
+ Ok(activation)
+ }
+
+ pub(crate) fn target(&self) -> &ClientEndpointId {
+ &self.target.endpoint_id
+ }
+
+ fn geometry(&self) -> crate::protocol::ClientSurfaceSize {
+ resize_geometry(&self.resize).expect("activation resize was validated before construction")
+ }
+
+ pub(crate) fn presentation_sync_endpoint(&self) -> Option<&ClientEndpointId> {
+ match self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ _ => None,
+ }
+ }
+
+ /// The complete source command lane cannot safely cross source-off into a later presentation
+ /// epoch. Other endpoint lanes are not part of this retirement.
+ pub(crate) fn source_command_lane(&self) -> Option<&ClientEndpointId> {
+ (self.source_available && self.source.endpoint_id != self.target.endpoint_id)
+ .then_some(&self.source.endpoint_id)
+ }
+
+ pub(crate) fn can_retarget(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.target.endpoint_id == *endpoint_id
+ && self.successor.is_none()
+ && matches!(
+ self.phase,
+ ActivationPhase::ReleasingSource { .. } | ActivationPhase::ActivatingTarget { .. }
+ )
+ }
+
+ /// Replace an in-flight handoff with the latest endpoint-qualified intent. The current
+ /// transaction is still reversed through target-off/source-on; the replacement is launched
+ /// by the caller only after the source's coherent restoration commits.
+ pub(crate) fn supersede(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ target: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ endpoints: &mut EndpointRegistry,
+ ) -> ActivationRollback {
+ self.successor = Some(EndpointActivationIntent {
+ endpoint_id,
+ target,
+ });
+ // Source-on is already ordered and must finish before any replacement is allowed to
+ // begin. Later rapid selections only replace the retained intent; they never turn a
+ // safe restoration into an unavailable state.
+ let source_restoration_in_flight = matches!(
+ self.phase,
+ ActivationPhase::RestoringSource { .. }
+ ) || matches!(
+ &self.phase,
+ ActivationPhase::SynchronizingPresentation { completion, .. }
+ if matches!(completion.as_ref(), ActivationCompletion::RestoredSource { .. })
+ );
+ if source_restoration_in_flight {
+ return ActivationRollback::Pending;
+ }
+ self.rollback(
+ endpoints,
+ "endpoint handoff superseded by a newer selection".into(),
+ false,
+ )
+ }
+
+ pub(crate) fn accepts_endpoint(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool {
+ (self.source_available
+ && self.source.endpoint_id == *endpoint_id
+ && self.source.generation == generation)
+ || (self.target.endpoint_id == *endpoint_id && self.target.generation == generation)
+ }
+
+ pub(crate) fn involves_endpoint(&self, endpoint_id: &ClientEndpointId) -> bool {
+ self.source.endpoint_id == *endpoint_id || self.target.endpoint_id == *endpoint_id
+ }
+
+ pub(crate) fn accepts_response(
+ &self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+ request_id: &str,
+ ) -> bool {
+ match &self.phase {
+ ActivationPhase::ReleasingSource {
+ request_id: expected,
+ } => {
+ endpoint_matches(&self.source, endpoint_id, generation, boot_id)
+ && expected == request_id
+ }
+ ActivationPhase::ActivatingTarget {
+ request_id: expected,
+ focus_request_id,
+ ..
+ } => {
+ endpoint_matches(&self.target, endpoint_id, generation, boot_id)
+ && (expected == request_id || focus_request_id.as_deref() == Some(request_id))
+ }
+ ActivationPhase::ReleasingTargetForRollback {
+ request_id: expected,
+ } => {
+ endpoint_matches(&self.target, endpoint_id, generation, boot_id)
+ && expected == request_id
+ }
+ ActivationPhase::RestoringSource {
+ request_id: expected,
+ ..
+ } => {
+ endpoint_matches(&self.source, endpoint_id, generation, boot_id)
+ && expected == request_id
+ }
+ ActivationPhase::SynchronizingPresentation {
+ lease,
+ request_id: expected,
+ ..
+ } => {
+ endpoint_matches(lease, endpoint_id, generation, boot_id) && expected == request_id
+ }
+ ActivationPhase::AwaitingPresentationEffects { .. } => false,
+ }
+ }
+
+ pub(crate) fn expired(&self, now: Instant) -> bool {
+ now >= self.deadline
+ }
+
+ #[cfg(test)]
+ pub(crate) fn receive_response(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ request_id: &str,
+ data: &[u8],
+ endpoints: &mut EndpointRegistry,
+ ) -> SurfaceActivationProgress {
+ let boot_id = if self.source.endpoint_id == *endpoint_id {
+ self.source.boot_id.clone()
+ } else {
+ self.target.boot_id.clone()
+ };
+ self.receive_response_for_boot(
+ endpoint_id,
+ generation,
+ &boot_id,
+ request_id,
+ data,
+ endpoints,
+ )
+ }
+
+ pub(crate) fn receive_response_for_boot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+ request_id: &str,
+ data: &[u8],
+ endpoints: &mut EndpointRegistry,
+ ) -> SurfaceActivationProgress {
+ if !self.accepts_response(endpoint_id, generation, boot_id, request_id) {
+ return SurfaceActivationProgress::Stale;
+ }
+ let result = match decode_endpoint_response(request_id, data) {
+ Ok(result) => result,
+ Err(error) => {
+ return SurfaceActivationProgress::Rejected {
+ source_release_rejected: matches!(
+ self.phase,
+ ActivationPhase::ReleasingSource { .. }
+ ) && error.code.is_some(),
+ message: error.message,
+ };
+ }
+ };
+ match &mut self.phase {
+ ActivationPhase::ReleasingSource { .. } => {
+ if let Err(message) = surface_set_revision(&result, false) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ if let Err(message) = self.start_target(endpoints, self.resize.clone()) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ SurfaceActivationProgress::Pending
+ }
+ ActivationPhase::ActivatingTarget {
+ request_id: surface_request_id,
+ acknowledged_revision,
+ ..
+ } if surface_request_id == request_id => {
+ let revision = match surface_set_revision(&result, true) {
+ Ok(revision) => revision,
+ Err(message) => {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ };
+ *acknowledged_revision = Some(revision);
+ self.progress()
+ }
+ ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_request_target,
+ focus_acknowledged,
+ ..
+ } if focus_request_id.as_deref() == Some(request_id) => {
+ let requested = focus_request_target.clone();
+ let Some(requested) = requested else {
+ return SurfaceActivationProgress::Stale;
+ };
+ if !focus_result_matches(Some(&requested), &result) {
+ return SurfaceActivationProgress::Rejected {
+ message: "endpoint focus returned an unexpected result".into(),
+ source_release_rejected: false,
+ };
+ }
+ *focus_request_id = None;
+ *focus_request_target = None;
+ if self.focus != Some(requested) {
+ *focus_acknowledged = false;
+ if let Err(message) = self.send_latest_focus(endpoints) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ return self.progress();
+ }
+ *focus_acknowledged = true;
+ self.progress()
+ }
+ ActivationPhase::ReleasingTargetForRollback { .. } => {
+ if let Err(message) = surface_set_revision(&result, false) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ endpoints.set_surface_active(&self.target.endpoint_id, false);
+ if !self.source_available {
+ return SurfaceActivationProgress::Rejected {
+ message: self.rollback_error.clone().unwrap_or_else(|| {
+ "the previous endpoint is no longer connected".into()
+ }),
+ source_release_rejected: false,
+ };
+ }
+ if let Err(message) = self.start_source_restore(endpoints, self.resize.clone()) {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ SurfaceActivationProgress::Pending
+ }
+ ActivationPhase::RestoringSource {
+ acknowledged_revision,
+ ..
+ } => {
+ let revision = match surface_set_revision(&result, true) {
+ Ok(revision) => revision,
+ Err(message) => {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ };
+ *acknowledged_revision = Some(revision);
+ self.progress()
+ }
+ ActivationPhase::SynchronizingPresentation {
+ acknowledged_revision,
+ ..
+ } => {
+ let revision = match surface_set_revision(&result, true) {
+ Ok(revision) => revision,
+ Err(message) => {
+ return SurfaceActivationProgress::Rejected {
+ message,
+ source_release_rejected: false,
+ };
+ }
+ };
+ *acknowledged_revision = Some(revision);
+ self.progress()
+ }
+ _ => SurfaceActivationProgress::Stale,
+ }
+ }
+
+ pub(crate) fn receive_snapshot(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ snapshot: &crate::protocol::ClientShellSnapshot,
+ ) -> SurfaceActivationProgress {
+ let lease = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => &self.target,
+ ActivationPhase::RestoringSource { .. } => &self.source,
+ ActivationPhase::SynchronizingPresentation { lease, .. } => lease,
+ _ => return SurfaceActivationProgress::Stale,
+ };
+ if !endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id)
+ || snapshot.revision < lease.minimum_revision
+ {
+ return SurfaceActivationProgress::Stale;
+ }
+ let evidence = match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ if endpoint_matches(&self.target, endpoint_id, generation, &snapshot.boot_id) =>
+ {
+ evidence
+ }
+ ActivationPhase::RestoringSource { evidence, .. }
+ if endpoint_matches(&self.source, endpoint_id, generation, &snapshot.boot_id) =>
+ {
+ evidence
+ }
+ ActivationPhase::SynchronizingPresentation {
+ lease, evidence, ..
+ } if endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) => evidence,
+ _ => return SurfaceActivationProgress::Stale,
+ };
+ evidence.record_snapshot(snapshot);
+ self.progress()
+ }
+
+ pub(crate) fn receive_surface(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ surface: crate::protocol::PaneSurfaceFrame,
+ ) -> SurfaceActivationProgress {
+ let lease = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => &self.target,
+ ActivationPhase::RestoringSource { .. } => &self.source,
+ ActivationPhase::SynchronizingPresentation { lease, .. } => lease,
+ _ => return SurfaceActivationProgress::Stale,
+ };
+ if !endpoint_matches(lease, endpoint_id, generation, &surface.boot_id) {
+ return SurfaceActivationProgress::Stale;
+ }
+ if !surface_matches_geometry(&surface, self.geometry()) {
+ return SurfaceActivationProgress::Pending;
+ }
+ match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ | ActivationPhase::RestoringSource { evidence, .. }
+ | ActivationPhase::SynchronizingPresentation { evidence, .. } => {
+ evidence.record_surface(surface)
+ }
+ _ => unreachable!("checked activation phase"),
+ }
+ self.progress()
+ }
+
+ pub(crate) fn receive_presentation_effects_ready(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ token: &str,
+ ) -> SurfaceActivationProgress {
+ let ActivationPhase::AwaitingPresentationEffects {
+ lease,
+ token: expected,
+ ready,
+ ..
+ } = &mut self.phase
+ else {
+ return SurfaceActivationProgress::Stale;
+ };
+ if lease.endpoint_id != *endpoint_id || lease.generation != generation || expected != token
+ {
+ return SurfaceActivationProgress::Stale;
+ }
+ *ready = true;
+ SurfaceActivationProgress::Ready
+ }
+
+ /// A same-endpoint navigation request replaces the desired target but never joins the
+ /// in-flight focus RPC. Once that request resolves, `send_latest_focus` sends only the most
+ /// recent desired target.
+ pub(crate) fn retarget(
+ &mut self,
+ focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ self.focus = focus;
+ if let ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_acknowledged,
+ ..
+ } = &mut self.phase
+ {
+ *focus_acknowledged = self.focus.is_none() && focus_request_id.is_none();
+ } else {
+ // The latest target is retained and will be sent immediately after source release.
+ return Ok(());
+ }
+ self.send_latest_focus(endpoints)
+ }
+
+ pub(crate) fn update_resize(
+ &mut self,
+ resize: crate::protocol::ClientMessage,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ resize_geometry(&resize)
+ .ok_or_else(|| "endpoint activation did not include a surface resize".to_owned())?;
+ self.resize = resize.clone();
+ let restart_effects_fence = match &self.phase {
+ ActivationPhase::AwaitingPresentationEffects {
+ lease, completion, ..
+ } => Some((lease.clone(), (**completion).clone())),
+ _ => None,
+ };
+ if let Some((lease, completion)) = restart_effects_fence {
+ if endpoints.send_to(&lease.endpoint_id, &resize) != EndpointSendOutcome::Sent {
+ return Err("pending endpoint resize could not be sent".into());
+ }
+ return self.start_presentation_sync(endpoints, lease, completion);
+ }
+ match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ | ActivationPhase::RestoringSource { evidence, .. }
+ | ActivationPhase::SynchronizingPresentation { evidence, .. } => {
+ evidence.invalidate_surface()
+ }
+ _ => {}
+ }
+ let destination = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ ActivationPhase::SynchronizingPresentation { lease, .. } => Some(&lease.endpoint_id),
+ _ => None,
+ };
+ if let Some(destination) = destination {
+ if endpoints.send_to(destination, &resize) != EndpointSendOutcome::Sent {
+ return Err("pending endpoint resize could not be sent".into());
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn update_host_focus(
+ &mut self,
+ focused: bool,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ self.host_focused = focused;
+ let restart = self.presentation_restart();
+ let destination = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ ActivationPhase::SynchronizingPresentation { lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { lease, .. } => {
+ Some(&lease.endpoint_id)
+ }
+ _ => None,
+ };
+ if let Some(destination) = destination {
+ if endpoints.send_to(
+ destination,
+ &crate::protocol::ClientMessage::ClientShellFocus { focused },
+ ) != EndpointSendOutcome::Sent
+ {
+ return Err("pending endpoint focus baseline could not be sent".into());
+ }
+ }
+ if let Some((lease, completion)) = restart {
+ self.start_presentation_sync(endpoints, lease, completion)?;
+ } else {
+ self.invalidate_current_evidence();
+ }
+ Ok(())
+ }
+
+ pub(crate) fn update_host_theme(
+ &mut self,
+ update: crate::protocol::ClientHostThemeUpdate,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<(), String> {
+ let restart = self.presentation_restart();
+ let destination = match &self.phase {
+ ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id),
+ ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id),
+ ActivationPhase::SynchronizingPresentation { lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { lease, .. } => {
+ Some(&lease.endpoint_id)
+ }
+ _ => None,
+ };
+ if let Some(destination) = destination {
+ let message = crate::protocol::ClientMessage::ClientShellHostTheme { update };
+ if endpoints.send_to(destination, &message) != EndpointSendOutcome::Sent {
+ return Err("pending endpoint host theme could not be sent".into());
+ }
+ }
+ if let Some((lease, completion)) = restart {
+ self.start_presentation_sync(endpoints, lease, completion)?;
+ } else {
+ self.invalidate_current_evidence();
+ }
+ Ok(())
+ }
+
+ fn presentation_restart(&self) -> Option<(EndpointLease, ActivationCompletion)> {
+ match &self.phase {
+ ActivationPhase::SynchronizingPresentation {
+ lease, completion, ..
+ }
+ | ActivationPhase::AwaitingPresentationEffects {
+ lease, completion, ..
+ } => Some((lease.clone(), (**completion).clone())),
+ _ => None,
+ }
+ }
+
+ fn invalidate_current_evidence(&mut self) {
+ match &mut self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. }
+ | ActivationPhase::RestoringSource { evidence, .. } => evidence.invalidate_surface(),
+ _ => {}
+ }
+ }
+
+ /// Losing the source revokes its surface and removes the rollback destination; it must not
+ /// cancel a healthy target. Losing the target restores the source when it is still available.
+ pub(crate) fn endpoint_disconnected(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ endpoint_id: &ClientEndpointId,
+ error: String,
+ ) -> ActivationRollback {
+ self.rollback_error = Some(error.clone());
+ if self.target.endpoint_id == *endpoint_id && self.source.endpoint_id != *endpoint_id {
+ return match self.phase {
+ ActivationPhase::RestoringSource { .. } => ActivationRollback::Pending,
+ _ => match self.start_source_restore(endpoints, self.resize.clone()) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(restore_error) => ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not be restored safely: {restore_error}"
+ )),
+ },
+ };
+ }
+ if self.source.endpoint_id != *endpoint_id {
+ return ActivationRollback::Unavailable(error);
+ }
+ self.source_available = false;
+ if self.source.endpoint_id != self.target.endpoint_id {
+ match &self.phase {
+ ActivationPhase::ReleasingSource { .. } => {
+ return match self.start_target(endpoints, self.resize.clone()) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(message) => ActivationRollback::Unavailable(message),
+ };
+ }
+ ActivationPhase::ActivatingTarget { .. } => return ActivationRollback::Pending,
+ ActivationPhase::SynchronizingPresentation { lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { lease, .. }
+ if lease.endpoint_id == self.target.endpoint_id =>
+ {
+ return ActivationRollback::Pending
+ }
+ _ => {}
+ }
+ }
+ match self.phase {
+ ActivationPhase::ReleasingSource { .. } => ActivationRollback::Unavailable(error),
+ ActivationPhase::ActivatingTarget { .. } => {
+ match self.start_target_release(endpoints) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(release_error) => ActivationRollback::Unavailable(format!(
+ "{error}; target endpoint could not be released safely: {release_error}"
+ )),
+ }
+ }
+ ActivationPhase::ReleasingTargetForRollback { .. } => ActivationRollback::Pending,
+ ActivationPhase::RestoringSource { .. } => ActivationRollback::Unavailable(error),
+ ActivationPhase::SynchronizingPresentation { ref lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => {
+ if lease.endpoint_id == self.target.endpoint_id {
+ match self.start_target_release(endpoints) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(release_error) => ActivationRollback::Unavailable(format!(
+ "{error}; target endpoint could not be released safely: {release_error}"
+ )),
+ }
+ } else {
+ ActivationRollback::Unavailable(error)
+ }
+ }
+ }
+ }
+
+ pub(crate) fn rollback(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ error: String,
+ source_release_rejected: bool,
+ ) -> ActivationRollback {
+ self.rollback_error = Some(error.clone());
+ if matches!(self.phase, ActivationPhase::ReleasingSource { .. }) && source_release_rejected
+ {
+ // Rejection proves source-off did not commit, but cached source metadata may have
+ // advanced while the frame was frozen. Restore through the same coherent on/sync
+ // path rather than immediately exposing a stale source projection.
+ return match self.start_source_restore(endpoints, self.resize.clone()) {
+ Ok(()) => ActivationRollback::Pending,
+ Err(restore_error) => ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not resume: {restore_error}"
+ )),
+ };
+ }
+ let result = match self.phase {
+ ActivationPhase::ReleasingSource { .. } => {
+ if self.source_available {
+ self.start_source_restore(endpoints, self.resize.clone())
+ } else {
+ Err("the previous endpoint is no longer connected".into())
+ }
+ }
+ ActivationPhase::ActivatingTarget { .. } => self.start_target_release(endpoints),
+ ActivationPhase::ReleasingTargetForRollback { .. } => {
+ // The target may have observed target-on or target-off. Closing this transport
+ // is the only safe local revocation when target-off is not acknowledged.
+ endpoints.fail(
+ &self.target.endpoint_id,
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "endpoint did not acknowledge surface revocation",
+ ),
+ );
+ if !self.source_available {
+ return ActivationRollback::Unavailable(format!(
+ "{error}; the target connection was closed because no presentation owner could be proven"
+ ));
+ }
+ self.start_source_restore(endpoints, self.resize.clone())
+ }
+ ActivationPhase::RestoringSource { .. } => {
+ return ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not be restored"
+ ));
+ }
+ ActivationPhase::SynchronizingPresentation { ref lease, .. }
+ | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => {
+ if lease.endpoint_id == self.target.endpoint_id {
+ self.start_target_release(endpoints)
+ } else {
+ return ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint presentation could not be synchronized"
+ ));
+ }
+ }
+ };
+ match result {
+ Ok(()) => ActivationRollback::Pending,
+ Err(rollback_error) => ActivationRollback::Unavailable(format!(
+ "{error}; source endpoint could not be restored safely: {rollback_error}"
+ )),
+ }
+ }
+
+ pub(crate) fn complete(
+ &mut self,
+ shell: &mut crate::client::shell::ClientShellState,
+ endpoints: &mut EndpointRegistry,
+ ) -> Result<ActivationCompletion, String> {
+ if let ActivationPhase::SynchronizingPresentation {
+ lease,
+ acknowledged_revision,
+ evidence,
+ completion,
+ ..
+ } = &self.phase
+ {
+ let lease = lease.clone();
+ let completion = (**completion).clone();
+ let surface = coherent_completion_surface(
+ shell,
+ &lease,
+ evidence,
+ *acknowledged_revision,
+ self.geometry(),
+ )?;
+ if endpoints.active_id() != &lease.endpoint_id
+ || !shell.endpoint_projection_available(&lease.endpoint_id)
+ || !shell.activate_endpoint_projection(&lease.endpoint_id)
+ {
+ return Err(
+ "endpoint became unavailable during presentation synchronization".into(),
+ );
+ }
+ shell.set_pane_surface(surface);
+ self.start_presentation_effects_fence(endpoints, lease, completion)?;
+ return Ok(ActivationCompletion::AwaitingPresentationEffects);
+ }
+ if let ActivationPhase::AwaitingPresentationEffects {
+ ready: true,
+ completion,
+ ..
+ } = &self.phase
+ {
+ return Ok((**completion).clone());
+ }
+
+ let (lease, evidence, acknowledgement_revision, completion) = match &self.phase {
+ ActivationPhase::ActivatingTarget {
+ evidence,
+ acknowledged_revision,
+ ..
+ } => {
+ let completion = ActivationCompletion::Activated;
+ (
+ self.target.clone(),
+ evidence,
+ *acknowledged_revision,
+ completion,
+ )
+ }
+ ActivationPhase::RestoringSource {
+ evidence,
+ acknowledged_revision,
+ ..
+ } => {
+ let completion = ActivationCompletion::RestoredSource {
+ error: self
+ .rollback_error
+ .clone()
+ .unwrap_or_else(|| "endpoint handoff was rolled back".into()),
+ successor: self.successor.clone(),
+ };
+ (
+ self.source.clone(),
+ evidence,
+ *acknowledged_revision,
+ completion,
+ )
+ }
+ _ => return Err("endpoint activation completed in an invalid phase".into()),
+ };
+ let surface = coherent_completion_surface(
+ shell,
+ &lease,
+ evidence,
+ acknowledgement_revision,
+ self.geometry(),
+ )?;
+ endpoints.set_surface_active(&lease.endpoint_id, true);
+ shell.set_endpoint_status(&lease.endpoint_id, ClientEndpointStatus::Online);
+ if !shell.endpoint_projection_available(&lease.endpoint_id)
+ || !endpoints.set_active(&lease.endpoint_id)
+ {
+ return Err("endpoint became unavailable during activation".into());
+ }
+ let activated = shell.activate_endpoint_projection(&lease.endpoint_id);
+ debug_assert!(activated, "preflighted endpoint projection must activate");
+ debug_assert!(shell.endpoint_is_active(endpoints.active_id()));
+ shell.set_pane_surface(surface);
+ self.start_presentation_sync(endpoints, lease.clone(), completion)?;
+ Ok(ActivationCompletion::AwaitingPresentationSync {
+ previous: self.source.endpoint_id.clone(),
+ endpoint: lease.endpoint_id,
+ })
+ }
+
+ fn start_target(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ resize: crate::protocol::ClientMessage,
+ ) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:on", self.epoch);
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ // A transport may fail after writing any baseline or surface message. Enter the target
+ // phase first so every uncertain target write is reversed through target-off before
+ // source restoration is considered.
+ self.phase = ActivationPhase::ActivatingTarget {
+ request_id: request_id.clone(),
+ acknowledged_revision: None,
+ focus_request_id: None,
+ focus_request_target: None,
+ focus_acknowledged: self.focus.is_none(),
+ evidence: ActivationEvidence::default(),
+ };
+ send_surface_activation(
+ endpoints,
+ &self.target,
+ request_id,
+ &resize,
+ self.host_focused,
+ )?;
+
+ // From this point the target may have processed surface.set(true). Optional navigation
+ // is serialized through one coalescing focus lane.
+ self.send_latest_focus(endpoints)
+ }
+
+ fn start_presentation_sync(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ lease: EndpointLease,
+ completion: ActivationCompletion,
+ ) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:presentation-sync", self.epoch);
+ let request = surface_interest_request(&lease.boot_id, request_id.clone(), true)
+ .map_err(|error| error.to_string())?;
+ self.phase = ActivationPhase::SynchronizingPresentation {
+ lease: lease.clone(),
+ request_id,
+ acknowledged_revision: None,
+ evidence: ActivationEvidence::default(),
+ completion: Box::new(completion),
+ };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ if endpoints.send_to(&lease.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("endpoint presentation synchronization could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn start_presentation_effects_fence(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ lease: EndpointLease,
+ completion: ActivationCompletion,
+ ) -> Result<(), String> {
+ let token = format!("{}:{}:{}", self.epoch, lease.generation, lease.boot_id);
+ self.phase = ActivationPhase::AwaitingPresentationEffects {
+ lease: lease.clone(),
+ token: token.clone(),
+ ready: false,
+ completion: Box::new(completion),
+ };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ let message = crate::protocol::ClientMessage::EndpointControl {
+ kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND.into(),
+ data: token,
+ };
+ if endpoints.send_to(&lease.endpoint_id, &message) != EndpointSendOutcome::Sent {
+ return Err("endpoint presentation effects fence could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn start_target_release(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:rollback-target-off", self.epoch);
+ let request = surface_interest_request(&self.target.boot_id, request_id.clone(), false)
+ .map_err(|error| error.to_string())?;
+ // Set the rollback phase before the potentially observed target-off write.
+ self.phase = ActivationPhase::ReleasingTargetForRollback { request_id };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("target endpoint release could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn start_source_restore(
+ &mut self,
+ endpoints: &mut EndpointRegistry,
+ resize: crate::protocol::ClientMessage,
+ ) -> Result<(), String> {
+ let request_id = format!("client-shell-surface:{}:rollback-source-on", self.epoch);
+ // Source baseline writes can also be observed before their send reports an error.
+ self.phase = ActivationPhase::RestoringSource {
+ request_id: request_id.clone(),
+ acknowledged_revision: None,
+ evidence: ActivationEvidence::default(),
+ };
+ self.deadline = Instant::now() + ACTIVATION_TIMEOUT;
+ send_surface_activation(
+ endpoints,
+ &self.source,
+ request_id,
+ &resize,
+ self.host_focused,
+ )
+ }
+
+ fn send_latest_focus(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> {
+ let desired = self.focus.clone();
+ let Some(desired) = desired else {
+ if let ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_acknowledged,
+ ..
+ } = &mut self.phase
+ {
+ if focus_request_id.is_none() {
+ *focus_acknowledged = true;
+ }
+ }
+ return Ok(());
+ };
+ if !matches!(
+ &self.phase,
+ ActivationPhase::ActivatingTarget {
+ focus_request_id: None,
+ ..
+ }
+ ) {
+ return Ok(());
+ }
+ let request_id = self
+ .next_focus_request_id()
+ .expect("desired focus creates a request id");
+ if let ActivationPhase::ActivatingTarget {
+ focus_request_id,
+ focus_request_target,
+ focus_acknowledged,
+ ..
+ } = &mut self.phase
+ {
+ *focus_acknowledged = false;
+ *focus_request_id = Some(request_id.clone());
+ *focus_request_target = Some(desired.clone());
+ }
+ let request = focus_request(&self.target.boot_id, request_id, &desired)
+ .map_err(|error| error.to_string())?;
+ if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("endpoint focus could not be sent".into());
+ }
+ Ok(())
+ }
+
+ fn next_focus_request_id(&mut self) -> Option<String> {
+ self.focus.as_ref()?;
+ self.next_focus_serial = self.next_focus_serial.saturating_add(1);
+ Some(format!(
+ "client-shell-focus:{}:{}",
+ self.epoch, self.next_focus_serial
+ ))
+ }
+
+ fn progress(&self) -> SurfaceActivationProgress {
+ match &self.phase {
+ ActivationPhase::ActivatingTarget {
+ acknowledged_revision,
+ focus_acknowledged,
+ evidence,
+ ..
+ } if acknowledged_revision.is_some_and(|revision| {
+ *focus_acknowledged
+ && evidence
+ .coherent_surface(revision, self.geometry())
+ .is_some_and(|surface| self.target_matches(surface))
+ }) =>
+ {
+ SurfaceActivationProgress::Ready
+ }
+ ActivationPhase::RestoringSource {
+ acknowledged_revision,
+ evidence,
+ ..
+ }
+ | ActivationPhase::SynchronizingPresentation {
+ acknowledged_revision,
+ evidence,
+ ..
+ } if acknowledged_revision.is_some_and(|revision| {
+ evidence
+ .coherent_surface(revision, self.geometry())
+ .is_some()
+ }) =>
+ {
+ SurfaceActivationProgress::Ready
+ }
+ _ => SurfaceActivationProgress::Pending,
+ }
+ }
+
+ fn target_matches(&self, surface: &crate::protocol::PaneSurfaceFrame) -> bool {
+ let evidence = match &self.phase {
+ ActivationPhase::ActivatingTarget { evidence, .. } => evidence,
+ _ => return false,
+ };
+ match &self.focus {
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => {
+ evidence.focused_pane_id.as_deref() == Some(pane_id)
+ && surface
+ .panes
+ .iter()
+ .any(|pane| pane.focused && &pane.pane_id == pane_id)
+ }
+ Some(crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id)) => {
+ evidence.focused_tab_id.as_deref() == Some(tab_id)
+ }
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id)) => {
+ evidence.focused_workspace_id.as_deref() == Some(workspace_id)
+ }
+ None => true,
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "activation_tests.rs"]
+mod tests;
diff --git a/src/client/endpoint/activation/model.rs b/src/client/endpoint/activation/model.rs
new file mode 100644
index 00000000..c94029e9
--- /dev/null
+++ b/src/client/endpoint/activation/model.rs
@@ -0,0 +1,174 @@
+use std::time::Instant;
+
+use super::super::ClientEndpointId;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) struct EndpointLease {
+ pub(super) endpoint_id: ClientEndpointId,
+ pub(super) generation: u64,
+ pub(super) boot_id: String,
+ /// The endpoint cache may already be newer than the first activation event. Never let an
+ /// activation prove coherence with a revision that the monotonic cache has discarded.
+ pub(super) minimum_revision: u64,
+}
+
+#[derive(Clone, Debug, Default)]
+pub(super) struct ActivationEvidence {
+ pub(super) snapshot_revision: Option<u64>,
+ pub(super) focused_workspace_id: Option<String>,
+ pub(super) focused_tab_id: Option<String>,
+ pub(super) focused_pane_id: Option<String>,
+ pub(super) surface: Option<crate::protocol::PaneSurfaceFrame>,
+}
+
+impl ActivationEvidence {
+ pub(super) fn record_snapshot(&mut self, snapshot: &crate::protocol::ClientShellSnapshot) {
+ if self
+ .snapshot_revision
+ .is_none_or(|current| snapshot.revision >= current)
+ {
+ self.snapshot_revision = Some(snapshot.revision);
+ self.focused_workspace_id = snapshot.focused_workspace_id.clone();
+ self.focused_tab_id = snapshot.focused_tab_id.clone();
+ self.focused_pane_id = snapshot.focused_pane_id.clone();
+ }
+ }
+
+ pub(super) fn record_surface(&mut self, surface: crate::protocol::PaneSurfaceFrame) {
+ let replace = self.surface.as_ref().is_none_or(|current| {
+ surface.projection_revision > current.projection_revision
+ || (surface.projection_revision == current.projection_revision
+ && surface.surface_revision >= current.surface_revision)
+ });
+ if replace {
+ self.surface = Some(surface);
+ }
+ }
+
+ pub(super) fn invalidate_surface(&mut self) {
+ self.surface = None;
+ }
+
+ pub(super) fn coherent_surface(
+ &self,
+ minimum_revision: u64,
+ geometry: crate::protocol::ClientSurfaceSize,
+ ) -> Option<&crate::protocol::PaneSurfaceFrame> {
+ self.surface.as_ref().filter(|surface| {
+ self.snapshot_revision == Some(surface.projection_revision)
+ && surface.projection_revision >= minimum_revision
+ && super::surface_matches_geometry(surface, geometry)
+ })
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(super) enum ActivationPhase {
+ ReleasingSource {
+ request_id: String,
+ },
+ ActivatingTarget {
+ request_id: String,
+ acknowledged_revision: Option<u64>,
+ /// At most one focus request may be in flight. Retargets only replace `focus` until
+ /// this response arrives, at which point the latest target is sent.
+ focus_request_id: Option<String>,
+ focus_request_target: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ focus_acknowledged: bool,
+ evidence: ActivationEvidence,
+ },
+ ReleasingTargetForRollback {
+ request_id: String,
+ },
+ RestoringSource {
+ request_id: String,
+ acknowledged_revision: Option<u64>,
+ evidence: ActivationEvidence,
+ },
+ SynchronizingPresentation {
+ lease: EndpointLease,
+ request_id: String,
+ acknowledged_revision: Option<u64>,
+ evidence: ActivationEvidence,
+ completion: Box<ActivationCompletion>,
+ },
+ AwaitingPresentationEffects {
+ lease: EndpointLease,
+ token: String,
+ ready: bool,
+ completion: Box<ActivationCompletion>,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum SurfaceActivationProgress {
+ Pending,
+ Ready,
+ Rejected {
+ message: String,
+ source_release_rejected: bool,
+ },
+ Stale,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ActivationRollback {
+ /// A correlated rollback request is in flight; retain the frozen frame.
+ Pending,
+ /// Neither endpoint can be made safe to present. Keep pane input frozen while presenting
+ /// client chrome and this error.
+ Unavailable(String),
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ActivationCompletion {
+ AwaitingPresentationSync {
+ previous: ClientEndpointId,
+ endpoint: ClientEndpointId,
+ },
+ AwaitingPresentationEffects,
+ Activated,
+ RestoredSource {
+ error: String,
+ /// A newer endpoint-qualified selection arrived while this handoff was frozen. It is
+ /// started only after the original source has been coherently restored.
+ successor: Option<EndpointActivationIntent>,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct EndpointActivationIntent {
+ pub(crate) endpoint_id: ClientEndpointId,
+ pub(crate) target: Option<crate::client::shell::ClientEndpointFocusTarget>,
+}
+
+/// Begin failures are separated by whether the transport may already have observed a lifecycle
+/// write. A partial transaction must be kept and rolled back under a frozen presentation.
+#[derive(Debug)]
+pub(crate) enum ActivationBeginError {
+ Preflight(String),
+ Partial {
+ activation: Box<PendingEndpointActivation>,
+ error: String,
+ },
+}
+
+/// The only owner of an endpoint handoff. The registry's active endpoint remains the committed
+/// endpoint until the target commits, while this object owns the uncommitted lifecycle lane.
+#[derive(Debug)]
+pub(crate) struct PendingEndpointActivation {
+ pub(super) source: EndpointLease,
+ pub(super) source_available: bool,
+ pub(super) target: EndpointLease,
+ pub(super) focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
+ pub(super) host_focused: bool,
+ pub(super) resize: crate::protocol::ClientMessage,
+ pub(super) phase: ActivationPhase,
+ pub(super) deadline: Instant,
+ pub(super) epoch: u64,
+ pub(super) next_focus_serial: u64,
+ pub(super) rollback_error: Option<String>,
+ /// A different endpoint was selected while this source-off-first transaction was in flight.
+ /// Keep only the latest intent until source restoration commits.
+ pub(super) successor: Option<EndpointActivationIntent>,
+}
diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs
new file mode 100644
index 00000000..569d53f3
--- /dev/null
+++ b/src/client/endpoint/activation/protocol.rs
@@ -0,0 +1,219 @@
+use super::super::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome};
+use super::model::{ActivationEvidence, EndpointLease};
+
+pub(super) fn focus_result_matches(
+ focus: Option<&crate::client::shell::ClientEndpointFocusTarget>,
+ result: &crate::api::schema::ResponseResult,
+) -> bool {
+ match (focus, result) {
+ (
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)),
+ crate::api::schema::ResponseResult::PaneInfo { pane },
+ ) => pane.focused && &pane.pane_id == expected,
+ (
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)),
+ crate::api::schema::ResponseResult::WorkspaceInfo { workspace },
+ ) => workspace.focused && &workspace.workspace_id == expected,
+ (
+ Some(crate::client::shell::ClientEndpointFocusTarget::Tab(expected)),
+ crate::api::schema::ResponseResult::TabInfo { tab },
+ ) => tab.focused && &tab.tab_id == expected,
+ _ => false,
+ }
+}
+
+pub(super) fn endpoint_lease(
+ shell: &crate::client::shell::ClientShellState,
+ endpoints: &EndpointRegistry,
+ endpoint_id: &ClientEndpointId,
+) -> Result<EndpointLease, String> {
+ let connection = endpoints
+ .connection(endpoint_id)
+ .ok_or_else(|| "endpoint connection is unavailable".to_owned())?;
+ let (boot_id, minimum_revision) = shell
+ .endpoint_snapshot_identity(endpoint_id, connection.generation)
+ .ok_or_else(|| "endpoint metadata is not ready for this connection".to_owned())?;
+ Ok(EndpointLease {
+ endpoint_id: endpoint_id.clone(),
+ generation: connection.generation,
+ boot_id: boot_id.to_owned(),
+ minimum_revision,
+ })
+}
+
+pub(super) fn disconnected_endpoint_lease(
+ shell: &crate::client::shell::ClientShellState,
+ endpoint_id: &ClientEndpointId,
+) -> EndpointLease {
+ EndpointLease {
+ endpoint_id: endpoint_id.clone(),
+ generation: 0,
+ boot_id: shell
+ .endpoint_boot_id(endpoint_id)
+ .unwrap_or_default()
+ .to_owned(),
+ minimum_revision: 0,
+ }
+}
+
+pub(super) fn endpoint_matches(
+ lease: &EndpointLease,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ boot_id: &str,
+) -> bool {
+ lease.endpoint_id == *endpoint_id && lease.generation == generation && lease.boot_id == boot_id
+}
+
+pub(super) fn coherent_completion_surface(
+ shell: &crate::client::shell::ClientShellState,
+ lease: &EndpointLease,
+ evidence: &ActivationEvidence,
+ acknowledgement_revision: Option<u64>,
+ geometry: crate::protocol::ClientSurfaceSize,
+) -> Result<crate::protocol::PaneSurfaceFrame, String> {
+ let acknowledgement_revision = acknowledgement_revision.ok_or_else(|| {
+ "endpoint activation completed without a surface acknowledgement".to_owned()
+ })?;
+ let surface = evidence
+ .surface
+ .clone()
+ .ok_or_else(|| "endpoint activation completed without a surface".to_owned())?;
+ if surface.projection_revision < acknowledgement_revision
+ || !surface_matches_geometry(&surface, geometry)
+ {
+ return Err("endpoint activation lost its acknowledged surface evidence".into());
+ }
+ if !shell.endpoint_snapshot_matches(
+ &lease.endpoint_id,
+ lease.generation,
+ &lease.boot_id,
+ surface.projection_revision,
+ ) {
+ return Err("endpoint activation lost its coherent snapshot/surface pair".into());
+ }
+ Ok(surface)
+}
+
+pub(super) fn resize_geometry(
+ message: &crate::protocol::ClientMessage,
+) -> Option<crate::protocol::ClientSurfaceSize> {
+ match message {
+ crate::protocol::ClientMessage::ClientShellResize { surface_size, .. } => {
+ Some(*surface_size)
+ }
+ _ => None,
+ }
+}
+
+pub(super) fn surface_matches_geometry(
+ surface: &crate::protocol::PaneSurfaceFrame,
+ geometry: crate::protocol::ClientSurfaceSize,
+) -> bool {
+ surface.frame.width == geometry.cols && surface.frame.height == geometry.rows
+}
+
+pub(super) fn send_surface_activation(
+ endpoints: &mut EndpointRegistry,
+ target: &EndpointLease,
+ request_id: String,
+ resize: &crate::protocol::ClientMessage,
+ focused: bool,
+) -> Result<(), String> {
+ if endpoints.send_to(&target.endpoint_id, resize) != EndpointSendOutcome::Sent {
+ return Err("endpoint resize could not be sent".into());
+ }
+ let request = surface_interest_request(&target.boot_id, request_id, true)
+ .map_err(|error| error.to_string())?;
+ if endpoints.send_to(&target.endpoint_id, &request) != EndpointSendOutcome::Sent {
+ return Err("endpoint activation could not be sent".into());
+ }
+ // Inactive endpoints reject focus events. Activate first, then establish the host baseline
+ // on the same ordered transport before navigation or presentation can commit.
+ if endpoints.send_to(
+ &target.endpoint_id,
+ &crate::protocol::ClientMessage::ClientShellFocus { focused },
+ ) != EndpointSendOutcome::Sent
+ {
+ return Err("endpoint focus baseline could not be sent".into());
+ }
+ Ok(())
+}
+
+pub(super) fn decode_endpoint_response(
+ request_id: &str,
+ data: &[u8],
+) -> Result<crate::api::schema::ResponseResult, crate::client::shell::ClientShellEndpointError> {
+ crate::client::endpoint_commands::parse_response(request_id, data)
+}
+
+pub(super) fn surface_set_revision(
+ result: &crate::api::schema::ResponseResult,
+ expected_active: bool,
+) -> Result<u64, String> {
+ match result {
+ crate::api::schema::ResponseResult::ClientShellSurfaceSet {
+ active,
+ projection_revision,
+ } if *active == expected_active => Ok(*projection_revision),
+ _ => Err("surface activation returned an invalid acknowledgement".into()),
+ }
+}
+
+pub(super) fn focus_request(
+ boot_id: &str,
+ request_id: String,
+ focus: &crate::client::shell::ClientEndpointFocusTarget,
+) -> std::io::Result<crate::protocol::ClientMessage> {
+ let method = match focus {
+ crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => {
+ crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
+ workspace_id: workspace_id.clone(),
+ })
+ }
+ crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id) => {
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
+ })
+ }
+ crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id) => {
+ crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
+ tab_id: tab_id.clone(),
+ })
+ }
+ };
+ endpoint_request(
+ boot_id,
+ crate::api::schema::Request {
+ id: request_id,
+ method,
+ },
+ )
+}
+
+pub(super) fn surface_interest_request(
+ boot_id: &str,
+ request_id: String,
+ active: bool,
+) -> std::io::Result<crate::protocol::ClientMessage> {
+ endpoint_request(
+ boot_id,
+ crate::api::schema::Request {
+ id: request_id,
+ method: crate::api::schema::Method::ClientShellSurfaceSet(
+ crate::api::schema::ClientShellSurfaceSetParams { active },
+ ),
+ },
+ )
+}
+
+fn endpoint_request(
+ boot_id: &str,
+ request: crate::api::schema::Request,
+) -> std::io::Result<crate::protocol::ClientMessage> {
+ Ok(crate::protocol::ClientMessage::ClientShellEndpointRequest {
+ boot_id: boot_id.to_owned(),
+ request: serde_json::to_string(&request)
+ .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?,
+ })
+}
diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs
new file mode 100644
index 00000000..b66cb98b
--- /dev/null
+++ b/src/client/endpoint/activation_tests.rs
@@ -0,0 +1,1305 @@
+use super::*;
+
+fn endpoint() -> ClientEndpointId {
+ ClientEndpointId::Ssh(
+ super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ )
+}
+
+fn lease(id: ClientEndpointId, generation: u64, boot: &str) -> EndpointLease {
+ EndpointLease {
+ endpoint_id: id,
+ generation,
+ boot_id: boot.into(),
+ minimum_revision: 0,
+ }
+}
+
+#[derive(Clone)]
+struct FakeTransport {
+ sent: std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>,
+ fail_after_write: bool,
+}
+
+impl super::super::EndpointTransport for FakeTransport {
+ fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> {
+ self.sent.lock().unwrap().push(message.clone());
+ if self.fail_after_write {
+ Err(std::io::Error::other("simulated observed write failure"))
+ } else {
+ Ok(())
+ }
+ }
+}
+
+fn negotiation() -> super::super::EndpointNegotiation {
+ super::super::EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ ],
+ )
+}
+
+fn test_snapshot(boot_id: &str, revision: u64) -> crate::protocol::ClientShellSnapshot {
+ crate::protocol::ClientShellSnapshot {
+ boot_id: boot_id.into(),
+ revision,
+ config_diagnostic: None,
+ product_announcement: None,
+ update_available: None,
+ update_install_command: String::new(),
+ 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(),
+ }
+}
+
+type SentMessages = std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>;
+type TestFixture = (
+ crate::client::ClientShellState,
+ EndpointRegistry,
+ SentMessages,
+ SentMessages,
+);
+
+fn shell_and_registry() -> TestFixture {
+ shell_and_registry_with_source_failure(false)
+}
+
+fn shell_and_registry_with_source_failure(source_fail_after_write: bool) -> TestFixture {
+ let mut shell = crate::client::ClientShellState::new(
+ crate::client::ClientShellConfig::from_config(&crate::config::Config::default()),
+ );
+ let profile = super::super::SavedSshEndpoint {
+ id: super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Remote".into(),
+ target: "dev@example.com".into(),
+ session: "main".into(),
+ enabled: true,
+ };
+ let target = ClientEndpointId::Ssh(profile.id.clone());
+ shell.set_endpoint_catalog(&[profile]);
+ shell.set_snapshot(Box::new(test_snapshot("local-boot", 1)));
+ shell.set_endpoint_status(&target, ClientEndpointStatus::Online);
+ shell.set_endpoint_snapshot(&target, Box::new(test_snapshot("remote-boot", 1)));
+
+ let local_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let remote_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let mut endpoints = EndpointRegistry::new(
+ FakeTransport {
+ sent: local_sent.clone(),
+ fail_after_write: source_fail_after_write,
+ },
+ 1,
+ negotiation(),
+ );
+ endpoints.insert(
+ target,
+ FakeTransport {
+ sent: remote_sent.clone(),
+ fail_after_write: false,
+ },
+ 7,
+ negotiation(),
+ false,
+ );
+ (shell, endpoints, local_sent, remote_sent)
+}
+
+fn surface_success(id: &str, active: bool, projection_revision: u64) -> Vec<u8> {
+ serde_json::to_vec(&crate::api::schema::SuccessResponse {
+ id: id.into(),
+ result: crate::api::schema::ResponseResult::ClientShellSurfaceSet {
+ active,
+ projection_revision,
+ },
+ })
+ .unwrap()
+}
+
+fn workspace_focus_success(id: &str, workspace_id: &str) -> Vec<u8> {
+ serde_json::to_vec(&crate::api::schema::SuccessResponse {
+ id: id.into(),
+ result: crate::api::schema::ResponseResult::WorkspaceInfo {
+ workspace: crate::api::schema::WorkspaceInfo {
+ workspace_id: workspace_id.into(),
+ number: 1,
+ label: workspace_id.into(),
+ focused: true,
+ pane_count: 1,
+ tab_count: 1,
+ active_tab_id: "tab".into(),
+ agent_status: crate::api::schema::AgentStatus::Unknown,
+ tokens: Default::default(),
+ worktree: None,
+ },
+ },
+ })
+ .unwrap()
+}
+
+fn failure(id: &str, message: &str) -> Vec<u8> {
+ serde_json::to_vec(&crate::api::schema::ErrorResponse {
+ id: id.into(),
+ error: crate::api::schema::ErrorBody {
+ code: "surface_rejected".into(),
+ message: message.into(),
+ },
+ })
+ .unwrap()
+}
+
+fn surface_set_active(message: &crate::protocol::ClientMessage) -> Option<bool> {
+ let crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } = message else {
+ return None;
+ };
+ let request: crate::api::schema::Request = serde_json::from_str(request).ok()?;
+ match request.method {
+ crate::api::schema::Method::ClientShellSurfaceSet(params) => Some(params.active),
+ _ => None,
+ }
+}
+
+fn resize() -> crate::protocol::ClientMessage {
+ crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 },
+ pixel_mouse: false,
+ }
+}
+
+fn surface(boot_id: &str, revision: u64, pane: &str) -> crate::protocol::PaneSurfaceFrame {
+ crate::protocol::PaneSurfaceFrame {
+ boot_id: boot_id.into(),
+ projection_revision: revision,
+ surface_revision: revision,
+ frame: crate::protocol::FrameData {
+ cells: Vec::new(),
+ width: 80,
+ height: 24,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ },
+ panes: vec![crate::protocol::PaneSurfacePane {
+ pane_id: pane.into(),
+ content_revision: revision,
+ rect: crate::protocol::SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 24,
+ },
+ inner_rect: crate::protocol::SurfaceRect {
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 24,
+ },
+ scrollbar_rect: None,
+ scroll: None,
+ focused: true,
+ mouse_reporting: false,
+ sgr_pixel_mouse: false,
+ alternate_screen_active: false,
+ pixel_width: 0,
+ pixel_height: 0,
+ }],
+ splits: Vec::new(),
+ popup: None,
+ graphics: Default::default(),
+ }
+}
+
+fn machine() -> PendingEndpointActivation {
+ PendingEndpointActivation {
+ source: lease(ClientEndpointId::Local, 1, "local-boot"),
+ source_available: true,
+ target: lease(endpoint(), 7, "remote-boot"),
+ focus: None,
+ host_focused: true,
+ resize: resize(),
+ phase: ActivationPhase::ActivatingTarget {
+ request_id: "client-shell-surface:3:on".into(),
+ acknowledged_revision: Some(1),
+ focus_request_id: None,
+ focus_request_target: None,
+ focus_acknowledged: true,
+ evidence: ActivationEvidence::default(),
+ },
+ deadline: Instant::now() + ACTIVATION_TIMEOUT,
+ epoch: 3,
+ next_focus_serial: 0,
+ rollback_error: None,
+ successor: None,
+ }
+}
+
+#[test]
+fn source_off_request_is_distinct_and_precedes_target_on_phase() {
+ let activation = PendingEndpointActivation {
+ source: lease(ClientEndpointId::Local, 1, "local-boot"),
+ source_available: true,
+ target: lease(endpoint(), 7, "remote-boot"),
+ focus: None,
+ host_focused: true,
+ resize: resize(),
+ phase: ActivationPhase::ReleasingSource {
+ request_id: "client-shell-surface:9:off".into(),
+ },
+ deadline: Instant::now() + ACTIVATION_TIMEOUT,
+ epoch: 9,
+ next_focus_serial: 0,
+ rollback_error: None,
+ successor: None,
+ };
+ assert!(activation.accepts_response(
+ &ClientEndpointId::Local,
+ 1,
+ "local-boot",
+ "client-shell-surface:9:off"
+ ));
+ assert!(!activation.accepts_response(
+ &endpoint(),
+ 7,
+ "remote-boot",
+ "client-shell-surface:9:on"
+ ));
+}
+
+#[test]
+fn active_source_requires_metadata_from_its_current_connection_generation() {
+ let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry();
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 99,
+ Box::new(test_snapshot("stale-local-boot", 1)),
+ );
+
+ let result = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 26,
+ Instant::now(),
+ );
+
+ assert!(
+ matches!(result, Err(ActivationBeginError::Preflight(message)) if message.contains("this connection"))
+ );
+ assert!(local_sent.lock().unwrap().is_empty());
+ assert!(remote_sent.lock().unwrap().is_empty());
+ assert!(endpoints.active_surface_available());
+}
+
+#[test]
+fn observed_begin_write_failure_returns_recoverable_partial_activation() {
+ let (shell, mut endpoints, local_sent, remote_sent) =
+ shell_and_registry_with_source_failure(true);
+ let result = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 10,
+ Instant::now(),
+ );
+
+ let ActivationBeginError::Partial {
+ mut activation,
+ error,
+ } = (match result {
+ Err(error) => error,
+ Ok(_) => panic!("an observed source write must return partial lifecycle state"),
+ })
+ else {
+ panic!("an observed source write must return partial lifecycle state");
+ };
+ assert!(error.contains("focus revoke"));
+ assert_eq!(
+ local_sent.lock().unwrap().first(),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false })
+ );
+ assert!(remote_sent.lock().unwrap().is_empty());
+ assert!(matches!(
+ activation.rollback(&mut endpoints, error, false),
+ ActivationRollback::Unavailable(_)
+ ));
+}
+
+#[test]
+fn source_release_is_sent_and_acknowledged_before_target_activation() {
+ let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 11,
+ Instant::now(),
+ )
+ .unwrap();
+ let local = local_sent.lock().unwrap();
+ assert_eq!(
+ local.first(),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }),
+ "source focus is revoked before source-off"
+ );
+ assert_eq!(
+ local
+ .as_slice()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false],
+ "the source is released first"
+ );
+ drop(local);
+ assert!(remote_sent.lock().unwrap().is_empty());
+ assert!(
+ !endpoints.active_surface_available(),
+ "pane input is blocked while frozen"
+ );
+
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:11:off",
+ &surface_success("client-shell-surface:11:off", false, 1),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ let remote = remote_sent.lock().unwrap();
+ assert!(matches!(
+ remote[0],
+ crate::protocol::ClientMessage::ClientShellResize { .. }
+ ));
+ assert_eq!(
+ remote.get(2),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true })
+ );
+ assert_eq!(remote.get(1).and_then(surface_set_active), Some(true));
+}
+
+#[test]
+fn activation_requires_an_exact_snapshot_surface_revision_pair() {
+ let mut activation = machine();
+ let target = endpoint();
+ let snapshot = crate::protocol::ClientShellSnapshot {
+ boot_id: "remote-boot".into(),
+ revision: 2,
+ config_diagnostic: None,
+ product_announcement: None,
+ update_available: None,
+ update_install_command: String::new(),
+ 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(),
+ };
+ assert_eq!(
+ activation.receive_snapshot(&target, 7, &snapshot),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 2, "pane")),
+ SurfaceActivationProgress::Ready
+ );
+}
+
+#[test]
+fn typed_target_ack_sets_a_floor_for_same_boot_activation_evidence() {
+ let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 16,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:16:off",
+ &surface_success("client-shell-surface:16:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ activation.receive_response(
+ &target,
+ 7,
+ "client-shell-surface:16:on",
+ &surface_success("client-shell-surface:16:on", true, 4),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 3)),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 3, "pane")),
+ SurfaceActivationProgress::Pending,
+ "a delayed same-boot surface below the acknowledgement floor is not evidence"
+ );
+ assert_eq!(
+ activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 4)),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&target, 7, surface("remote-boot", 4, "pane")),
+ SurfaceActivationProgress::Ready
+ );
+}
+
+#[test]
+fn stale_generation_and_boot_are_not_activation_evidence() {
+ let mut activation = machine();
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 6, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Stale
+ );
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("old-boot", 1, "pane")),
+ SurfaceActivationProgress::Stale
+ );
+}
+
+#[test]
+fn stale_response_boot_is_not_consumed() {
+ let mut activation = machine();
+ let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ assert_eq!(
+ activation.receive_response_for_boot(
+ &endpoint(),
+ 7,
+ "old-boot",
+ "client-shell-surface:3:on",
+ &surface_success("client-shell-surface:3:on", true, 2),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Stale
+ );
+}
+
+#[test]
+fn same_target_retarget_is_latest_wins() {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(
+ "old".into(),
+ )),
+ resize(),
+ 12,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:12:off",
+ &surface_success("client-shell-surface:12:off", false, 1),
+ &mut endpoints,
+ );
+ let old_focus = remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|message| match message {
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. }
+ if surface_set_active(message).is_none() =>
+ {
+ Some(
+ serde_json::from_str::<crate::api::schema::Request>(request)
+ .unwrap()
+ .id,
+ )
+ }
+ _ => None,
+ })
+ .unwrap();
+ let sent_before_retarget = remote_sent.lock().unwrap().len();
+ activation
+ .retarget(
+ Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(
+ "new".into(),
+ )),
+ &mut endpoints,
+ )
+ .unwrap();
+ assert_eq!(remote_sent.lock().unwrap().len(), sent_before_retarget);
+ assert!(activation.accepts_response(&target, 7, "remote-boot", &old_focus));
+ assert_eq!(
+ activation.receive_response(
+ &target,
+ 7,
+ &old_focus,
+ &workspace_focus_success(&old_focus, "old"),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ let latest_focus = remote_sent
+ .lock()
+ .unwrap()
+ .last()
+ .and_then(|message| match message {
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } => Some(
+ serde_json::from_str::<crate::api::schema::Request>(request)
+ .unwrap()
+ .id,
+ ),
+ _ => None,
+ })
+ .unwrap();
+ assert_ne!(latest_focus, old_focus);
+ assert!(activation.accepts_response(&target, 7, "remote-boot", &latest_focus));
+}
+
+#[test]
+fn latest_host_focus_is_replayed_to_the_eventual_target() {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 25,
+ Instant::now(),
+ )
+ .unwrap();
+ activation.update_host_focus(false, &mut endpoints).unwrap();
+ assert!(remote_sent.lock().unwrap().is_empty());
+
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:25:off",
+ &surface_success("client-shell-surface:25:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ remote_sent.lock().unwrap().get(2),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false })
+ );
+
+ activation.update_host_focus(true, &mut endpoints).unwrap();
+ assert_eq!(
+ remote_sent.lock().unwrap().last(),
+ Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true })
+ );
+}
+
+#[test]
+fn host_focus_change_restarts_an_issued_presentation_effects_fence() {
+ let (_shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let mut activation = machine();
+ activation.phase = ActivationPhase::AwaitingPresentationEffects {
+ lease: lease(endpoint(), 7, "remote-boot"),
+ token: "old-token".into(),
+ ready: false,
+ completion: Box::new(ActivationCompletion::Activated),
+ };
+
+ activation.update_host_focus(false, &mut endpoints).unwrap();
+
+ assert!(matches!(
+ activation.phase,
+ ActivationPhase::SynchronizingPresentation { .. }
+ ));
+ assert_eq!(
+ activation.receive_presentation_effects_ready(&endpoint(), 7, "old-token"),
+ SurfaceActivationProgress::Stale
+ );
+ assert_eq!(
+ remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true]
+ );
+}
+
+#[test]
+fn source_release_rejection_restores_the_source_coherently() {
+ let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 13,
+ Instant::now(),
+ )
+ .unwrap();
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:13:off",
+ &failure("client-shell-surface:13:off", "source rejected release"),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Rejected {
+ message: "source rejected release".into(),
+ source_release_rejected: true,
+ }
+ );
+ assert_eq!(
+ activation.rollback(&mut endpoints, "source rejected release".into(), true),
+ ActivationRollback::Pending
+ );
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+ assert!(!endpoints.active_surface_available());
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+}
+
+#[test]
+fn source_release_timeout_starts_an_acknowledged_source_restore() {
+ let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 14,
+ Instant::now(),
+ )
+ .unwrap();
+ assert_eq!(
+ activation.rollback(&mut endpoints, "source release timed out".into(), false),
+ ActivationRollback::Pending
+ );
+ let sent = local_sent.lock().unwrap();
+ assert_eq!(
+ sent.iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+ assert!(activation.accepts_response(
+ &ClientEndpointId::Local,
+ 1,
+ "local-boot",
+ "client-shell-surface:14:rollback-source-on"
+ ));
+}
+
+#[test]
+fn resize_invalidates_already_recorded_surface_evidence() {
+ let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ let mut activation = machine();
+ assert_eq!(
+ activation.receive_snapshot(&endpoint(), 7, &test_snapshot("remote-boot", 1)),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Ready
+ );
+ let resize = crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 9,
+ cell_height_px: 17,
+ surface_size: crate::protocol::ClientSurfaceSize {
+ cols: 100,
+ rows: 30,
+ },
+ pixel_mouse: true,
+ };
+ activation.update_resize(resize, &mut endpoints).unwrap();
+ assert_eq!(activation.progress(), SurfaceActivationProgress::Pending);
+}
+
+#[test]
+fn resize_during_activation_reaches_the_pending_target() {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 15,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:15:off",
+ &surface_success("client-shell-surface:15:off", false, 1),
+ &mut endpoints,
+ );
+ let resized = crate::protocol::ClientMessage::ClientShellResize {
+ cell_width_px: 9,
+ cell_height_px: 17,
+ surface_size: crate::protocol::ClientSurfaceSize {
+ cols: 100,
+ rows: 30,
+ },
+ pixel_mouse: true,
+ };
+ activation
+ .update_resize(resized.clone(), &mut endpoints)
+ .unwrap();
+ assert_eq!(remote_sent.lock().unwrap().last(), Some(&resized));
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")),
+ SurfaceActivationProgress::Pending,
+ "a surface for the prior geometry cannot commit"
+ );
+}
+
+#[test]
+fn rapid_a_to_b_to_a_restores_source_before_a_fresh_latest_epoch() {
+ let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ endpoint(),
+ None,
+ resize(),
+ 20,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:20:off",
+ &surface_success("client-shell-surface:20:off", false, 1),
+ &mut endpoints,
+ );
+
+ // The latest A-qualified target replaces B while B may already have accepted target-on.
+ assert_eq!(
+ activation.supersede(
+ ClientEndpointId::Local,
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "local-pane".into(),
+ )),
+ &mut endpoints,
+ ),
+ ActivationRollback::Pending
+ );
+ assert_eq!(
+ remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true, false],
+ "B is released before A can be restored"
+ );
+ assert_eq!(
+ activation.receive_surface(&endpoint(), 7, surface("remote-boot", 2, "pane")),
+ SurfaceActivationProgress::Stale,
+ "delayed B activation evidence cannot satisfy A restoration"
+ );
+ let _ = activation.receive_response(
+ &endpoint(),
+ 7,
+ "client-shell-surface:20:rollback-target-off",
+ &surface_success("client-shell-surface:20:rollback-target-off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true],
+ "source restoration is acknowledged rather than racing target ownership"
+ );
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:20:rollback-source-on",
+ &surface_success("client-shell-surface:20:rollback-source-on", true, 2),
+ &mut endpoints,
+ );
+ let local_snapshot = test_snapshot("local-boot", 2);
+ shell.set_snapshot(Box::new(local_snapshot.clone()));
+ assert_eq!(
+ activation.receive_snapshot(&ClientEndpointId::Local, 1, &local_snapshot),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 2, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationSync {
+ endpoint: ClientEndpointId::Local,
+ ..
+ })
+ ));
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:20:presentation-sync",
+ &surface_success("client-shell-surface:20:presentation-sync", true, 3),
+ &mut endpoints,
+ );
+ let sync_snapshot = test_snapshot("local-boot", 3);
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(sync_snapshot.clone()),
+ );
+ let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot);
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 3, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationEffects)
+ );
+ assert_eq!(
+ activation.receive_presentation_effects_ready(
+ &ClientEndpointId::Local,
+ 1,
+ "20:1:local-boot"
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::RestoredSource {
+ successor: Some(EndpointActivationIntent {
+ endpoint_id: ClientEndpointId::Local,
+ ..
+ }),
+ ..
+ })
+ ));
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+
+ // Runtime queues this successor with force=true, so even source==target receives a new
+ // activation epoch only after restoration committed.
+ let _fresh_epoch = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ ClientEndpointId::Local,
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "local-pane".into(),
+ )),
+ resize(),
+ 21,
+ Instant::now(),
+ )
+ .unwrap();
+ assert!(local_sent.lock().unwrap().iter().any(|message| {
+ matches!(
+ message,
+ crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. }
+ if serde_json::from_str::<crate::api::schema::Request>(request)
+ .is_ok_and(|request| request.id == "client-shell-surface:21:on")
+ )
+ }));
+}
+
+#[test]
+fn disconnected_committed_source_does_not_block_switching_to_a_live_endpoint() {
+ let (mut shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let disconnected = endpoint();
+ endpoints.set_surface_active(&ClientEndpointId::Local, false);
+ endpoints.set_surface_active(&disconnected, true);
+ assert!(endpoints.set_active(&disconnected));
+ assert!(shell.activate_endpoint_projection(&disconnected));
+ endpoints.disconnect(&disconnected);
+
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ ClientEndpointId::Local,
+ None,
+ resize(),
+ 22,
+ Instant::now(),
+ )
+ .unwrap();
+ assert_eq!(activation.source_command_lane(), None);
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true],
+ "there is no unavailable source release to await"
+ );
+
+ assert_eq!(
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:22:on",
+ &surface_success("client-shell-surface:22:on", true, 2),
+ &mut endpoints,
+ ),
+ SurfaceActivationProgress::Pending
+ );
+ let snapshot = test_snapshot("local-boot", 2);
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(snapshot.clone()),
+ );
+ assert_eq!(
+ activation.receive_snapshot(&ClientEndpointId::Local, 1, &snapshot),
+ SurfaceActivationProgress::Pending
+ );
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 2, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert!(matches!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationSync {
+ previous,
+ endpoint: ClientEndpointId::Local,
+ }) if previous == disconnected
+ ));
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:22:presentation-sync",
+ &surface_success("client-shell-surface:22:presentation-sync", true, 3),
+ &mut endpoints,
+ );
+ let sync_snapshot = test_snapshot("local-boot", 3);
+ shell.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(sync_snapshot.clone()),
+ );
+ let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot);
+ assert_eq!(
+ activation.receive_surface(
+ &ClientEndpointId::Local,
+ 1,
+ surface("local-boot", 3, "pane")
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::AwaitingPresentationEffects)
+ );
+ assert_eq!(
+ activation.receive_presentation_effects_ready(
+ &ClientEndpointId::Local,
+ 1,
+ "22:1:local-boot"
+ ),
+ SurfaceActivationProgress::Ready
+ );
+ assert_eq!(
+ activation.complete(&mut shell, &mut endpoints),
+ Ok(ActivationCompletion::Activated)
+ );
+ assert_eq!(endpoints.active_id(), &ClientEndpointId::Local);
+ assert_ne!(endpoints.active_id(), &disconnected);
+}
+
+#[test]
+fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() {
+ let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 23,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:23:off",
+ &surface_success("client-shell-surface:23:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ activation.supersede(
+ ClientEndpointId::Local,
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "local-pane".into()
+ )),
+ &mut endpoints,
+ ),
+ ActivationRollback::Pending
+ );
+ assert!(!activation.can_retarget(&target));
+ assert_eq!(
+ activation.supersede(
+ target.clone(),
+ Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "remote-pane".into()
+ )),
+ &mut endpoints,
+ ),
+ ActivationRollback::Pending
+ );
+ assert_eq!(
+ activation.successor,
+ Some(EndpointActivationIntent {
+ endpoint_id: target,
+ target: Some(crate::client::shell::ClientEndpointFocusTarget::Pane(
+ "remote-pane".into()
+ )),
+ })
+ );
+}
+
+#[test]
+fn unacknowledged_target_release_closes_target_before_restoring_source() {
+ let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 24,
+ Instant::now(),
+ )
+ .unwrap();
+ let _ = activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:24:off",
+ &surface_success("client-shell-surface:24:off", false, 1),
+ &mut endpoints,
+ );
+ assert_eq!(
+ activation.rollback(&mut endpoints, "target activation timed out".into(), false),
+ ActivationRollback::Pending
+ );
+ assert_eq!(
+ activation.rollback(&mut endpoints, "target release timed out".into(), false),
+ ActivationRollback::Pending
+ );
+ assert!(endpoints.connection(&target).is_none());
+ let failures = endpoints.take_failures();
+ assert_eq!(
+ failures.len(),
+ 1,
+ "rollback revocation must reach the reconnect owner"
+ );
+ assert_eq!(failures[0].endpoint_id, target);
+ assert_eq!(failures[0].generation, 7);
+ assert_eq!(failures[0].kind, std::io::ErrorKind::TimedOut);
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+}
+
+#[test]
+fn target_loss_at_activation_deadline_restores_source_before_timeout() {
+ let (shell, mut endpoints, local_sent, _) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 30,
+ Instant::now(),
+ )
+ .unwrap();
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:30:off",
+ &surface_success("client-shell-surface:30:off", false, 1),
+ &mut endpoints,
+ );
+ let now = Instant::now();
+ activation.deadline = now;
+ assert!(activation.expired(now));
+ endpoints.fail(&target, std::io::ErrorKind::UnexpectedEof.into());
+ // Match the client timer: apply transport failures before checking phase expiry.
+ for failure in endpoints.take_failures() {
+ assert_eq!(
+ activation.endpoint_disconnected(&mut endpoints, &failure.endpoint_id, failure.message),
+ ActivationRollback::Pending
+ );
+ }
+ assert!(matches!(
+ activation.phase,
+ ActivationPhase::RestoringSource { .. }
+ ));
+ assert!(!activation.expired(now));
+ assert_eq!(
+ local_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![false, true]
+ );
+}
+
+#[test]
+fn losing_local_during_handoff_does_not_revoke_the_healthy_target() {
+ for source_released in [false, true] {
+ let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry();
+ let target = endpoint();
+ let mut activation = PendingEndpointActivation::begin(
+ &shell,
+ &mut endpoints,
+ target.clone(),
+ None,
+ resize(),
+ 29,
+ Instant::now(),
+ )
+ .unwrap();
+ if source_released {
+ activation.receive_response(
+ &ClientEndpointId::Local,
+ 1,
+ "client-shell-surface:29:off",
+ &surface_success("client-shell-surface:29:off", false, 1),
+ &mut endpoints,
+ );
+ }
+ if !source_released {
+ activation.deadline = Instant::now() - Duration::from_millis(1);
+ }
+ endpoints.fail(
+ &ClientEndpointId::Local,
+ std::io::ErrorKind::BrokenPipe.into(),
+ );
+ assert_eq!(
+ activation.endpoint_disconnected(
+ &mut endpoints,
+ &ClientEndpointId::Local,
+ "Local stopped".into()
+ ),
+ ActivationRollback::Pending
+ );
+ assert!(!activation.source_available);
+ assert!(
+ !activation.expired(Instant::now()),
+ "starting the healthy target must get a fresh deadline"
+ );
+ assert!(matches!(
+ activation.phase,
+ ActivationPhase::ActivatingTarget { .. }
+ ));
+ assert!(endpoints.connection(&target).is_some());
+ assert_eq!(
+ remote_sent
+ .lock()
+ .unwrap()
+ .iter()
+ .filter_map(surface_set_active)
+ .collect::<Vec<_>>(),
+ vec![true]
+ );
+ }
+}
+
+#[test]
+fn resize_message_preserves_the_latest_surface_dimensions() {
+ assert_eq!(
+ resize_geometry(&resize()),
+ Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 })
+ );
+}
diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs
new file mode 100644
index 00000000..23f26387
--- /dev/null
+++ b/src/client/endpoint/catalog.rs
@@ -0,0 +1,578 @@
+use std::collections::HashSet;
+use std::io::{self, Read as _, Write as _};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use serde::{Deserialize, Serialize};
+
+use super::ProfileId;
+
+const CATALOG_VERSION: u32 = 1;
+const SELECTION_VERSION: u32 = 1;
+const MAX_CATALOG_BYTES: u64 = 64 * 1024;
+const MAX_PROFILES: usize = 64;
+const MAX_LABEL_BYTES: usize = 128;
+const MAX_TARGET_BYTES: usize = 1024;
+static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1);
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct SavedSshEndpoint {
+ pub(crate) id: ProfileId,
+ pub(crate) label: String,
+ pub(crate) target: String,
+ pub(crate) session: String,
+ pub(crate) enabled: bool,
+}
+
+impl SavedSshEndpoint {
+ pub(crate) fn new(
+ label: impl Into<String>,
+ target: impl Into<String>,
+ session: impl Into<String>,
+ ) -> Result<Self, String> {
+ let profile = Self {
+ id: ProfileId::generate(),
+ label: label.into(),
+ target: target.into(),
+ session: session.into(),
+ enabled: true,
+ };
+ profile.validate()?;
+ Ok(profile)
+ }
+
+ fn validate(&self) -> Result<(), String> {
+ ProfileId::parse(self.id.to_string())?;
+ let label = self.label.trim();
+ if label.is_empty() {
+ return Err("SSH endpoint label cannot be empty".into());
+ }
+ if label.len() > MAX_LABEL_BYTES || label.chars().any(char::is_control) {
+ return Err(format!(
+ "SSH endpoint label must be at most {MAX_LABEL_BYTES} bytes and contain no control characters"
+ ));
+ }
+ if self.target.len() > MAX_TARGET_BYTES || self.target.chars().any(char::is_control) {
+ return Err(format!(
+ "SSH target must be at most {MAX_TARGET_BYTES} bytes and contain no control characters"
+ ));
+ }
+ crate::remote::validate_remote_target(&self.target).map(|_| ())?;
+ let authority = self.target.strip_prefix("ssh://").unwrap_or(&self.target);
+ if authority
+ .rsplit_once('@')
+ .is_some_and(|(userinfo, _)| userinfo.contains(':'))
+ {
+ return Err("SSH target must not contain a password".into());
+ }
+ crate::session::validate_name(&self.session)?;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct EndpointCatalog {
+ version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) selected_profile: Option<ProfileId>,
+ #[serde(default)]
+ pub(crate) ssh: Vec<SavedSshEndpoint>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct EndpointSelection {
+ version: u32,
+ selected_profile: Option<ProfileId>,
+}
+
+impl Default for EndpointCatalog {
+ fn default() -> Self {
+ Self {
+ version: CATALOG_VERSION,
+ selected_profile: None,
+ ssh: Vec::new(),
+ }
+ }
+}
+
+impl EndpointCatalog {
+ pub(crate) fn load() -> Result<Self, String> {
+ Self::load_from_paths(&catalog_path(), &selection_path())
+ }
+
+ pub(crate) fn load_profiles() -> Result<Vec<SavedSshEndpoint>, String> {
+ // Live clients keep their own selection, independent of other attached clients.
+ Self::load_from_path(&catalog_path()).map(|catalog| catalog.ssh)
+ }
+
+ fn load_from_paths(catalog_path: &Path, selection_path: &Path) -> Result<Self, String> {
+ let mut catalog = Self::load_from_path(catalog_path)?;
+ match load_selection_from_path(selection_path) {
+ Ok(Some(selection)) => {
+ let valid = selection.selected_profile.as_ref().is_none_or(|selected| {
+ catalog
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == selected && profile.enabled)
+ });
+ if valid {
+ catalog.selected_profile = selection.selected_profile;
+ } else {
+ tracing::warn!(
+ path = %selection_path.display(),
+ "saved endpoint selection is absent or disabled; using Local"
+ );
+ }
+ }
+ Ok(None) => {}
+ Err(error) => {
+ tracing::warn!(
+ %error,
+ path = %selection_path.display(),
+ "saved endpoint selection is unavailable; using Local"
+ );
+ }
+ }
+ Ok(catalog)
+ }
+
+ pub(crate) fn store_profiles(&self) -> Result<(), String> {
+ self.store_to_path(&catalog_path())
+ }
+
+ pub(crate) fn store_selection(&self) -> Result<(), String> {
+ self.store_selection_to_path(&selection_path())
+ }
+
+ fn store_selection_to_path(&self, path: &Path) -> Result<(), String> {
+ self.validate()?;
+ let content = serde_json::to_vec_pretty(&EndpointSelection {
+ version: SELECTION_VERSION,
+ selected_profile: self.selected_profile.clone(),
+ })
+ .map_err(|error| format!("failed to encode endpoint selection: {error}"))?;
+ store_private_json(path, &content, "endpoint selection")
+ }
+
+ pub(crate) fn add_ssh(
+ &mut self,
+ label: impl Into<String>,
+ target: impl Into<String>,
+ session: impl Into<String>,
+ ) -> Result<ProfileId, String> {
+ if self.ssh.len() >= MAX_PROFILES {
+ return Err(format!("at most {MAX_PROFILES} SSH endpoints can be saved"));
+ }
+ let profile = SavedSshEndpoint::new(label, target, session)?;
+ let id = profile.id.clone();
+ self.ssh.push(profile);
+ Ok(id)
+ }
+
+ pub(crate) fn rename_ssh(
+ &mut self,
+ id: &ProfileId,
+ label: impl Into<String>,
+ ) -> Result<bool, String> {
+ let Some(index) = self.ssh.iter().position(|profile| &profile.id == id) else {
+ return Ok(false);
+ };
+ let mut renamed = self.ssh[index].clone();
+ renamed.label = label.into();
+ renamed.validate()?;
+ self.ssh[index] = renamed;
+ Ok(true)
+ }
+
+ pub(crate) fn remove_ssh(&mut self, id: &ProfileId) -> bool {
+ let previous_len = self.ssh.len();
+ self.ssh.retain(|profile| &profile.id != id);
+ if self.selected_profile.as_ref() == Some(id) {
+ self.selected_profile = None;
+ }
+ self.ssh.len() != previous_len
+ }
+
+ pub(crate) fn select_local(&mut self) {
+ self.selected_profile = None;
+ }
+
+ pub(crate) fn select_endpoint(&mut self, endpoint_id: &super::ClientEndpointId) -> bool {
+ match endpoint_id {
+ super::ClientEndpointId::Local => {
+ self.select_local();
+ true
+ }
+ super::ClientEndpointId::Ssh(profile_id) => self.select_ssh(profile_id),
+ }
+ }
+
+ pub(crate) fn select_ssh(&mut self, id: &ProfileId) -> bool {
+ if !self
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == id && profile.enabled)
+ {
+ return false;
+ }
+ self.selected_profile = Some(id.clone());
+ true
+ }
+
+ pub(crate) fn has_enabled_ssh(&self) -> bool {
+ self.ssh.iter().any(|profile| profile.enabled)
+ }
+
+ pub(crate) fn contains_enabled_target_session(&self, target: &str, session: &str) -> bool {
+ self.ssh.iter().any(|profile| {
+ profile.enabled && profile.target == target && profile.session == session
+ })
+ }
+
+ pub(crate) fn set_enabled(&mut self, id: &ProfileId, enabled: bool) -> bool {
+ let Some(profile) = self.ssh.iter_mut().find(|profile| &profile.id == id) else {
+ return false;
+ };
+ profile.enabled = enabled;
+ if !enabled && self.selected_profile.as_ref() == Some(id) {
+ self.selected_profile = None;
+ }
+ true
+ }
+
+ fn validate(&self) -> Result<(), String> {
+ if self.version != CATALOG_VERSION {
+ return Err(format!(
+ "unsupported endpoint catalog version {}; expected {CATALOG_VERSION}",
+ self.version
+ ));
+ }
+ if self.ssh.len() > MAX_PROFILES {
+ return Err(format!(
+ "endpoint catalog contains more than {MAX_PROFILES} SSH profiles"
+ ));
+ }
+ let mut ids = HashSet::new();
+ for profile in &self.ssh {
+ profile.validate()?;
+ if !ids.insert(profile.id.clone()) {
+ return Err(format!("duplicate endpoint profile id {}", profile.id));
+ }
+ }
+ if self.selected_profile.as_ref().is_some_and(|selected| {
+ !self
+ .ssh
+ .iter()
+ .any(|profile| &profile.id == selected && profile.enabled)
+ }) {
+ return Err("selected SSH endpoint is absent or disabled in the catalog".into());
+ }
+ Ok(())
+ }
+
+ fn load_from_path(path: &Path) -> Result<Self, String> {
+ let file = match std::fs::File::open(path) {
+ Ok(file) => file,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
+ Err(error) => {
+ return Err(format!(
+ "failed to open endpoint catalog {}: {error}",
+ path.display()
+ ))
+ }
+ };
+ let metadata = file
+ .metadata()
+ .map_err(|error| format!("failed to inspect endpoint catalog: {error}"))?;
+ if metadata.len() > MAX_CATALOG_BYTES {
+ return Err("endpoint catalog exceeds the storage limit".into());
+ }
+ let mut content = String::new();
+ file.take(MAX_CATALOG_BYTES + 1)
+ .read_to_string(&mut content)
+ .map_err(|error| format!("failed to read endpoint catalog: {error}"))?;
+ if content.len() as u64 > MAX_CATALOG_BYTES {
+ return Err("endpoint catalog exceeds the storage limit".into());
+ }
+ let catalog: Self = serde_json::from_str(&content)
+ .map_err(|error| format!("stored endpoint catalog is invalid: {error}"))?;
+ catalog.validate()?;
+ Ok(catalog)
+ }
+
+ fn store_to_path(&self, path: &Path) -> Result<(), String> {
+ self.validate()?;
+ let content = serde_json::to_vec_pretty(self)
+ .map_err(|error| format!("failed to encode endpoint catalog: {error}"))?;
+ store_private_json(path, &content, "endpoint catalog")
+ }
+}
+
+fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, String> {
+ let content = match std::fs::read(path) {
+ Ok(content) => content,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(format!("failed to read endpoint selection: {error}")),
+ };
+ if content.len() as u64 > MAX_CATALOG_BYTES {
+ return Err("endpoint selection exceeds the storage limit".into());
+ }
+ let selection: EndpointSelection = serde_json::from_slice(&content)
+ .map_err(|error| format!("stored endpoint selection is invalid: {error}"))?;
+ if selection.version != SELECTION_VERSION {
+ return Err(format!(
+ "unsupported endpoint selection version {}; expected {SELECTION_VERSION}",
+ selection.version
+ ));
+ }
+ Ok(Some(selection))
+}
+
+fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> {
+ if content.len() as u64 > MAX_CATALOG_BYTES {
+ return Err(format!("{description} exceeds the storage limit"));
+ }
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("invalid {description} path: {}", path.display()))?;
+ std::fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {description} directory: {error}"))?;
+ if let Ok(metadata) = std::fs::symlink_metadata(path) {
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err(format!(
+ "refusing to replace {description} through a non-file path"
+ ));
+ }
+ }
+
+ let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed);
+ let temp_path = parent.join(format!(".endpoints-{}-{sequence}.tmp", std::process::id()));
+ let mut temp = crate::platform::create_private_state_file(&temp_path)
+ .map_err(|error| format!("failed to create {description}: {error}"))?;
+ if let Err(error) = temp.write_all(content).and_then(|()| temp.sync_all()) {
+ drop(temp);
+ let _ = std::fs::remove_file(&temp_path);
+ return Err(format!("failed to write {description}: {error}"));
+ }
+ drop(temp);
+ if let Err(error) = crate::platform::replace_file(&temp_path, path) {
+ let _ = std::fs::remove_file(&temp_path);
+ return Err(format!("failed to activate {description}: {error}"));
+ }
+ crate::platform::sync_parent_directory(parent)
+ .map_err(|error| format!("failed to persist {description} directory: {error}"))
+}
+
+pub(crate) fn catalog_path() -> PathBuf {
+ crate::config::state_dir()
+ .join("client")
+ .join("endpoints.json")
+}
+
+fn selection_path() -> PathBuf {
+ crate::config::state_dir()
+ .join("client")
+ .join("endpoint-selection.json")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn path(name: &str) -> PathBuf {
+ std::env::temp_dir()
+ .join(format!(
+ "herdr-endpoint-catalog-{}-{name}",
+ std::process::id()
+ ))
+ .join("endpoints.json")
+ }
+
+ #[test]
+ fn catalog_roundtrip_persists_profiles_without_secret_fields() {
+ let path = path("roundtrip");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog
+ .add_ssh("Build", "ssh://dev@build.example:2222", "agents")
+ .unwrap();
+ assert!(catalog.select_ssh(&id));
+ catalog.store_to_path(&path).unwrap();
+
+ let encoded = std::fs::read_to_string(&path).unwrap();
+ assert!(!encoded.contains("password"));
+ assert!(!encoded.contains("private_key"));
+ assert!(!encoded.contains("control_socket"));
+ let loaded = EndpointCatalog::load_from_path(&path).unwrap();
+ assert_eq!(loaded, catalog);
+ assert_eq!(loaded.ssh[0].id, id);
+ std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn duplicate_target_and_session_profiles_keep_distinct_opaque_ids() {
+ let mut catalog = EndpointCatalog::default();
+ let first = catalog.add_ssh("One", "build", "default").unwrap();
+ let second = catalog.add_ssh("Two", "build", "default").unwrap();
+ assert_ne!(first, second);
+ }
+
+ #[test]
+ fn catalog_rejects_passwords_embedded_in_ssh_targets() {
+ let mut catalog = EndpointCatalog::default();
+ assert!(catalog
+ .add_ssh("Build", "ssh://dev:secret@build.example", "default")
+ .unwrap_err()
+ .contains("must not contain a password"));
+ assert!(catalog
+ .add_ssh("Build", "dev:secret@build.example", "default")
+ .is_err());
+ assert!(catalog
+ .add_ssh("Build", "ssh://dev@[::1]:2222", "default")
+ .is_ok());
+ }
+
+ #[test]
+ fn interactive_bootstrap_matches_only_enabled_target_and_session() {
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "agents").unwrap();
+ assert!(catalog.contains_enabled_target_session("build", "agents"));
+ assert!(!catalog.contains_enabled_target_session("build", "default"));
+ assert!(catalog.set_enabled(&id, false));
+ assert!(!catalog.contains_enabled_target_session("build", "agents"));
+ }
+
+ #[test]
+ fn rename_changes_only_the_machine_label() {
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Old", "build", "agents").unwrap();
+ let original = catalog.ssh[0].clone();
+
+ assert!(catalog.rename_ssh(&id, "New").unwrap());
+ assert_eq!(catalog.ssh[0].label, "New");
+ assert_eq!(catalog.ssh[0].id, original.id);
+ assert_eq!(catalog.ssh[0].target, original.target);
+ assert_eq!(catalog.ssh[0].session, original.session);
+ assert!(catalog.rename_ssh(&id, "\n").is_err());
+ assert_eq!(catalog.ssh[0].label, "New");
+ }
+
+ #[test]
+ fn removal_and_disable_return_selection_to_local() {
+ let mut catalog = EndpointCatalog::default();
+ let first = catalog.add_ssh("One", "one", "default").unwrap();
+ assert!(catalog.select_ssh(&first));
+ assert!(catalog.set_enabled(&first, false));
+ assert_eq!(catalog.selected_profile, None);
+
+ assert!(catalog.set_enabled(&first, true));
+ assert!(catalog.select_ssh(&first));
+ assert!(catalog.remove_ssh(&first));
+ assert_eq!(catalog.selected_profile, None);
+ }
+
+ #[test]
+ fn catalog_rejects_unknown_fields_instead_of_retaining_possible_secrets() {
+ let path = path("unknown-field");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(
+ &path,
+ r#"{
+ "version": 1,
+ "ssh": [{
+ "id": "0123456789abcdef0123456789abcdef",
+ "label": "Build",
+ "target": "build",
+ "session": "default",
+ "enabled": true,
+ "password": "must-not-be-accepted"
+ }]
+ }"#,
+ )
+ .unwrap();
+ assert!(EndpointCatalog::load_from_path(&path)
+ .unwrap_err()
+ .contains("unknown field"));
+ std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn storing_selection_does_not_rewrite_profile_membership() {
+ let catalog_path = path("separate-selection");
+ let selection_path = catalog_path.with_file_name("selection.json");
+ let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "agents").unwrap();
+ catalog.store_to_path(&catalog_path).unwrap();
+ let profiles_before = std::fs::read(&catalog_path).unwrap();
+
+ assert!(catalog.select_ssh(&id));
+ catalog.store_selection_to_path(&selection_path).unwrap();
+
+ assert_eq!(std::fs::read(&catalog_path).unwrap(), profiles_before);
+ assert_eq!(
+ load_selection_from_path(&selection_path)
+ .unwrap()
+ .unwrap()
+ .selected_profile,
+ Some(id)
+ );
+ std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn malformed_selection_does_not_discard_saved_profiles() {
+ let catalog_path = path("malformed-selection");
+ let selection_path = catalog_path.with_file_name("selection.json");
+ let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let id = catalog.add_ssh("Build", "build", "agents").unwrap();
+ catalog.store_to_path(&catalog_path).unwrap();
+ std::fs::write(&selection_path, b"not json").unwrap();
+
+ let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap();
+ assert_eq!(loaded.ssh.len(), 1);
+ assert_eq!(loaded.ssh[0].id, id);
+ assert_eq!(loaded.selected_profile, None);
+ std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn absent_selected_profile_falls_back_without_discarding_catalog() {
+ let catalog_path = path("absent-selection");
+ let selection_path = catalog_path.with_file_name("selection.json");
+ let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
+ let mut catalog = EndpointCatalog::default();
+ let saved = catalog.add_ssh("Build", "build", "agents").unwrap();
+ catalog.store_to_path(&catalog_path).unwrap();
+ let missing = ProfileId::parse("fedcba9876543210fedcba9876543210").unwrap();
+ store_private_json(
+ &selection_path,
+ &serde_json::to_vec(&EndpointSelection {
+ version: SELECTION_VERSION,
+ selected_profile: Some(missing),
+ })
+ .unwrap(),
+ "endpoint selection",
+ )
+ .unwrap();
+
+ let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap();
+ assert_eq!(loaded.ssh[0].id, saved);
+ assert_eq!(loaded.selected_profile, None);
+ std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn invalid_or_missing_selected_profile_is_rejected() {
+ let catalog = EndpointCatalog {
+ selected_profile: Some(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()),
+ ..EndpointCatalog::default()
+ };
+ assert!(catalog.validate().is_err());
+ }
+}
diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs
new file mode 100644
index 00000000..7cf1159f
--- /dev/null
+++ b/src/client/endpoint/control.rs
@@ -0,0 +1,63 @@
+use super::ClientEndpointId;
+
+pub(crate) enum EndpointControlMessage {
+ HealthPong,
+ Snapshot(Box<crate::protocol::ClientShellSnapshot>),
+ Ignored,
+}
+
+pub(crate) fn decode_endpoint_control(
+ kind: &str,
+ data: &str,
+) -> Result<EndpointControlMessage, String> {
+ if kind == crate::protocol::endpoint::HEALTH_PONG_KIND {
+ return Ok(EndpointControlMessage::HealthPong);
+ }
+ if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND {
+ let snapshot = serde_json::from_str(data)
+ .map_err(|error| format!("invalid endpoint snapshot: {error}"))?;
+ return Ok(EndpointControlMessage::Snapshot(Box::new(snapshot)));
+ }
+ if kind.starts_with("shell.snapshot.") {
+ return Err(format!(
+ "unsupported mandatory endpoint snapshot codec {kind:?}"
+ ));
+ }
+ Ok(EndpointControlMessage::Ignored)
+}
+
+pub(crate) fn protocol_failure_is_fatal(endpoint_id: &ClientEndpointId) -> bool {
+ endpoint_id.is_local()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::client::endpoint::ProfileId;
+
+ #[test]
+ fn unknown_optional_controls_are_ignored() {
+ assert!(matches!(
+ decode_endpoint_control("future.optional", "not json").unwrap(),
+ EndpointControlMessage::Ignored
+ ));
+ }
+
+ #[test]
+ fn unknown_snapshot_codecs_are_rejected() {
+ assert_eq!(
+ decode_endpoint_control("shell.snapshot.v2", "{}")
+ .err()
+ .as_deref(),
+ Some("unsupported mandatory endpoint snapshot codec \"shell.snapshot.v2\"")
+ );
+ }
+
+ #[test]
+ fn only_local_protocol_failures_end_the_client() {
+ let remote =
+ ClientEndpointId::Ssh(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap());
+ assert!(protocol_failure_is_fatal(&ClientEndpointId::Local));
+ assert!(!protocol_failure_is_fatal(&remote));
+ }
+}
diff --git a/src/client/endpoint/health.rs b/src/client/endpoint/health.rs
new file mode 100644
index 00000000..87c08952
--- /dev/null
+++ b/src/client/endpoint/health.rs
@@ -0,0 +1,99 @@
+use std::time::{Duration, Instant};
+
+pub(super) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
+pub(super) const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum HealthAction {
+ None,
+ Ping,
+ Expired,
+}
+
+pub(super) struct EndpointHealth {
+ connected_at: Instant,
+ last_received: Instant,
+ ping_sent_at: Option<Instant>,
+ ready: bool,
+}
+
+impl EndpointHealth {
+ pub(super) fn new(now: Instant) -> Self {
+ Self {
+ connected_at: now,
+ last_received: now,
+ ping_sent_at: None,
+ ready: false,
+ }
+ }
+
+ pub(super) fn received(&mut self, now: Instant) {
+ self.last_received = now;
+ self.ping_sent_at = None;
+ }
+
+ pub(super) fn ready(&mut self) {
+ self.ready = true;
+ }
+
+ pub(super) fn action(&self, now: Instant) -> HealthAction {
+ let initial_snapshot_expired =
+ !self.ready && now.saturating_duration_since(self.connected_at) >= HEARTBEAT_TIMEOUT;
+ let probe_expired = self
+ .ping_sent_at
+ .is_some_and(|sent_at| now.saturating_duration_since(sent_at) >= HEARTBEAT_TIMEOUT);
+ if initial_snapshot_expired || probe_expired {
+ HealthAction::Expired
+ } else if self.ping_sent_at.is_none()
+ && now.saturating_duration_since(self.last_received) >= HEARTBEAT_INTERVAL
+ {
+ HealthAction::Ping
+ } else {
+ HealthAction::None
+ }
+ }
+
+ pub(super) fn ping_sent(&mut self, now: Instant) {
+ self.ping_sent_at = Some(now);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn quiet_connection_is_probed_then_expires_without_a_reply() {
+ let now = Instant::now();
+ let mut health = EndpointHealth::new(now);
+ assert_eq!(health.action(now), HealthAction::None);
+ assert_eq!(health.action(now + HEARTBEAT_INTERVAL), HealthAction::Ping);
+ health.ping_sent(now + HEARTBEAT_INTERVAL);
+ assert_eq!(
+ health.action(now + HEARTBEAT_INTERVAL + HEARTBEAT_TIMEOUT),
+ HealthAction::Expired
+ );
+ }
+
+ #[test]
+ fn any_incoming_message_satisfies_an_outstanding_probe() {
+ let now = Instant::now();
+ let mut health = EndpointHealth::new(now);
+ health.ready();
+ health.ping_sent(now);
+ health.received(now + HEARTBEAT_TIMEOUT - Duration::from_millis(1));
+ assert_eq!(health.action(now + HEARTBEAT_TIMEOUT), HealthAction::None);
+ }
+
+ #[test]
+ fn heartbeats_do_not_hide_a_missing_initial_snapshot() {
+ let now = Instant::now();
+ let mut health = EndpointHealth::new(now);
+ health.ping_sent(now + HEARTBEAT_INTERVAL);
+ health.received(now + HEARTBEAT_INTERVAL + Duration::from_secs(1));
+ assert_eq!(
+ health.action(now + HEARTBEAT_TIMEOUT),
+ HealthAction::Expired
+ );
+ }
+}
diff --git a/src/client/endpoint/message_policy.rs b/src/client/endpoint/message_policy.rs
new file mode 100644
index 00000000..5e84325a
--- /dev/null
+++ b/src/client/endpoint/message_policy.rs
@@ -0,0 +1,124 @@
+use crate::protocol::ServerMessage;
+
+/// Effects that are meaningful only for the endpoint currently holding the host presentation
+/// lease. During a frozen handoff they are intentionally dropped and the committed target asks
+/// for one bounded replay, rather than allowing target graphics or modes onto the source frame.
+pub(crate) fn is_presentation_effect(message: &ServerMessage) -> bool {
+ matches!(
+ message,
+ ServerMessage::MouseCapture { .. }
+ | ServerMessage::ClientShellKeyboardReportAll { .. }
+ | ServerMessage::WindowTitle { .. }
+ | ServerMessage::Graphics { .. }
+ | ServerMessage::GraphicsFile { .. }
+ | ServerMessage::GraphicsTransmissionRetired { .. }
+ | ServerMessage::TerminalBell { .. }
+ )
+}
+
+pub(crate) fn accepts_endpoint_message(
+ endpoint_active: bool,
+ activation_message: bool,
+ command_response: bool,
+ message: &ServerMessage,
+) -> bool {
+ endpoint_active
+ || matches!(
+ message,
+ ServerMessage::EndpointControl { .. }
+ | ServerMessage::SemanticNotification(_)
+ | ServerMessage::ServerShutdown { .. }
+ | ServerMessage::ClientShellSnapshot(_)
+ | ServerMessage::Welcome { .. }
+ )
+ || (activation_message
+ && matches!(
+ message,
+ ServerMessage::PaneSurface(_)
+ | ServerMessage::ClientShellEndpointResponseChunk { .. }
+ ))
+ || (command_response
+ && matches!(
+ message,
+ ServerMessage::ClientShellEndpointResponseChunk { .. }
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn inactive_endpoints_deliver_metadata_but_not_presentation_effects() {
+ assert!(accepts_endpoint_message(
+ false,
+ false,
+ false,
+ &ServerMessage::EndpointControl {
+ kind: "snapshot".into(),
+ data: "{}".into(),
+ }
+ ));
+ assert!(!accepts_endpoint_message(
+ false,
+ false,
+ false,
+ &ServerMessage::Clipboard {
+ data: "text".into()
+ }
+ ));
+ assert!(!accepts_endpoint_message(
+ false,
+ false,
+ false,
+ &ServerMessage::WindowTitle {
+ title: Some("remote".into())
+ }
+ ));
+ }
+
+ #[test]
+ fn frozen_handoffs_drop_target_effects_until_the_post_commit_resync() {
+ assert!(is_presentation_effect(&ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: false,
+ }));
+ assert!(is_presentation_effect(&ServerMessage::GraphicsFile {
+ path: "asset".into(),
+ expected_len: 1,
+ image_id: 1,
+ transfer_id: 1,
+ leading: Vec::new(),
+ control: "a=t".into(),
+ surface_asset: None,
+ }));
+ assert!(!is_presentation_effect(&ServerMessage::EndpointControl {
+ kind: "shell.snapshot.v1".into(),
+ data: "{}".into(),
+ }));
+ }
+
+ #[test]
+ fn pending_activation_accepts_only_its_surface_lane() {
+ assert!(accepts_endpoint_message(
+ false,
+ true,
+ false,
+ &ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id: "boot".into(),
+ request_id: "surface".into(),
+ final_chunk: true,
+ data: Vec::new(),
+ }
+ ));
+ assert!(!accepts_endpoint_message(
+ false,
+ true,
+ false,
+ &ServerMessage::MouseCapture {
+ enabled: true,
+ sgr_pixels: false,
+ }
+ ));
+ }
+}
diff --git a/src/client/endpoint/registry.rs b/src/client/endpoint/registry.rs
new file mode 100644
index 00000000..437f39f3
--- /dev/null
+++ b/src/client/endpoint/registry.rs
@@ -0,0 +1,629 @@
+use std::collections::{HashMap, HashSet};
+use std::io;
+use std::time::Instant;
+
+use super::health::{EndpointHealth, HealthAction};
+use super::ClientEndpointId;
+use crate::protocol::ClientMessage;
+
+pub(crate) trait EndpointTransport: Send {
+ fn send(&mut self, message: &ClientMessage) -> io::Result<()>;
+
+ fn disconnect(&mut self) {}
+
+ fn flush(&mut self, _deadline: Instant) -> io::Result<()> {
+ Ok(())
+ }
+
+ fn take_error(&mut self) -> Option<io::Error> {
+ None
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct EndpointNegotiation {
+ methods: HashSet<String>,
+ capabilities: HashSet<String>,
+}
+
+impl EndpointNegotiation {
+ pub(crate) fn new(methods: Vec<String>, capabilities: Vec<String>) -> Self {
+ Self {
+ methods: methods.into_iter().collect(),
+ capabilities: capabilities.into_iter().collect(),
+ }
+ }
+
+ pub(crate) fn methods(&self) -> Vec<String> {
+ self.methods.iter().cloned().collect()
+ }
+
+ pub(crate) fn supports_method(&self, method: &str) -> bool {
+ self.methods.contains(method)
+ }
+
+ pub(crate) fn supports_capability(&self, capability: &str) -> bool {
+ self.capabilities.contains(capability)
+ }
+
+ pub(crate) fn supports_surface_interest(&self) -> bool {
+ self.supports_capability(crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY)
+ && self.supports_capability(
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY,
+ )
+ && self.supports_method("client_shell.surface.set")
+ }
+
+ pub(crate) fn supports_health_check(&self) -> bool {
+ self.supports_capability(crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY)
+ }
+}
+
+pub(crate) struct EndpointConnection {
+ transport: Box<dyn EndpointTransport>,
+ pub(crate) generation: u64,
+ pub(crate) surface_active: bool,
+ pub(crate) negotiation: EndpointNegotiation,
+ health: Option<EndpointHealth>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct EndpointTransportFailure {
+ pub(crate) endpoint_id: ClientEndpointId,
+ pub(crate) generation: u64,
+ pub(crate) kind: io::ErrorKind,
+ pub(crate) message: String,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum EndpointSendOutcome {
+ Sent,
+ NotSent,
+}
+
+pub(crate) struct EndpointRegistry {
+ active: ClientEndpointId,
+ input_enabled: bool,
+ connections: HashMap<ClientEndpointId, EndpointConnection>,
+ failures: Vec<EndpointTransportFailure>,
+}
+
+impl EndpointRegistry {
+ pub(crate) fn empty() -> Self {
+ Self {
+ active: ClientEndpointId::Local,
+ input_enabled: false,
+ connections: HashMap::new(),
+ failures: Vec::new(),
+ }
+ }
+
+ pub(crate) fn new(
+ local: impl EndpointTransport + 'static,
+ generation: u64,
+ negotiation: EndpointNegotiation,
+ ) -> Self {
+ let mut registry = Self::empty();
+ registry.input_enabled = true;
+ registry.insert(
+ ClientEndpointId::Local,
+ local,
+ generation,
+ negotiation,
+ true,
+ );
+ registry
+ }
+
+ pub(crate) fn active_id(&self) -> &ClientEndpointId {
+ &self.active
+ }
+
+ pub(crate) fn active_surface_available(&self) -> bool {
+ self.input_enabled
+ && self
+ .connections
+ .get(&self.active)
+ .is_some_and(|connection| connection.surface_active)
+ }
+
+ pub(crate) fn select_unavailable_local(&mut self) {
+ self.active = ClientEndpointId::Local;
+ self.freeze_input();
+ }
+
+ pub(crate) fn freeze_input(&mut self) {
+ self.input_enabled = false;
+ }
+
+ pub(crate) fn unfreeze_input(&mut self) {
+ self.input_enabled = true;
+ }
+
+ pub(crate) fn connection(&self, endpoint_id: &ClientEndpointId) -> Option<&EndpointConnection> {
+ self.connections.get(endpoint_id)
+ }
+
+ pub(crate) fn insert(
+ &mut self,
+ endpoint_id: ClientEndpointId,
+ transport: impl EndpointTransport + 'static,
+ generation: u64,
+ negotiation: EndpointNegotiation,
+ surface_active: bool,
+ ) {
+ let health = (!endpoint_id.is_local() && negotiation.supports_health_check())
+ .then(|| EndpointHealth::new(Instant::now()));
+ if let Some(mut previous) = self.connections.insert(
+ endpoint_id,
+ EndpointConnection {
+ transport: Box::new(transport),
+ generation,
+ surface_active,
+ negotiation,
+ health,
+ },
+ ) {
+ previous.transport.disconnect();
+ }
+ }
+
+ pub(crate) fn accepts(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool {
+ self.connections
+ .get(endpoint_id)
+ .is_some_and(|connection| connection.generation == generation)
+ }
+
+ pub(crate) fn received(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ now: Instant,
+ ) {
+ if let Some(health) = self
+ .connections
+ .get_mut(endpoint_id)
+ .filter(|connection| connection.generation == generation)
+ .and_then(|connection| connection.health.as_mut())
+ {
+ health.received(now);
+ }
+ }
+
+ pub(crate) fn mark_ready(&mut self, endpoint_id: &ClientEndpointId, generation: u64) {
+ if let Some(health) = self
+ .connections
+ .get_mut(endpoint_id)
+ .filter(|connection| connection.generation == generation)
+ .and_then(|connection| connection.health.as_mut())
+ {
+ health.ready();
+ }
+ }
+
+ pub(crate) fn tick_health(&mut self, now: Instant) {
+ let actions = self
+ .connections
+ .iter()
+ .filter_map(|(endpoint_id, connection)| {
+ connection
+ .health
+ .as_ref()
+ .map(|health| (endpoint_id.clone(), health.action(now)))
+ })
+ .filter(|(_, action)| *action != HealthAction::None)
+ .collect::<Vec<_>>();
+ for (endpoint_id, action) in actions {
+ match action {
+ HealthAction::None => {}
+ HealthAction::Ping => {
+ let ping = ClientMessage::EndpointControl {
+ kind: crate::protocol::endpoint::HEALTH_PING_KIND.into(),
+ data: String::new(),
+ };
+ if self.send_to(&endpoint_id, &ping) == EndpointSendOutcome::Sent {
+ if let Some(health) = self
+ .connections
+ .get_mut(&endpoint_id)
+ .and_then(|connection| connection.health.as_mut())
+ {
+ health.ping_sent(now);
+ }
+ }
+ }
+ HealthAction::Expired => self.record_failure(
+ endpoint_id,
+ io::Error::new(io::ErrorKind::TimedOut, "endpoint health check timed out"),
+ ),
+ }
+ }
+ }
+
+ pub(crate) fn set_active(&mut self, endpoint_id: &ClientEndpointId) -> bool {
+ if !self
+ .connections
+ .get(endpoint_id)
+ .is_some_and(|connection| connection.surface_active)
+ {
+ return false;
+ }
+ self.active = endpoint_id.clone();
+ true
+ }
+
+ pub(crate) fn set_surface_active(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ active: bool,
+ ) -> bool {
+ let Some(connection) = self.connections.get_mut(endpoint_id) else {
+ return false;
+ };
+ let changed = connection.surface_active != active;
+ connection.surface_active = active;
+ changed
+ }
+
+ pub(crate) fn send(&mut self, message: &ClientMessage) -> EndpointSendOutcome {
+ let endpoint_id = self.active.clone();
+ self.send_to(&endpoint_id, message)
+ }
+
+ pub(crate) fn send_to(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ message: &ClientMessage,
+ ) -> EndpointSendOutcome {
+ let result = self
+ .connections
+ .get_mut(endpoint_id)
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "endpoint is unavailable"))
+ .and_then(|connection| connection.transport.send(message));
+ match result {
+ Ok(()) => EndpointSendOutcome::Sent,
+ Err(error) => {
+ self.record_failure(endpoint_id.clone(), error);
+ EndpointSendOutcome::NotSent
+ }
+ }
+ }
+
+ pub(crate) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) {
+ self.failures
+ .retain(|failure| &failure.endpoint_id != endpoint_id);
+ if let Some(mut connection) = self.connections.remove(endpoint_id) {
+ connection.transport.disconnect();
+ }
+ }
+
+ pub(crate) fn fail(&mut self, endpoint_id: &ClientEndpointId, error: io::Error) {
+ self.record_failure(endpoint_id.clone(), error);
+ }
+
+ pub(crate) fn take_failures(&mut self) -> Vec<EndpointTransportFailure> {
+ let errors = self
+ .connections
+ .iter_mut()
+ .filter_map(|(id, connection)| {
+ connection
+ .transport
+ .take_error()
+ .map(|error| (id.clone(), error))
+ })
+ .collect::<Vec<_>>();
+ for (endpoint_id, error) in errors {
+ self.record_failure(endpoint_id, error);
+ }
+ std::mem::take(&mut self.failures)
+ }
+
+ fn record_failure(&mut self, endpoint_id: ClientEndpointId, error: io::Error) {
+ let Some(generation) = self
+ .connections
+ .get(&endpoint_id)
+ .map(|connection| connection.generation)
+ else {
+ return;
+ };
+ let failure = EndpointTransportFailure {
+ endpoint_id: endpoint_id.clone(),
+ generation,
+ kind: error.kind(),
+ message: error.to_string(),
+ };
+ if let Some(mut connection) = self.connections.remove(&endpoint_id) {
+ connection.transport.disconnect();
+ }
+ if let Some(existing) = self
+ .failures
+ .iter_mut()
+ .find(|existing| existing.endpoint_id == endpoint_id)
+ {
+ *existing = failure;
+ } else {
+ self.failures.push(failure);
+ }
+ }
+}
+
+impl Drop for EndpointRegistry {
+ fn drop(&mut self) {
+ let deadline = Instant::now() + std::time::Duration::from_millis(250);
+ for connection in self.connections.values_mut() {
+ let _ = connection.transport.send(&ClientMessage::Detach);
+ }
+ for connection in self.connections.values_mut() {
+ let _ = connection.transport.flush(deadline);
+ connection.transport.disconnect();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{Arc, Mutex};
+
+ use super::*;
+
+ struct FakeTransport {
+ sent: Arc<Mutex<Vec<ClientMessage>>>,
+ error: Option<io::ErrorKind>,
+ }
+
+ impl EndpointTransport for FakeTransport {
+ fn send(&mut self, message: &ClientMessage) -> io::Result<()> {
+ if let Some(kind) = self.error {
+ return Err(io::Error::new(kind, "fake transport failure"));
+ }
+ self.sent.lock().unwrap().push(message.clone());
+ Ok(())
+ }
+ }
+
+ fn negotiation() -> EndpointNegotiation {
+ EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![
+ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
+ crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
+ crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(),
+ ],
+ )
+ }
+
+ fn profile() -> crate::client::endpoint::ProfileId {
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()
+ }
+
+ #[test]
+ fn endpoint_failures_do_not_remove_other_connections() {
+ let local_sent = Arc::new(Mutex::new(Vec::new()));
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: local_sent.clone(),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: Some(io::ErrorKind::BrokenPipe),
+ },
+ 2,
+ negotiation(),
+ true,
+ );
+ assert!(registry.set_active(&ssh_id));
+
+ assert_eq!(
+ registry.send(&ClientMessage::ClientShellFocus { focused: true }),
+ EndpointSendOutcome::NotSent
+ );
+ assert!(registry.connection(&ssh_id).is_none());
+ assert!(registry.connection(&ClientEndpointId::Local).is_some());
+ assert_eq!(registry.take_failures()[0].endpoint_id, ssh_id);
+
+ assert!(registry.set_active(&ClientEndpointId::Local));
+ assert_eq!(
+ registry.send(&ClientMessage::ClientShellFocus { focused: true }),
+ EndpointSendOutcome::Sent
+ );
+ assert_eq!(local_sent.lock().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn reconnecting_active_identity_does_not_count_as_an_active_surface() {
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ true,
+ );
+ assert!(registry.set_active(&ssh_id));
+ assert!(registry.active_surface_available());
+ registry.disconnect(&ssh_id);
+ registry.insert(
+ ssh_id,
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 3,
+ negotiation(),
+ false,
+ );
+ assert!(!registry.active_surface_available());
+ }
+
+ #[test]
+ fn recovered_local_uses_transport_failure_not_remote_health_probes() {
+ let mut registry = EndpointRegistry::empty();
+ let sent = Arc::new(Mutex::new(Vec::new()));
+ registry.insert(
+ ClientEndpointId::Local,
+ FakeTransport {
+ sent: sent.clone(),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+ registry.tick_health(Instant::now() + std::time::Duration::from_secs(300));
+ assert!(registry.connection(&ClientEndpointId::Local).is_some());
+ assert!(sent.lock().unwrap().is_empty());
+ assert!(registry.take_failures().is_empty());
+ }
+
+ #[test]
+ fn negotiated_remote_health_probe_expires_the_connection() {
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ let sent = Arc::new(Mutex::new(Vec::new()));
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: sent.clone(),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+ let now = Instant::now();
+ registry.tick_health(now + super::super::health::HEARTBEAT_INTERVAL);
+ assert!(matches!(
+ sent.lock().unwrap().as_slice(),
+ [ClientMessage::EndpointControl { kind, .. }]
+ if kind == crate::protocol::endpoint::HEALTH_PING_KIND
+ ));
+
+ registry.tick_health(
+ now + super::super::health::HEARTBEAT_INTERVAL
+ + super::super::health::HEARTBEAT_TIMEOUT,
+ );
+ assert!(registry.connection(&ssh_id).is_none());
+ assert_eq!(registry.take_failures()[0].kind, io::ErrorKind::TimedOut);
+ }
+
+ #[test]
+ fn a_ready_endpoint_can_stay_connected_after_the_initial_deadline() {
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ let ssh_id = ClientEndpointId::Ssh(profile());
+ registry.insert(
+ ssh_id.clone(),
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+ let now = Instant::now();
+ registry.mark_ready(&ssh_id, 2);
+ registry.received(&ssh_id, 2, now + super::super::health::HEARTBEAT_INTERVAL);
+ registry.tick_health(now + super::super::health::HEARTBEAT_TIMEOUT);
+ assert!(registry.connection(&ssh_id).is_some());
+ }
+
+ #[test]
+ fn negotiated_surface_interest_requires_capability_and_method() {
+ assert!(negotiation().supports_surface_interest());
+ assert!(negotiation().supports_health_check());
+ assert!(
+ !EndpointNegotiation::new(vec!["client_shell.surface.set".into()], Vec::new())
+ .supports_surface_interest()
+ );
+ assert!(!EndpointNegotiation::new(
+ Vec::new(),
+ vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()]
+ )
+ .supports_surface_interest());
+ assert!(!EndpointNegotiation::new(
+ vec!["client_shell.surface.set".into()],
+ vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()]
+ )
+ .supports_surface_interest());
+ }
+
+ #[test]
+ fn dropping_registry_detaches_every_connected_endpoint() {
+ let local_sent = Arc::new(Mutex::new(Vec::new()));
+ let remote_sent = Arc::new(Mutex::new(Vec::new()));
+ let mut registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: local_sent.clone(),
+ error: None,
+ },
+ 1,
+ negotiation(),
+ );
+ registry.insert(
+ ClientEndpointId::Ssh(profile()),
+ FakeTransport {
+ sent: remote_sent.clone(),
+ error: None,
+ },
+ 2,
+ negotiation(),
+ false,
+ );
+
+ drop(registry);
+
+ assert!(matches!(
+ local_sent.lock().unwrap().as_slice(),
+ [ClientMessage::Detach]
+ ));
+ assert!(matches!(
+ remote_sent.lock().unwrap().as_slice(),
+ [ClientMessage::Detach]
+ ));
+ }
+
+ #[test]
+ fn stale_generations_are_rejected() {
+ let registry = EndpointRegistry::new(
+ FakeTransport {
+ sent: Arc::new(Mutex::new(Vec::new())),
+ error: None,
+ },
+ 7,
+ negotiation(),
+ );
+ assert!(registry.accepts(&ClientEndpointId::Local, 7));
+ assert!(!registry.accepts(&ClientEndpointId::Local, 6));
+ }
+}
diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs
new file mode 100644
index 00000000..458ce927
--- /dev/null
+++ b/src/client/endpoint/supervisor.rs
@@ -0,0 +1,492 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use super::{ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, NativeEndpointTransport};
+use crate::protocol::{ClientSurfaceSize, RenderEncoding};
+use interprocess::TryClone as _;
+
+const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500);
+const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
+
+#[derive(Clone, Copy)]
+pub(crate) struct EndpointConnectOptions {
+ pub(crate) cols: u16,
+ pub(crate) rows: u16,
+ pub(crate) cell_width_px: u32,
+ pub(crate) cell_height_px: u32,
+ pub(crate) pixel_geometry_exact: bool,
+ pub(crate) surface_size: ClientSurfaceSize,
+ pub(crate) endpoint_keybindings: bool,
+ pub(crate) mouse_capture: bool,
+}
+
+pub(crate) enum EndpointSupervisorEvent {
+ Status {
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+ status: ClientEndpointStatus,
+ message: String,
+ },
+ Connected {
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+ reader: crate::ipc::LocalStream,
+ writer: NativeEndpointTransport,
+ negotiation: EndpointNegotiation,
+ },
+}
+
+#[derive(Clone)]
+enum ConnectTarget {
+ Local(PathBuf),
+ Ssh(super::SavedSshEndpoint),
+}
+
+struct ReconnectState {
+ target: ConnectTarget,
+ attempts: u32,
+ next_attempt: Option<Instant>,
+ in_flight: bool,
+ generation: Option<u64>,
+}
+
+impl ReconnectState {
+ fn new(target: ConnectTarget, now: Instant) -> Self {
+ Self {
+ target,
+ attempts: 0,
+ next_attempt: Some(now),
+ in_flight: false,
+ generation: None,
+ }
+ }
+}
+
+pub(crate) struct EndpointSupervisors {
+ endpoints: HashMap<ClientEndpointId, ReconnectState>,
+ next_generation: u64,
+ shutdown: Arc<AtomicBool>,
+}
+
+impl EndpointSupervisors {
+ pub(crate) fn new(profiles: &[super::SavedSshEndpoint], now: Instant) -> Self {
+ let endpoints = profiles
+ .iter()
+ .filter(|profile| profile.enabled)
+ .map(|profile| {
+ (
+ ClientEndpointId::Ssh(profile.id.clone()),
+ ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now),
+ )
+ })
+ .collect();
+ Self {
+ endpoints,
+ next_generation: 2,
+ shutdown: Arc::new(AtomicBool::new(false)),
+ }
+ }
+
+ pub(crate) fn add_local(&mut self, path: PathBuf, generation: Option<u64>, now: Instant) {
+ let mut state = ReconnectState::new(ConnectTarget::Local(path), now);
+ state.generation = generation;
+ if generation.is_some() {
+ state.next_attempt = None;
+ }
+ self.endpoints.insert(ClientEndpointId::Local, state);
+ }
+
+ pub(crate) fn reconcile_profiles(
+ &mut self,
+ profiles: &[super::SavedSshEndpoint],
+ now: Instant,
+ ) -> Vec<ClientEndpointId> {
+ let mut retired = Vec::new();
+ self.endpoints.retain(|endpoint_id, state| {
+ let ConnectTarget::Ssh(previous) = &state.target else {
+ return true;
+ };
+ let keep = profiles.iter().any(|profile| {
+ profile.id == previous.id
+ && profile.enabled
+ && profile.target == previous.target
+ && profile.session == previous.session
+ });
+ if !keep {
+ retired.push(endpoint_id.clone());
+ }
+ keep
+ });
+ for profile in profiles.iter().filter(|profile| profile.enabled) {
+ let state = self
+ .endpoints
+ .entry(ClientEndpointId::Ssh(profile.id.clone()))
+ .or_insert_with(|| ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now));
+ state.target = ConnectTarget::Ssh(profile.clone());
+ }
+ retired
+ }
+
+ pub(crate) fn spawn_due(
+ &mut self,
+ now: Instant,
+ options: EndpointConnectOptions,
+ event_tx: &tokio::sync::mpsc::Sender<EndpointSupervisorEvent>,
+ ) {
+ for (endpoint_id, state) in &mut self.endpoints {
+ if state.in_flight || state.next_attempt.is_none_or(|deadline| deadline > now) {
+ continue;
+ }
+ state.in_flight = true;
+ state.next_attempt = None;
+ let generation = self.next_generation;
+ state.generation = Some(generation);
+ self.next_generation = self.next_generation.saturating_add(1);
+ let endpoint_id = endpoint_id.clone();
+ let target = state.target.clone();
+ let event_tx = event_tx.clone();
+ let shutdown = self.shutdown.clone();
+ tokio::spawn(async move {
+ if shutdown.load(Ordering::Acquire) {
+ return;
+ }
+ let task_endpoint_id = endpoint_id.clone();
+ let result = tokio::task::spawn_blocking(move || {
+ connect_once(&target, options, endpoint_id, generation)
+ })
+ .await;
+ let event = match result {
+ Ok(Ok(event)) => event,
+ Ok(Err(error)) => EndpointSupervisorEvent::Status {
+ endpoint_id: task_endpoint_id,
+ generation,
+ status: if failure_needs_attention(&error) {
+ ClientEndpointStatus::Attention
+ } else {
+ ClientEndpointStatus::Reconnecting
+ },
+ message: error.to_string(),
+ },
+ Err(error) => EndpointSupervisorEvent::Status {
+ endpoint_id: task_endpoint_id,
+ generation,
+ status: ClientEndpointStatus::Reconnecting,
+ message: format!("endpoint connection task stopped unexpectedly: {error}"),
+ },
+ };
+ if !shutdown.load(Ordering::Acquire) {
+ let _ = event_tx.send(event).await;
+ }
+ });
+ }
+ }
+
+ pub(crate) fn record_status(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ status: ClientEndpointStatus,
+ now: Instant,
+ ) -> bool {
+ let Some(state) = self.endpoints.get_mut(endpoint_id) else {
+ return false;
+ };
+ if state.generation != Some(generation) {
+ return false;
+ }
+ state.in_flight = false;
+ match status {
+ ClientEndpointStatus::Online => {
+ state.attempts = 0;
+ state.next_attempt = None;
+ }
+ ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => {
+ state.next_attempt = None
+ }
+ ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => {
+ state.attempts = state.attempts.saturating_add(1);
+ state.next_attempt = Some(now + retry_delay(state.attempts));
+ }
+ }
+ true
+ }
+
+ pub(crate) fn disconnected(
+ &mut self,
+ endpoint_id: &ClientEndpointId,
+ generation: u64,
+ now: Instant,
+ ) -> bool {
+ self.record_status(
+ endpoint_id,
+ generation,
+ ClientEndpointStatus::Reconnecting,
+ now,
+ )
+ }
+}
+
+impl Drop for EndpointSupervisors {
+ fn drop(&mut self) {
+ self.shutdown.store(true, Ordering::Release);
+ }
+}
+
+fn connect_once(
+ target: &ConnectTarget,
+ options: EndpointConnectOptions,
+ endpoint_id: ClientEndpointId,
+ generation: u64,
+) -> Result<EndpointSupervisorEvent, std::io::Error> {
+ let (mut stream, lifetime): (_, Box<dyn Send>) = match target {
+ ConnectTarget::Local(path) => {
+ let stream = crate::ipc::connect_local_stream(path).map_err(|error| {
+ // An absent Local socket is transient, unlike a missing SSH install.
+ if error.kind() == std::io::ErrorKind::NotFound {
+ std::io::Error::new(
+ std::io::ErrorKind::ConnectionRefused,
+ "Local is unavailable; start its server to reconnect",
+ )
+ } else {
+ error
+ }
+ })?;
+ (stream, Box::new(()))
+ }
+ ConnectTarget::Ssh(profile) => {
+ let connected = crate::remote::connect_saved_ssh(profile.id.as_str(), &profile.target, &profile.session).map_err(|error| {
+ if failure_needs_attention(&error) {
+ std::io::Error::new(error.kind(), format!("{error}. Run `{}` interactively to approve setup, then restart this client", crate::remote::saved_ssh_bootstrap_command(&profile.target, &profile.session)))
+ } else { error }
+ })?;
+ (connected.stream, Box::new(connected.bridge))
+ }
+ };
+ let handshake = super::super::do_handshake(
+ &mut stream,
+ options.cols,
+ options.rows,
+ options.cell_width_px,
+ options.cell_height_px,
+ options.pixel_geometry_exact,
+ Some(options.surface_size),
+ options.endpoint_keybindings,
+ options.mouse_capture,
+ false,
+ )
+ .map_err(handshake_error)?;
+ if handshake.encoding != RenderEncoding::SemanticFrame {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "endpoint did not negotiate the semantic client shell",
+ ));
+ }
+ let negotiation = EndpointNegotiation::new(
+ handshake.endpoint_methods.unwrap_or_default(),
+ handshake.endpoint_capabilities.unwrap_or_default(),
+ );
+ if !negotiation.supports_surface_interest()
+ || (!endpoint_id.is_local() && !negotiation.supports_health_check())
+ {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::Unsupported,
+ "this machine needs a server update before it can participate in multi-machine viewing",
+ ));
+ }
+ let reader = stream.try_clone()?;
+ let writer = NativeEndpointTransport::with_lifetime(stream, lifetime)?;
+ Ok(EndpointSupervisorEvent::Connected {
+ endpoint_id,
+ generation,
+ reader,
+ writer,
+ negotiation,
+ })
+}
+
+fn failure_needs_attention(error: &std::io::Error) -> bool {
+ crate::remote::saved_ssh_failure_needs_attention(error)
+}
+
+fn handshake_error(error: crate::client::ClientError) -> std::io::Error {
+ use crate::client::ClientError;
+ use crate::protocol::FramingError;
+ match error {
+ ClientError::ConnectionFailed(error) | ClientError::ConnectionLost(error) => error,
+ ClientError::HandshakeRejected { error, .. } => {
+ std::io::Error::new(std::io::ErrorKind::Unsupported, error)
+ }
+ ClientError::Protocol(FramingError::Io(error)) => error,
+ ClientError::Protocol(error) => {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
+ }
+ ClientError::ServerShutdown { reason } => std::io::Error::new(
+ std::io::ErrorKind::ConnectionAborted,
+ reason.unwrap_or_else(|| "server shut down during handshake".into()),
+ ),
+ }
+}
+
+fn retry_delay(attempt: u32) -> Duration {
+ INITIAL_RETRY_DELAY
+ .saturating_mul(
+ 1_u32
+ .checked_shl(attempt.saturating_sub(1).min(6))
+ .unwrap_or(u32::MAX),
+ )
+ .min(MAX_RETRY_DELAY)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::client::endpoint::ProfileId;
+
+ fn profile() -> super::super::SavedSshEndpoint {
+ super::super::SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "build".into(),
+ session: "agents".into(),
+ enabled: true,
+ }
+ }
+
+ #[test]
+ fn live_catalog_preserves_renamed_connections_and_local_recovery() {
+ let now = Instant::now();
+ let mut profile = profile();
+ let id = ClientEndpointId::Ssh(profile.id.clone());
+ let mut supervisors = EndpointSupervisors::new(&[profile.clone()], now);
+ supervisors.add_local(PathBuf::from("local"), Some(1), now);
+ let state = supervisors.endpoints.get_mut(&id).unwrap();
+ state.generation = Some(7);
+ state.attempts = 3;
+ state.next_attempt = Some(now + Duration::from_secs(4));
+ profile.label = "Renamed".into();
+ assert!(supervisors.reconcile_profiles(&[profile], now).is_empty());
+ let state = &supervisors.endpoints[&id];
+ assert_eq!(state.generation, Some(7));
+ assert_eq!(state.attempts, 3);
+ assert_eq!(state.next_attempt, Some(now + Duration::from_secs(4)));
+ assert_eq!(
+ supervisors.endpoints[&ClientEndpointId::Local].generation,
+ Some(1)
+ );
+ }
+
+ #[test]
+ fn live_catalog_add_disable_enable_and_remove_fence_late_connections() {
+ let now = Instant::now();
+ let mut profile = profile();
+ let id = ClientEndpointId::Ssh(profile.id.clone());
+ let mut supervisors = EndpointSupervisors::new(&[], now);
+ assert!(supervisors
+ .reconcile_profiles(&[profile.clone()], now)
+ .is_empty());
+ assert_eq!(supervisors.endpoints[&id].next_attempt, Some(now));
+ supervisors.endpoints.get_mut(&id).unwrap().generation = Some(7);
+ supervisors.next_generation = 8;
+ profile.enabled = false;
+ assert_eq!(
+ supervisors.reconcile_profiles(&[profile.clone()], now),
+ vec![id.clone()]
+ );
+ assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now));
+ profile.enabled = true;
+ assert!(supervisors
+ .reconcile_profiles(&[profile.clone()], now)
+ .is_empty());
+ assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now));
+ assert_eq!(supervisors.next_generation, 8);
+ assert_eq!(supervisors.reconcile_profiles(&[], now), vec![id.clone()]);
+ assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now));
+ }
+
+ #[test]
+ fn live_catalog_destination_change_retires_only_that_machine() {
+ let now = Instant::now();
+ let mut changed = profile();
+ let other = super::super::SavedSshEndpoint::new("Other", "other", "main").unwrap();
+ let id = ClientEndpointId::Ssh(changed.id.clone());
+ let other_id = ClientEndpointId::Ssh(other.id.clone());
+ let mut supervisors = EndpointSupervisors::new(&[changed.clone(), other.clone()], now);
+ supervisors.endpoints.get_mut(&id).unwrap().generation = Some(2);
+ supervisors.endpoints.get_mut(&other_id).unwrap().generation = Some(3);
+ changed.session = "another-session".into();
+ assert_eq!(
+ supervisors.reconcile_profiles(&[changed, other], now),
+ vec![id.clone()]
+ );
+ assert_eq!(supervisors.endpoints[&id].generation, None);
+ assert_eq!(supervisors.endpoints[&other_id].generation, Some(3));
+ }
+
+ #[test]
+ fn retry_backoff_is_bounded() {
+ assert_eq!(retry_delay(1), INITIAL_RETRY_DELAY);
+ assert_eq!(retry_delay(100), MAX_RETRY_DELAY);
+ }
+
+ #[test]
+ fn handshake_network_failures_retry_but_incompatibility_needs_attention() {
+ let timeout = handshake_error(crate::client::ClientError::ConnectionLost(
+ std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"),
+ ));
+ assert!(!failure_needs_attention(&timeout));
+ let rejected = handshake_error(crate::client::ClientError::HandshakeRejected {
+ version: 1,
+ error: "surface capability missing".into(),
+ });
+ assert_eq!(rejected.kind(), std::io::ErrorKind::Unsupported);
+ assert!(failure_needs_attention(&rejected));
+ }
+
+ #[test]
+ fn healthy_local_only_retries_after_its_connection_fails() {
+ let now = Instant::now();
+ let mut supervisors = EndpointSupervisors::new(&[profile()], now);
+ supervisors.add_local(PathBuf::from("local.sock"), Some(1), now);
+ assert!(supervisors.endpoints[&ClientEndpointId::Local]
+ .next_attempt
+ .is_none());
+ assert!(!supervisors.disconnected(&ClientEndpointId::Local, 0, now));
+ assert!(supervisors.endpoints[&ClientEndpointId::Local]
+ .next_attempt
+ .is_none());
+ assert!(supervisors.disconnected(&ClientEndpointId::Local, 1, now));
+ assert_eq!(
+ supervisors.endpoints[&ClientEndpointId::Local].next_attempt,
+ Some(now + INITIAL_RETRY_DELAY)
+ );
+ assert_eq!(
+ supervisors.endpoints[&ClientEndpointId::Ssh(profile().id)].next_attempt,
+ Some(now)
+ );
+ }
+
+ #[test]
+ fn ssh_recovery_rejects_stale_generations_and_stops_retries_for_attention() {
+ let now = Instant::now();
+ let mut supervisors = EndpointSupervisors::new(&[profile()], now);
+ let endpoint_id = ClientEndpointId::Ssh(profile().id);
+ supervisors
+ .endpoints
+ .get_mut(&endpoint_id)
+ .unwrap()
+ .generation = Some(4);
+ assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Online, now));
+ assert!(!supervisors.disconnected(&endpoint_id, 3, now));
+ assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none());
+ assert!(supervisors.disconnected(&endpoint_id, 4, now));
+ assert_eq!(
+ supervisors.endpoints[&endpoint_id].next_attempt,
+ Some(now + INITIAL_RETRY_DELAY)
+ );
+ assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Attention, now));
+ assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none());
+ }
+}
diff --git a/src/client/endpoint/writer.rs b/src/client/endpoint/writer.rs
new file mode 100644
index 00000000..973ff234
--- /dev/null
+++ b/src/client/endpoint/writer.rs
@@ -0,0 +1,400 @@
+use std::io;
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::{mpsc, Arc, Mutex};
+use std::time::{Duration, Instant};
+
+use interprocess::local_socket::traits::Stream as _;
+
+use super::EndpointTransport;
+use crate::ipc::LocalStream;
+use crate::protocol::ClientMessage;
+
+const MAX_QUEUED_MESSAGES: usize = 256;
+const MAX_QUEUED_BYTES: usize = 2 * crate::protocol::MAX_GRAPHICS_FRAME_SIZE;
+const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
+const IO_POLL_INTERVAL: Duration = Duration::from_millis(2);
+
+enum WriterCommand {
+ Frame(Vec<u8>),
+ Flush(mpsc::Sender<()>),
+}
+
+/// The UI only enqueues complete frames. A worker owns partial writes, cancellation, and the
+/// bridge lifetime, so neither socket backpressure nor bridge teardown can block other endpoints.
+pub(crate) struct NativeEndpointTransport {
+ sender: mpsc::SyncSender<WriterCommand>,
+ queued_bytes: Arc<AtomicUsize>,
+ stopped: Arc<AtomicBool>,
+ error: Arc<Mutex<Option<io::Error>>>,
+}
+
+impl NativeEndpointTransport {
+ pub(crate) fn with_lifetime(
+ mut stream: LocalStream,
+ lifetime: impl Send + 'static,
+ ) -> io::Result<Self> {
+ stream.set_nonblocking(true)?;
+ let (sender, receiver) = mpsc::sync_channel::<WriterCommand>(MAX_QUEUED_MESSAGES);
+ let queued_bytes = Arc::new(AtomicUsize::new(0));
+ let stopped = Arc::new(AtomicBool::new(false));
+ let error = Arc::new(Mutex::new(None));
+ let worker_bytes = queued_bytes.clone();
+ let worker_stop = stopped.clone();
+ let worker_error = error.clone();
+ std::thread::Builder::new()
+ .name("endpoint-writer".into())
+ .spawn(move || {
+ let _lifetime = lifetime;
+ while let Ok(command) = receiver.recv() {
+ if worker_stop.load(Ordering::Acquire) {
+ break;
+ }
+ let frame = match command {
+ WriterCommand::Frame(frame) => frame,
+ WriterCommand::Flush(done) => {
+ let _ = done.send(());
+ continue;
+ }
+ };
+ let result = write_frame(&mut stream, &frame, &worker_stop);
+ worker_bytes.fetch_sub(frame.len(), Ordering::AcqRel);
+ if let Err(error) = result {
+ if let Ok(mut slot) = worker_error.lock() {
+ *slot = Some(error);
+ }
+ worker_stop.store(true, Ordering::Release);
+ break;
+ }
+ }
+ })?;
+ Ok(Self {
+ sender,
+ queued_bytes,
+ stopped,
+ error,
+ })
+ }
+
+ pub(crate) fn stop_handle(&self) -> Arc<AtomicBool> {
+ self.stopped.clone()
+ }
+}
+
+impl EndpointTransport for NativeEndpointTransport {
+ fn send(&mut self, message: &ClientMessage) -> io::Result<()> {
+ if self.stopped.load(Ordering::Acquire) {
+ return Err(io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ "endpoint writer stopped",
+ ));
+ }
+ let mut frame = Vec::new();
+ crate::protocol::write_message(&mut frame, message)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
+ let len = frame.len();
+ if self
+ .queued_bytes
+ .fetch_update(Ordering::AcqRel, Ordering::Acquire, |bytes| {
+ bytes
+ .checked_add(len)
+ .filter(|total| *total <= MAX_QUEUED_BYTES)
+ })
+ .is_err()
+ {
+ return Err(queue_full());
+ }
+ self.sender
+ .try_send(WriterCommand::Frame(frame))
+ .map_err(|error| {
+ self.queued_bytes.fetch_sub(len, Ordering::AcqRel);
+ match error {
+ mpsc::TrySendError::Full(_) => queue_full(),
+ mpsc::TrySendError::Disconnected(_) => {
+ io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped")
+ }
+ }
+ })
+ }
+
+ fn disconnect(&mut self) {
+ self.stopped.store(true, Ordering::Release);
+ }
+
+ fn flush(&mut self, deadline: Instant) -> io::Result<()> {
+ let (done, completion) = mpsc::channel();
+ self.sender
+ .try_send(WriterCommand::Flush(done))
+ .map_err(|_| queue_full())?;
+ completion
+ .recv_timeout(deadline.saturating_duration_since(Instant::now()))
+ .map_err(|error| match error {
+ mpsc::RecvTimeoutError::Timeout => {
+ io::Error::new(io::ErrorKind::TimedOut, "endpoint flush timed out")
+ }
+ mpsc::RecvTimeoutError::Disconnected => {
+ io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped")
+ }
+ })
+ }
+
+ fn take_error(&mut self) -> Option<io::Error> {
+ self.error.lock().ok()?.take()
+ }
+}
+
+impl Drop for NativeEndpointTransport {
+ fn drop(&mut self) {
+ self.stopped.store(true, Ordering::Release);
+ }
+}
+
+fn queue_full() -> io::Error {
+ // A message may already be partially written. Retrying or dropping only this message would
+ // lose input ordering; revoke the connection and recover through the normal lifecycle.
+ io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ "endpoint output queue is full",
+ )
+}
+
+fn write_frame(
+ writer: &mut impl io::Write,
+ mut frame: &[u8],
+ stopped: &AtomicBool,
+) -> io::Result<()> {
+ let deadline = Instant::now() + WRITE_TIMEOUT;
+ while !frame.is_empty() && !stopped.load(Ordering::Acquire) {
+ match writer.write(frame) {
+ Ok(0) => {}
+ Ok(written) => {
+ frame = &frame[written..];
+ continue;
+ }
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
+ Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
+ Err(error) => return Err(error),
+ }
+ if Instant::now() >= deadline {
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ "endpoint write timed out",
+ ));
+ }
+ std::thread::sleep(IO_POLL_INTERVAL);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn streams() -> (LocalStream, LocalStream, std::path::PathBuf) {
+ use interprocess::local_socket::traits::Listener as _;
+ let path = std::env::temp_dir().join(format!(
+ "herdr-writer-{}-{}.sock",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ let listener = crate::ipc::bind_private_local_listener(&path).unwrap();
+ let accepting = std::thread::spawn(move || listener.accept().unwrap());
+ let client = crate::ipc::connect_local_stream(&path).unwrap();
+ (client, accepting.join().unwrap(), path)
+ }
+
+ #[test]
+ fn native_endpoint_writer_delivers_ordered_protocol_frames() {
+ let (stream, mut peer, path) = streams();
+ let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap();
+ let (done, received) = mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let first: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ let second: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ done.send((first, second)).unwrap();
+ });
+ transport
+ .send(&ClientMessage::ClientShellFocus { focused: true })
+ .unwrap();
+ transport
+ .send(&ClientMessage::ClientShellFocus { focused: false })
+ .unwrap();
+ let messages = received.recv_timeout(Duration::from_secs(3)).unwrap();
+ assert_eq!(
+ messages,
+ (
+ ClientMessage::ClientShellFocus { focused: true },
+ ClientMessage::ClientShellFocus { focused: false }
+ )
+ );
+ reader.join().unwrap();
+ drop(transport);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn registry_exit_flushes_queued_input_and_a_complete_detach() {
+ let (stream, mut peer, path) = streams();
+ let transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap();
+ let (done, received) = mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let result = (|| {
+ let first: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?;
+ let second: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?;
+ Ok::<_, crate::protocol::FramingError>((first, second))
+ })();
+ done.send(result).unwrap();
+ });
+ let mut registry = super::super::EndpointRegistry::new(
+ transport,
+ 1,
+ super::super::EndpointNegotiation::default(),
+ );
+ let input = ClientMessage::Input {
+ data: b"queued input".to_vec(),
+ };
+ assert_eq!(
+ registry.send(&input),
+ super::super::EndpointSendOutcome::Sent
+ );
+ drop(registry);
+ let (first, second) = received
+ .recv_timeout(Duration::from_secs(3))
+ .unwrap()
+ .expect("clean exit must flush complete frames before closing");
+ assert_eq!(first, input);
+ assert_eq!(second, ClientMessage::Detach);
+ reader.join().unwrap();
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn native_endpoint_flush_drains_large_frames_before_detach() {
+ let (stream, mut peer, path) = streams();
+ let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap();
+ let (done, received) = mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let first: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ let second: ClientMessage =
+ crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap();
+ done.send((first, second)).unwrap();
+ });
+ let input = ClientMessage::Input {
+ data: vec![b'x'; 1024 * 1024],
+ };
+ transport.send(&input).unwrap();
+ transport.send(&ClientMessage::Detach).unwrap();
+ // Large-frame correctness must not depend on the registry's short exit grace period.
+ transport
+ .flush(Instant::now() + Duration::from_secs(10))
+ .unwrap();
+ drop(transport);
+ let (first, second) = received.recv_timeout(Duration::from_secs(10)).unwrap();
+ assert_eq!(first, input);
+ assert_eq!(second, ClientMessage::Detach);
+ reader.join().unwrap();
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn native_endpoint_teardown_does_not_wait_for_a_stalled_peer() {
+ struct Lifetime(mpsc::Sender<()>);
+ impl Drop for Lifetime {
+ fn drop(&mut self) {
+ let _ = self.0.send(());
+ }
+ }
+ let (stream, peer, path) = streams();
+ let (done, dropped) = mpsc::channel();
+ let mut transport = NativeEndpointTransport::with_lifetime(stream, Lifetime(done)).unwrap();
+ transport
+ .send(&ClientMessage::Input {
+ data: vec![b'x'; 2 * 1024 * 1024],
+ })
+ .unwrap();
+ drop(transport);
+ dropped
+ .recv_timeout(Duration::from_secs(3))
+ .expect("worker lifetime is released without peer reads");
+ drop(peer);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn partial_writes_preserve_frame_bytes() {
+ struct PartialWriter(Vec<u8>);
+ impl io::Write for PartialWriter {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ let count = bytes.len().min(3);
+ self.0.extend_from_slice(&bytes[..count]);
+ Ok(count)
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ }
+ let mut writer = PartialWriter(Vec::new());
+ write_frame(&mut writer, b"first frame", &AtomicBool::new(false)).unwrap();
+ write_frame(&mut writer, b"second frame", &AtomicBool::new(false)).unwrap();
+ assert_eq!(writer.0, b"first framesecond frame");
+ }
+
+ #[test]
+ fn a_stalled_write_is_cancellable_without_peer_progress() {
+ struct StalledWriter(mpsc::Sender<()>);
+ impl io::Write for StalledWriter {
+ fn write(&mut self, _: &[u8]) -> io::Result<usize> {
+ let _ = self.0.send(());
+ Err(io::ErrorKind::WouldBlock.into())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ }
+ let stop = Arc::new(AtomicBool::new(false));
+ let worker_stop = stop.clone();
+ let (attempted, attempts) = mpsc::channel();
+ let (done, completion) = mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ let result = write_frame(&mut StalledWriter(attempted), b"input", &worker_stop);
+ done.send(result).unwrap();
+ });
+ attempts.recv_timeout(Duration::from_secs(2)).unwrap();
+ stop.store(true, Ordering::Release);
+ completion
+ .recv_timeout(Duration::from_secs(2))
+ .unwrap()
+ .unwrap();
+ worker.join().unwrap();
+ }
+
+ #[test]
+ fn a_full_queue_is_a_connection_failure_not_silent_input_loss() {
+ let (sender, _receiver) = mpsc::sync_channel(1);
+ let mut transport = NativeEndpointTransport {
+ sender,
+ queued_bytes: Arc::new(AtomicUsize::new(0)),
+ stopped: Arc::new(AtomicBool::new(false)),
+ error: Arc::new(Mutex::new(None)),
+ };
+ transport.send(&ClientMessage::Detach).unwrap();
+ assert_eq!(
+ transport.send(&ClientMessage::Detach).unwrap_err().kind(),
+ io::ErrorKind::ConnectionAborted
+ );
+ transport
+ .queued_bytes
+ .store(MAX_QUEUED_BYTES, Ordering::Release);
+ assert_eq!(
+ transport.send(&ClientMessage::Detach).unwrap_err().kind(),
+ io::ErrorKind::ConnectionAborted
+ );
+ }
+}