From 848075ff8cbc0d121fc6e58d702cb78ece27dce1 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Wed, 7 Oct 2026 23:06:20 +0200 Subject: [PATCH] fix: retain workspace navigation during machine switches (#5061) refs #5019 --- src/client/endpoint/activation_tests.rs | 21 +++++ src/client/shell/tests/endpoints.rs | 101 ++++++++++++++++++++++++ src/client/shell_runtime.rs | 21 +++-- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs index 532342e7f..3cece7875 100644 --- a/src/client/endpoint/activation_tests.rs +++ b/src/client/endpoint/activation_tests.rs @@ -1164,6 +1164,19 @@ fn local_selection_abandons_every_unfinished_remote_handoff_phase() { let mut state = ClientState::test_new(); state.shell = Some(shell); let mut commands = EndpointCommands::default(); + commands.enqueue( + ClientEndpointId::Local, + 1, + "local-boot".into(), + Box::new(crate::api::schema::Request { + id: "abandoned-workspace-focus".into(), + method: crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: "stale-workspace".into(), + }, + ), + }), + ); let mut pending = Some(abandoned); let mut serial = 31; let mut scheduled = None; @@ -1187,6 +1200,14 @@ fn local_selection_abandons_every_unfinished_remote_handoff_phase() { assert_eq!(local.target(), &ClientEndpointId::Local); assert!(!endpoints.active_surface_available()); assert!(state.presentation_frozen); + assert!(commands + .send_next(&ClientEndpointId::Local, &mut endpoints) + .is_empty()); + assert!(!local_sent.lock().unwrap().iter().any(|message| { + matches!(message, crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if serde_json::from_str::(request) + .is_ok_and(|request| request.id == "abandoned-workspace-focus")) + }), "a fresh activation must discard focus retained by an older target epoch"); let activations = local_sent .lock() .unwrap() diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 03b95b97e..c0a7e5235 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -73,6 +73,107 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +#[test] +fn workspace_navigation_waits_for_the_target_presentation_fence() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + struct Capture(std::sync::Arc>>); + impl crate::client::endpoint::EndpointTransport for Capture { + fn send(&mut self, message: &ClientMessage) -> std::io::Result<()> { + self.0.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + for (key, focused, expected, surface_active) in [ + (KeyCode::Down, "ws_1", "ws_2", true), + (KeyCode::Up, "ws_2", "ws_1", true), + (KeyCode::Down, "ws_1", "ws_2", false), + ] { + let (mut state, remote) = state_with_remote(); + let config: Config = + toml::from_str("[keys]\nnext_workspace = 'ctrl+down'\nprevious_workspace = 'ctrl+up'") + .unwrap(); + state.config = ClientShellConfig::from_config(&config); + let mut projection = snapshot(); + projection.boot_id = "remote-boot".into(); + let mut second = projection.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + projection.workspaces.push(second); + projection.focused_workspace_id = Some(focused.into()); + for workspace in &mut projection.workspaces { + workspace.focused = workspace.workspace_id == focused; + } + state.set_endpoint_snapshot(&remote, Box::new(projection)); + assert!(state.activate_endpoint_projection(&remote)); + + let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::empty(); + endpoints.insert( + remote.clone(), + Capture(sent.clone()), + 7, + EndpointNegotiation::default(), + true, + ); + assert!(endpoints.set_active(&remote)); + endpoints.set_surface_active(&remote, surface_active); + // Activation publishes this coherent target before releasing its effects fence. + endpoints.freeze_input(); + let outcome = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(key, KeyModifiers::CONTROL), + )]); + assert!(matches!(outcome.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == expected))); + let mut commands = EndpointCommands::default(); + let mut scheduled = None; + crate::client::shell_runtime::dispatch_client_shell_actions( + outcome.actions, + &mut commands, + &mut endpoints, + Some(&mut state), + &mut Vec::new(), + &mut scheduled, + ) + .unwrap(); + if !surface_active { + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.title == "Action interrupted")); + assert!(commands.disconnect(&remote).is_empty()); + assert!(sent.lock().unwrap().is_empty()); + continue; + } + assert!( + state.visible_endpoint_notice.is_none(), + "navigation was cancelled" + ); + assert!( + sent.lock().unwrap().is_empty(), + "the fence must still block sends" + ); + assert!(!endpoints.active_surface_available()); + + endpoints.unfreeze_input(); + assert!(commands.send_next(&remote, &mut endpoints).is_empty()); + let messages = sent.lock().unwrap(); + let [ClientMessage::ClientShellEndpointRequest { request, .. }] = messages.as_slice() + else { + panic!("queued navigation must reach the target after the fence"); + }; + let request: crate::api::schema::Request = serde_json::from_str(request).unwrap(); + assert!( + matches!(request.method, crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == expected) + ); + } +} + #[cfg(windows)] #[test] fn system_notification_clicks_keep_endpoint_and_boot_identity() { diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index 8435b7f8b..42e6ebee7 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -17,8 +17,16 @@ pub(super) fn dispatch_client_shell_actions( boot_id, request, } => { - if let Some(connection) = endpoints.connection(&endpoint_id).filter(|_| { - endpoints.active_id() == &endpoint_id && endpoints.active_surface_available() + if let Some(connection) = endpoints.connection(&endpoint_id).filter(|connection| { + endpoints.active_id() == &endpoint_id + && (endpoints.active_surface_available() + // The coherent target is visible before its input fence opens. + // Retain workspace navigation; sending remains gated below. + || (connection.surface_active + && matches!( + request.method, + crate::api::schema::Method::WorkspaceFocus(_) + ))) }) { endpoint_commands.enqueue(endpoint_id, connection.generation, boot_id, request); } else if let Some(shell) = shell.as_deref_mut() { @@ -163,10 +171,11 @@ fn install_pending_activation( next_surface_serial: &mut u64, activation: endpoint::PendingEndpointActivation, ) { - let retired = activation - .source_command_lane() - .map(|source| endpoint_commands.retire_lane(source)) - .unwrap_or_default(); + // A fresh target epoch must not replay navigation retained by an abandoned handoff. + let mut retired = endpoint_commands.retire_lane(activation.target()); + if let Some(source) = activation.source_command_lane() { + retired.extend(endpoint_commands.retire_lane(source)); + } if let Some(shell) = state.shell.as_mut() { for request_id in retired { shell.cancel_endpoint_request(&request_id);