Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 33 additions & 6 deletions apps/desktop/src/features/settings/UpdateChecker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Update | null>(null);
const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, total: 0 });
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
Expand Down Expand Up @@ -142,6 +146,7 @@ export function UpdateChecker() {
if (!updateInfo) return;

setDownloading(true);
setInstalling(false);
setDownloadProgress({ downloaded: 0, total: 0 });
setMessage(null);

Expand All @@ -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;
}
});
Expand All @@ -179,6 +188,7 @@ export function UpdateChecker() {
text: `Failed to install update: ${error}`,
});
setDownloading(false);
setInstalling(false);
}
};

Expand Down Expand Up @@ -387,7 +397,11 @@ export function UpdateChecker() {
{downloading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{downloadProgress.total > 0 ? 'Downloading...' : 'Installing...'}
{installing
? 'Restarting…'
: downloadProgress.total > 0
? 'Downloading...'
: 'Installing...'}
</>
) : (
<>
Expand All @@ -410,11 +424,24 @@ export function UpdateChecker() {
)}
</div>

{downloading && (
<p className="text-xs text-[rgb(var(--muted))]">
<strong>Note:</strong> On Windows, the app will close automatically to install the update.
</p>
)}
{downloading &&
(installing ? (
<div
className="flex items-start gap-2 rounded-lg bg-blue-500/10 p-3 text-sm text-blue-600 dark:text-blue-400"
data-testid="update-restarting"
>
<RotateCcw className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
<span>
Installing v{updateInfo.version} — McpMux will close and reopen automatically.
This is expected; the app isn't crashing.
</span>
</div>
) : (
<p className="text-xs text-[rgb(var(--muted))]">
<strong>Note:</strong> When the download finishes, McpMux closes briefly to
install the update, then reopens on its own.
</p>
))}
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion crates/mcpmux-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
83 changes: 83 additions & 0 deletions crates/mcpmux-core/src/service/gateway_port_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<u16, PortAllocationError> {
let listener = TcpListener::bind(("127.0.0.1", 0))
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 2 additions & 2 deletions crates/mcpmux-core/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
Loading