From 1918e1bfc7445a6410e813075d204dabd78b58b1 Mon Sep 17 00:00:00 2001 From: XU Ke Date: Thu, 8 Oct 2026 15:56:36 +0800 Subject: [PATCH 1/4] fix(remote): restore offline workspaces and reconnect in place --- src-tauri/src/commands/remote_proxy.rs | 84 +- src-tauri/src/commands/remote_workspace.rs | 10 +- src-tauri/src/commands/workspace_windows.rs | 152 ++- .../remote-connection-status.test.tsx | 103 ++ .../connection/remote-connection-status.tsx | 82 ++ src/contexts/remote-connection-context.tsx | 18 +- src/contexts/remote-connection-gate.test.tsx | 97 ++ .../remote-desktop-transport.test.ts | 959 ++++++++++++++++++ src/lib/transport/remote-desktop-transport.ts | 165 ++- src/lib/transport/types.ts | 11 +- .../app-workspace-store-offline.test.tsx | 149 +++ src/stores/app-workspace-store.test.ts | 88 +- src/stores/app-workspace-store.ts | 5 +- 13 files changed, 1838 insertions(+), 85 deletions(-) create mode 100644 src/components/connection/remote-connection-status.test.tsx create mode 100644 src/components/connection/remote-connection-status.tsx create mode 100644 src/contexts/remote-connection-gate.test.tsx create mode 100644 src/lib/transport/remote-desktop-transport.test.ts create mode 100644 src/stores/app-workspace-store-offline.test.tsx diff --git a/src-tauri/src/commands/remote_proxy.rs b/src-tauri/src/commands/remote_proxy.rs index db5322dfa3..86f7d9784b 100644 --- a/src-tauri/src/commands/remote_proxy.rs +++ b/src-tauri/src/commands/remote_proxy.rs @@ -1762,6 +1762,7 @@ async fn run_ws_task( let event_name = format!("remote-ws-event-{connection_id}"); let ws_url = http_url_to_ws_url(&base_url, WS_EVENTS_PATH); let mut fail_count: u32 = 0; + let mut terminal_failure = false; 'reconnect: loop { if *shutdown_rx.borrow() { @@ -1785,7 +1786,7 @@ async fn run_ws_task( tracing::error!("[RemoteProxy] WS connect failed for connection {connection_id}: {err}"); fail_count += 1; if fail_count >= WS_RECONNECT_FAIL_THRESHOLD { - emit_internal(&app, &entry, &event_name, WS_UNAUTHORIZED_CHANNEL).await; + terminal_failure = true; break; } if backoff_sleep(&mut shutdown_rx, fail_count).await { @@ -1860,7 +1861,7 @@ async fn run_ws_task( emit_internal(&app, &entry, &event_name, WS_DISCONNECTED_CHANNEL).await; fail_count += 1; if fail_count >= WS_RECONNECT_FAIL_THRESHOLD { - emit_internal(&app, &entry, &event_name, WS_UNAUTHORIZED_CHANNEL).await; + terminal_failure = true; break; } if backoff_sleep(&mut shutdown_rx, fail_count).await { @@ -1870,16 +1871,43 @@ async fn run_ws_task( *entry.ready.write().await = false; + // Remove before notifying the frontend that retries stopped. A manual + // in-place retry must create a live task, never rejoin this dying entry. + remove_finished_ws_task(&proxy, connection_id, &entry, || async { + if terminal_failure { + emit_internal(&app, &entry, &event_name, WS_UNAUTHORIZED_CHANNEL).await; + } + }) + .await; +} + +async fn remove_finished_ws_task( + proxy: &Arc, + connection_id: i32, + entry: &Arc, + notify: F, +) -> bool +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ // Cleanup: remove our entry from the proxy state. We only remove if // the stored Arc still points to us — a fresh resubscribe between // our shutdown signal and this cleanup could have already replaced // it, and we mustn't blow away the newer entry. let mut tasks = proxy.tasks.lock().await; if let Some(stored) = tasks.get(&connection_id) { - if Arc::ptr_eq(stored, &entry) { + if Arc::ptr_eq(stored, entry) { tasks.remove(&connection_id); + // Keep the map locked until the terminal event is enqueued. + // Otherwise another window could create a fresh task between + // removal and notification, then receive the old task's failure. + // Lock order remains tasks -> subscribers, as in subscribe. + notify().await; + return true; } } + false } /// Sleep for the exponential-backoff duration corresponding to @@ -2049,6 +2077,56 @@ mod tests { }) } + #[tokio::test] + async fn finished_ws_task_is_removed_before_a_manual_retry_registers() { + let proxy = Arc::new(RemoteProxyState::new()); + let finished = test_entry(HashMap::from([ + ( + "workspace".to_string(), + subscriber("remote-workspace-1", "workspace"), + ), + ( + "settings".to_string(), + subscriber("remote-settings-1", "settings"), + ), + ])); + proxy.tasks.lock().await.insert(1, finished.clone()); + + // run_ws_task awaits this removal before its terminal notification. + // Even a shared task must be absent when that signal enables Retry. + assert!( + remove_finished_ws_task(&proxy, 1, &finished, || async { + assert!(proxy.tasks.try_lock().is_err()); + }) + .await + ); + assert!(!proxy.tasks.lock().await.contains_key(&1)); + + let replacement = test_entry(HashMap::new()); + proxy.tasks.lock().await.insert(1, replacement.clone()); + assert!( + !remove_finished_ws_task(&proxy, 1, &finished, || async { + panic!("an old task must not notify after its replacement registers"); + }) + .await + ); + let tasks = proxy.tasks.lock().await; + assert!(Arc::ptr_eq(tasks.get(&1).unwrap(), &replacement)); + } + + #[tokio::test] + async fn finished_ws_task_cleanup_preserves_other_remote_connections() { + let proxy = Arc::new(RemoteProxyState::new()); + let finished = test_entry(HashMap::new()); + let other = test_entry(HashMap::new()); + proxy.tasks.lock().await.insert(1, finished.clone()); + proxy.tasks.lock().await.insert(2, other.clone()); + + assert!(remove_finished_ws_task(&proxy, 1, &finished, || async {}).await); + let tasks = proxy.tasks.lock().await; + assert!(Arc::ptr_eq(tasks.get(&2).unwrap(), &other)); + } + // ─── Host pinning for credential-carrying requests ────────────────── // // Both of these guard the same thing from two directions: the custom diff --git a/src-tauri/src/commands/remote_workspace.rs b/src-tauri/src/commands/remote_workspace.rs index abdf21a8eb..ea1f63ccca 100644 --- a/src-tauri/src/commands/remote_workspace.rs +++ b/src-tauri/src/commands/remote_workspace.rs @@ -38,10 +38,8 @@ pub struct RemoteWorkspaceConnectionInput { pub headers: Vec, } -/// The same check a remote workspace window has to pass before it opens, for -/// every caller that opens one: the "Open remote workspace" menus, and the -/// launch reopening the windows that were open at the last quit -/// (`workspace_windows`). +/// Validate connections opened manually. Restoring a saved workspace skips +/// this probe so an offline launch preserves its window and reconnects later. #[cfg(feature = "tauri-runtime")] pub(crate) async fn validate_remote_health( base_url: &str, @@ -225,8 +223,8 @@ pub async fn open_remote_workspace( build_remote_workspace_window(&app, &connection) } -/// Build the workspace window for `connection`, which the caller has already -/// run [`validate_remote_health`] against and found without a window. +/// Build the workspace window for a saved connection, without probing its +/// host. Manual opens validate health first; session restores may be offline. #[cfg(feature = "tauri-runtime")] pub(crate) fn build_remote_workspace_window( app: &AppHandle, diff --git a/src-tauri/src/commands/workspace_windows.rs b/src-tauri/src/commands/workspace_windows.rs index 06c42c9a85..a04ecfadf2 100644 --- a/src-tauri/src/commands/workspace_windows.rs +++ b/src-tauri/src/commands/workspace_windows.rs @@ -14,9 +14,9 @@ //! * `main` is always built — the tray, the dock, deep links and the close //! prompt all hang off it — but it starts hidden when it had been closed //! (hidden to the tray) at quit and something else is coming back instead. -//! * The remote windows reopen in the background, behind the same health -//! check a manual open runs, and in the remembered order, so the window -//! that was in front at quit is in front again. +//! * Saved remote windows reopen even while their hosts are offline. Their +//! transports reconnect in the background; an outage must not erase the +//! remembered session. The remembered front window is focused last. //! * A remote workspace that cannot be reopened brings `main` up with a toast //! saying why ([`take_workspace_restore_failures`]), rather than leaving a //! window silently missing — or no window at all. @@ -255,7 +255,7 @@ impl SessionState { } } - /// A remembered window this launch could not reopen. Only its pending + /// A remembered window this launch could not build. Only its pending /// entry goes: if the user opened it by hand meanwhile, that window is open /// and stays in the list. fn forget_pending(&mut self, window: WorkspaceWindow) -> Change { @@ -268,6 +268,22 @@ impl SessionState { } } + fn should_reopen(&self, window: WorkspaceWindow) -> bool { + !self.frozen && self.pending.contains(&window) + } + + fn should_focus_after_restore(&self, window: WorkspaceWindow) -> bool { + !self.frozen && self.open.contains(&window) + } + + fn mark_restored_focus(&mut self, window: WorkspaceWindow) -> Change { + if self.should_focus_after_restore(window) { + self.mark_open(window) + } else { + Change::None + } + } + /// The list to store, least recently focused first. fn snapshot(&self) -> Vec { self.pending.iter().chain(&self.open).copied().collect() @@ -317,10 +333,6 @@ impl WorkspaceWindowSession { !std::mem::replace(&mut state.frozen, true) } - fn is_frozen(&self) -> bool { - self.lock().frozen - } - /// Store the list as it is now, unless that is what is stored already. async fn write_latest(&self, conn: &DatabaseConnection) { let _serial = self.write.lock().await; @@ -517,42 +529,19 @@ async fn reopen_remote_windows( connections: Vec, front: WorkspaceWindow, ) { - // Every health check starts now, but the windows open in the remembered - // order, so the one that was in front at quit is also the last to open. A - // slow host holds back the windows after it, and nothing else. - let checks: Vec<_> = connections - .into_iter() - .map(|connection| { - let base_url = connection.base_url.clone(); - let token = connection.token.clone(); - let headers = connection.headers.clone(); - let check = tauri::async_runtime::spawn(async move { - crate::commands::remote_workspace::validate_remote_health( - &base_url, &token, &headers, - ) - .await - }); - (connection, check) - }) - .collect(); - let mut failures = Vec::new(); - for (connection, check) in checks { - let checked = match check.await { - Ok(result) => result, - Err(err) => Err( - AppCommandError::network("Remote Workspace health check failed") - .with_detail(err.to_string()), - ), - }; - let opened = checked.and_then(|()| reopen_remote_window(app, &connection)); + for connection in connections { + // These are saved, previously validated connections. Build the local + // shell without a network probe: the transport displays connectivity + // state and retries, including after an offline launch. A failed probe + // used to discard this window from the next saved session. + let opened = reopen_remote_window(app, &connection); if let Err(error) = opened { let window = WorkspaceWindow::Remote { connection_id: connection.id, }; - // The checks all started together, so this result can be older than - // a manual open that has since succeeded. That window stands, and - // there is nothing to report. + // A concurrent manual open may have created the same window. + // That window stands, and there is nothing to report. if app.get_webview_window(&window.label()).is_some() { continue; } @@ -581,12 +570,13 @@ fn reopen_remote_window( connection_id: connection.id, }; if let Some(session) = app.try_state::() { - // Quitting: a window built now would only be torn down again. - if session.is_frozen() { + // Quitting, or manually opened and then closed while this restore + // was starting: do not revive a window the user just dismissed. + if !session.lock().should_reopen(window) { return Ok(()); } } - // Opened by hand while its health check was still running. Building it + // Opened by hand while this restore was starting. Building it // recorded it, and the user may have moved on to another window since, so // it is not brought forward in the list again here. if app.get_webview_window(&window.label()).is_some() { @@ -606,13 +596,18 @@ fn finish_restore(app: &AppHandle, front: WorkspaceWindow, failures: Vec