From de609c37ff4ac895e40b93a179e3f2b402d6d174 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 16 Jun 2026 18:07:16 +0800 Subject: [PATCH] fix(gateway): ride out self-update port race + clearer update restart UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an in-place self-update relaunches the app, the freshly launched build raced the prior process's gateway-listener teardown. Auto-start did a single port probe, saw 45818 momentarily busy, and immediately deferred to the user with a "port not available" conflict prompt — even though the port frees within a second or two (the user could manually start on the same port right after). The abrupt close + spurious error made a normal update read as a crash. Backend: auto-start now waits up to ~6s (probing every 400ms) for the preferred port to free before treating it as a real conflict. The common case (port free) still returns on the first synchronous probe with no sleep. A genuine conflict (another app owning the port) stays busy the whole window and still surfaces the prompt. Added `wait_for_port_available` to the port service with unit tests for the free / held / freed-mid-wait cases. Frontend: extended AutoStartConflictResolver's poll schedule (~10s) so it is still polling when the backend resolves at the end of its wait window — otherwise a late conflict would never reach the prompt. UX: the manual "Download and Install" flow now flips to an explicit "Installing — McpMux will close and reopen automatically, this is expected" notice the moment the download finishes, so the window vanishing on Windows (passive NSIS installer kills the app) no longer looks like a crash. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/lib.rs | 15 +++- .../gateway/AutoStartConflictResolver.tsx | 9 +- .../src/features/settings/UpdateChecker.tsx | 39 +++++++-- crates/mcpmux-core/Cargo.toml | 2 +- .../src/service/gateway_port_service.rs | 83 +++++++++++++++++++ crates/mcpmux-core/src/service/mod.rs | 4 +- 6 files changed, 139 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0fdc9e39..7900760b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -375,9 +375,20 @@ pub fn run() { None => (mcpmux_core::DEFAULT_GATEWAY_PORT, "default"), }; - if !mcpmux_core::service::is_port_available(preferred_port) { + // A busy port is usually transient on launch: when the app + // updates in place, the OS relaunches the new build before the + // prior process has finished releasing its gateway listener. + // Probe with backoff before treating the port as a real + // conflict — a genuine conflict (another app owns the port) + // stays busy for the whole window and still surfaces the prompt. + if !mcpmux_core::service::wait_for_port_available( + preferred_port, + mcpmux_core::service::AUTOSTART_PORT_WAIT, + ) + .await + { warn!( - "[Gateway] Auto-start preferred port {} ({}) unavailable — deferring to user", + "[Gateway] Auto-start preferred port {} ({}) still unavailable after waiting — deferring to user", preferred_port, source ); { diff --git a/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx index 1486be5d..9795e476 100644 --- a/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx +++ b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx @@ -9,9 +9,14 @@ import { useGatewayControl } from './useGatewayControl'; * Polling schedule (ms after mount). Covers the realistic window for the * Rust auto-start task to complete its port probe. Short early polls catch * the common case; longer tails catch cold-start machines / slow disks. - * Total max wait: ~4.75s before giving up silently. + * + * The tail must outlast the backend's port-conflict wait: on a self-update + * restart the auto-start task retries a busy port for up to ~6s (riding out + * the prior process's listener teardown) before it either starts the gateway + * or records a conflict. If we stopped polling first, a conflict raised at the + * end of that window would never reach the prompt. Total max wait: ~10s. */ -const POLL_SCHEDULE_MS = [0, 150, 300, 600, 1200, 2400]; +const POLL_SCHEDULE_MS = [0, 200, 400, 800, 1500, 2400, 2400, 2400]; /** * Mounts at the app root and resolves any auto-start port conflict the diff --git a/apps/desktop/src/features/settings/UpdateChecker.tsx b/apps/desktop/src/features/settings/UpdateChecker.tsx index 3180b314..d78b73bf 100644 --- a/apps/desktop/src/features/settings/UpdateChecker.tsx +++ b/apps/desktop/src/features/settings/UpdateChecker.tsx @@ -30,6 +30,10 @@ interface DownloadEvent { export function UpdateChecker() { const [checking, setChecking] = useState(false); const [downloading, setDownloading] = useState(false); + // True once the download finishes and the installer takes over. On Windows + // the app is killed during this phase, so we surface a clear "restarting" + // notice first — otherwise the window vanishing reads as a crash. + const [installing, setInstalling] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, total: 0 }); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -142,6 +146,7 @@ export function UpdateChecker() { if (!updateInfo) return; setDownloading(true); + setInstalling(false); setDownloadProgress({ downloaded: 0, total: 0 }); setMessage(null); @@ -165,6 +170,10 @@ export function UpdateChecker() { break; case 'Finished': console.log('[Updater] Download finished, installing...'); + // Hand-off to the installer. On Windows the app is killed here, + // so flip to the restart notice now (before the window closes) + // so the disappearance is expected, not a surprise. + setInstalling(true); break; } }); @@ -179,6 +188,7 @@ export function UpdateChecker() { text: `Failed to install update: ${error}`, }); setDownloading(false); + setInstalling(false); } }; @@ -387,7 +397,11 @@ export function UpdateChecker() { {downloading ? ( <> - {downloadProgress.total > 0 ? 'Downloading...' : 'Installing...'} + {installing + ? 'Restarting…' + : downloadProgress.total > 0 + ? 'Downloading...' + : 'Installing...'} ) : ( <> @@ -410,11 +424,24 @@ export function UpdateChecker() { )} - {downloading && ( -

- Note: On Windows, the app will close automatically to install the update. -

- )} + {downloading && + (installing ? ( +
+ + + Installing v{updateInfo.version} — McpMux will close and reopen automatically. + This is expected; the app isn't crashing. + +
+ ) : ( +

+ Note: When the download finishes, McpMux closes briefly to + install the update, then reopens on its own. +

+ ))} )} diff --git a/crates/mcpmux-core/Cargo.toml b/crates/mcpmux-core/Cargo.toml index 10212c6f..49cfc242 100644 --- a/crates/mcpmux-core/Cargo.toml +++ b/crates/mcpmux-core/Cargo.toml @@ -17,7 +17,7 @@ async-trait.workspace = true tracing.workspace = true glob.workspace = true dirs.workspace = true -tokio = { workspace = true, features = ["fs", "io-util", "sync"] } +tokio = { workspace = true, features = ["fs", "io-util", "sync", "time"] } flate2 = "1.0" reqwest = { workspace = true, features = ["json"] } regex = "1.11" diff --git a/crates/mcpmux-core/src/service/gateway_port_service.rs b/crates/mcpmux-core/src/service/gateway_port_service.rs index 703f0183..bfefdb57 100644 --- a/crates/mcpmux-core/src/service/gateway_port_service.rs +++ b/crates/mcpmux-core/src/service/gateway_port_service.rs @@ -5,6 +5,7 @@ use std::net::TcpListener; use std::sync::Arc; +use std::time::Duration; use tracing::{info, warn}; use super::app_settings_service::keys; @@ -77,6 +78,58 @@ pub fn is_port_available(port: u16) -> bool { TcpListener::bind(("127.0.0.1", port)).is_ok() } +/// How long auto-start waits for a busy preferred port to free up before +/// treating it as a real conflict and deferring to the user. +/// +/// Sized to ride out the brief window after an in-place self-update relaunches +/// the app while the *prior* process is still tearing down its gateway +/// listener (its graceful shutdown alone can take ~2.5s, and the OS may hold +/// the socket a moment longer). A genuine conflict — another app permanently +/// owning the port — stays busy for the whole window and still surfaces the +/// prompt afterward. +pub const AUTOSTART_PORT_WAIT: Duration = Duration::from_secs(6); + +/// How often [`wait_for_port_available`] re-probes while waiting. +const PORT_WAIT_PROBE_INTERVAL: Duration = Duration::from_millis(400); + +/// Wait up to `timeout` for `port` to become bind-available, re-probing every +/// [`PORT_WAIT_PROBE_INTERVAL`]. Returns `true` as soon as the port is free, or +/// `false` if it never frees within the window. +/// +/// The common case (port already free) returns immediately with a single +/// synchronous probe and never sleeps. Only a busy port pays the wait — this +/// exists for the self-update restart race, where a single probe would see the +/// port momentarily busy and spuriously raise a conflict even though it frees a +/// moment later. +pub async fn wait_for_port_available(port: u16, timeout: Duration) -> bool { + if is_port_available(port) { + return true; + } + + info!( + "[PortService] Port {} busy — waiting up to {:?} for it to free up \ + (likely a self-update restart racing the prior process's shutdown)", + port, timeout + ); + + // Track elapsed time by counting fixed-interval sleeps rather than reading + // a clock — keeps the loop deterministic and trivial to test. + let mut waited = Duration::ZERO; + while waited < timeout { + tokio::time::sleep(PORT_WAIT_PROBE_INTERVAL).await; + waited = waited.saturating_add(PORT_WAIT_PROBE_INTERVAL); + if is_port_available(port) { + info!( + "[PortService] Port {} became available after ~{:?}", + port, waited + ); + return true; + } + } + + false +} + /// Allocate a dynamic port by letting the OS assign one. pub fn allocate_dynamic_port() -> Result { let listener = TcpListener::bind(("127.0.0.1", 0)) @@ -312,6 +365,36 @@ mod tests { assert!(is_port_available(port)); } + #[tokio::test] + async fn test_wait_for_port_available_returns_immediately_when_free() { + let port = allocate_dynamic_port().unwrap(); + // Already free — should resolve true without paying the timeout. + assert!(wait_for_port_available(port, Duration::from_secs(5)).await); + } + + #[tokio::test] + async fn test_wait_for_port_available_gives_up_when_held() { + // Hold the port for the whole call so it never frees. + let held = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = held.local_addr().unwrap().port(); + assert!(!is_port_available(port)); + // Short timeout keeps the test fast; must report the port as unavailable. + assert!(!wait_for_port_available(port, Duration::from_millis(300)).await); + } + + #[tokio::test] + async fn test_wait_for_port_available_succeeds_once_freed() { + // Hold the port, then release it shortly after — mirrors the prior + // process releasing its listener during a self-update restart. + let held = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = held.local_addr().unwrap().port(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + drop(held); + }); + assert!(wait_for_port_available(port, Duration::from_secs(3)).await); + } + #[tokio::test] async fn test_service_persistence() { let settings = Arc::new(InMemorySettings::new()); diff --git a/crates/mcpmux-core/src/service/mod.rs b/crates/mcpmux-core/src/service/mod.rs index 8cddb479..8119cde0 100644 --- a/crates/mcpmux-core/src/service/mod.rs +++ b/crates/mcpmux-core/src/service/mod.rs @@ -17,8 +17,8 @@ pub use cimd_fetcher::*; pub use client_install::{cursor_deep_link, vscode_deep_link}; pub use config_export::*; pub use gateway_port_service::{ - allocate_dynamic_port, is_port_available, GatewayPortService, PortAllocationError, - PortResolution, DEFAULT_GATEWAY_PORT, + allocate_dynamic_port, is_port_available, wait_for_port_available, GatewayPortService, + PortAllocationError, PortResolution, AUTOSTART_PORT_WAIT, DEFAULT_GATEWAY_PORT, }; pub use registry_api_client::*; pub use server_discovery::*;