Skip to content

Commit de609c3

Browse files
committed
fix(gateway): ride out self-update port race + clearer update restart UX
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 <maa.ashik00@gmail.com>
1 parent 09b561c commit de609c3

6 files changed

Lines changed: 139 additions & 13 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,20 @@ pub fn run() {
375375
None => (mcpmux_core::DEFAULT_GATEWAY_PORT, "default"),
376376
};
377377

378-
if !mcpmux_core::service::is_port_available(preferred_port) {
378+
// A busy port is usually transient on launch: when the app
379+
// updates in place, the OS relaunches the new build before the
380+
// prior process has finished releasing its gateway listener.
381+
// Probe with backoff before treating the port as a real
382+
// conflict — a genuine conflict (another app owns the port)
383+
// stays busy for the whole window and still surfaces the prompt.
384+
if !mcpmux_core::service::wait_for_port_available(
385+
preferred_port,
386+
mcpmux_core::service::AUTOSTART_PORT_WAIT,
387+
)
388+
.await
389+
{
379390
warn!(
380-
"[Gateway] Auto-start preferred port {} ({}) unavailable — deferring to user",
391+
"[Gateway] Auto-start preferred port {} ({}) still unavailable after waiting — deferring to user",
381392
preferred_port, source
382393
);
383394
{

apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ import { useGatewayControl } from './useGatewayControl';
99
* Polling schedule (ms after mount). Covers the realistic window for the
1010
* Rust auto-start task to complete its port probe. Short early polls catch
1111
* the common case; longer tails catch cold-start machines / slow disks.
12-
* Total max wait: ~4.75s before giving up silently.
12+
*
13+
* The tail must outlast the backend's port-conflict wait: on a self-update
14+
* restart the auto-start task retries a busy port for up to ~6s (riding out
15+
* the prior process's listener teardown) before it either starts the gateway
16+
* or records a conflict. If we stopped polling first, a conflict raised at the
17+
* end of that window would never reach the prompt. Total max wait: ~10s.
1318
*/
14-
const POLL_SCHEDULE_MS = [0, 150, 300, 600, 1200, 2400];
19+
const POLL_SCHEDULE_MS = [0, 200, 400, 800, 1500, 2400, 2400, 2400];
1520

1621
/**
1722
* Mounts at the app root and resolves any auto-start port conflict the

apps/desktop/src/features/settings/UpdateChecker.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ interface DownloadEvent {
3030
export function UpdateChecker() {
3131
const [checking, setChecking] = useState(false);
3232
const [downloading, setDownloading] = useState(false);
33+
// True once the download finishes and the installer takes over. On Windows
34+
// the app is killed during this phase, so we surface a clear "restarting"
35+
// notice first — otherwise the window vanishing reads as a crash.
36+
const [installing, setInstalling] = useState(false);
3337
const [updateInfo, setUpdateInfo] = useState<Update | null>(null);
3438
const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, total: 0 });
3539
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -142,6 +146,7 @@ export function UpdateChecker() {
142146
if (!updateInfo) return;
143147

144148
setDownloading(true);
149+
setInstalling(false);
145150
setDownloadProgress({ downloaded: 0, total: 0 });
146151
setMessage(null);
147152

@@ -165,6 +170,10 @@ export function UpdateChecker() {
165170
break;
166171
case 'Finished':
167172
console.log('[Updater] Download finished, installing...');
173+
// Hand-off to the installer. On Windows the app is killed here,
174+
// so flip to the restart notice now (before the window closes)
175+
// so the disappearance is expected, not a surprise.
176+
setInstalling(true);
168177
break;
169178
}
170179
});
@@ -179,6 +188,7 @@ export function UpdateChecker() {
179188
text: `Failed to install update: ${error}`,
180189
});
181190
setDownloading(false);
191+
setInstalling(false);
182192
}
183193
};
184194

@@ -387,7 +397,11 @@ export function UpdateChecker() {
387397
{downloading ? (
388398
<>
389399
<Loader2 className="h-4 w-4 animate-spin mr-2" />
390-
{downloadProgress.total > 0 ? 'Downloading...' : 'Installing...'}
400+
{installing
401+
? 'Restarting…'
402+
: downloadProgress.total > 0
403+
? 'Downloading...'
404+
: 'Installing...'}
391405
</>
392406
) : (
393407
<>
@@ -410,11 +424,24 @@ export function UpdateChecker() {
410424
)}
411425
</div>
412426

413-
{downloading && (
414-
<p className="text-xs text-[rgb(var(--muted))]">
415-
<strong>Note:</strong> On Windows, the app will close automatically to install the update.
416-
</p>
417-
)}
427+
{downloading &&
428+
(installing ? (
429+
<div
430+
className="flex items-start gap-2 rounded-lg bg-blue-500/10 p-3 text-sm text-blue-600 dark:text-blue-400"
431+
data-testid="update-restarting"
432+
>
433+
<RotateCcw className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
434+
<span>
435+
Installing v{updateInfo.version} — McpMux will close and reopen automatically.
436+
This is expected; the app isn't crashing.
437+
</span>
438+
</div>
439+
) : (
440+
<p className="text-xs text-[rgb(var(--muted))]">
441+
<strong>Note:</strong> When the download finishes, McpMux closes briefly to
442+
install the update, then reopens on its own.
443+
</p>
444+
))}
418445
</div>
419446
)}
420447

crates/mcpmux-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async-trait.workspace = true
1717
tracing.workspace = true
1818
glob.workspace = true
1919
dirs.workspace = true
20-
tokio = { workspace = true, features = ["fs", "io-util", "sync"] }
20+
tokio = { workspace = true, features = ["fs", "io-util", "sync", "time"] }
2121
flate2 = "1.0"
2222
reqwest = { workspace = true, features = ["json"] }
2323
regex = "1.11"

crates/mcpmux-core/src/service/gateway_port_service.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
use std::net::TcpListener;
77
use std::sync::Arc;
8+
use std::time::Duration;
89
use tracing::{info, warn};
910

1011
use super::app_settings_service::keys;
@@ -77,6 +78,58 @@ pub fn is_port_available(port: u16) -> bool {
7778
TcpListener::bind(("127.0.0.1", port)).is_ok()
7879
}
7980

81+
/// How long auto-start waits for a busy preferred port to free up before
82+
/// treating it as a real conflict and deferring to the user.
83+
///
84+
/// Sized to ride out the brief window after an in-place self-update relaunches
85+
/// the app while the *prior* process is still tearing down its gateway
86+
/// listener (its graceful shutdown alone can take ~2.5s, and the OS may hold
87+
/// the socket a moment longer). A genuine conflict — another app permanently
88+
/// owning the port — stays busy for the whole window and still surfaces the
89+
/// prompt afterward.
90+
pub const AUTOSTART_PORT_WAIT: Duration = Duration::from_secs(6);
91+
92+
/// How often [`wait_for_port_available`] re-probes while waiting.
93+
const PORT_WAIT_PROBE_INTERVAL: Duration = Duration::from_millis(400);
94+
95+
/// Wait up to `timeout` for `port` to become bind-available, re-probing every
96+
/// [`PORT_WAIT_PROBE_INTERVAL`]. Returns `true` as soon as the port is free, or
97+
/// `false` if it never frees within the window.
98+
///
99+
/// The common case (port already free) returns immediately with a single
100+
/// synchronous probe and never sleeps. Only a busy port pays the wait — this
101+
/// exists for the self-update restart race, where a single probe would see the
102+
/// port momentarily busy and spuriously raise a conflict even though it frees a
103+
/// moment later.
104+
pub async fn wait_for_port_available(port: u16, timeout: Duration) -> bool {
105+
if is_port_available(port) {
106+
return true;
107+
}
108+
109+
info!(
110+
"[PortService] Port {} busy — waiting up to {:?} for it to free up \
111+
(likely a self-update restart racing the prior process's shutdown)",
112+
port, timeout
113+
);
114+
115+
// Track elapsed time by counting fixed-interval sleeps rather than reading
116+
// a clock — keeps the loop deterministic and trivial to test.
117+
let mut waited = Duration::ZERO;
118+
while waited < timeout {
119+
tokio::time::sleep(PORT_WAIT_PROBE_INTERVAL).await;
120+
waited = waited.saturating_add(PORT_WAIT_PROBE_INTERVAL);
121+
if is_port_available(port) {
122+
info!(
123+
"[PortService] Port {} became available after ~{:?}",
124+
port, waited
125+
);
126+
return true;
127+
}
128+
}
129+
130+
false
131+
}
132+
80133
/// Allocate a dynamic port by letting the OS assign one.
81134
pub fn allocate_dynamic_port() -> Result<u16, PortAllocationError> {
82135
let listener = TcpListener::bind(("127.0.0.1", 0))
@@ -312,6 +365,36 @@ mod tests {
312365
assert!(is_port_available(port));
313366
}
314367

368+
#[tokio::test]
369+
async fn test_wait_for_port_available_returns_immediately_when_free() {
370+
let port = allocate_dynamic_port().unwrap();
371+
// Already free — should resolve true without paying the timeout.
372+
assert!(wait_for_port_available(port, Duration::from_secs(5)).await);
373+
}
374+
375+
#[tokio::test]
376+
async fn test_wait_for_port_available_gives_up_when_held() {
377+
// Hold the port for the whole call so it never frees.
378+
let held = TcpListener::bind(("127.0.0.1", 0)).unwrap();
379+
let port = held.local_addr().unwrap().port();
380+
assert!(!is_port_available(port));
381+
// Short timeout keeps the test fast; must report the port as unavailable.
382+
assert!(!wait_for_port_available(port, Duration::from_millis(300)).await);
383+
}
384+
385+
#[tokio::test]
386+
async fn test_wait_for_port_available_succeeds_once_freed() {
387+
// Hold the port, then release it shortly after — mirrors the prior
388+
// process releasing its listener during a self-update restart.
389+
let held = TcpListener::bind(("127.0.0.1", 0)).unwrap();
390+
let port = held.local_addr().unwrap().port();
391+
tokio::spawn(async move {
392+
tokio::time::sleep(Duration::from_millis(100)).await;
393+
drop(held);
394+
});
395+
assert!(wait_for_port_available(port, Duration::from_secs(3)).await);
396+
}
397+
315398
#[tokio::test]
316399
async fn test_service_persistence() {
317400
let settings = Arc::new(InMemorySettings::new());

crates/mcpmux-core/src/service/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ pub use cimd_fetcher::*;
1717
pub use client_install::{cursor_deep_link, vscode_deep_link};
1818
pub use config_export::*;
1919
pub use gateway_port_service::{
20-
allocate_dynamic_port, is_port_available, GatewayPortService, PortAllocationError,
21-
PortResolution, DEFAULT_GATEWAY_PORT,
20+
allocate_dynamic_port, is_port_available, wait_for_port_available, GatewayPortService,
21+
PortAllocationError, PortResolution, AUTOSTART_PORT_WAIT, DEFAULT_GATEWAY_PORT,
2222
};
2323
pub use registry_api_client::*;
2424
pub use server_discovery::*;

0 commit comments

Comments
 (0)