fix(client): coalesce bursty NetworkChanged events on mobile handoff

Root cause of "constantly disconnects on mobile, have to reconnect
manually": Android's ConnectivityManager fires onLost(old) + onAvailable(new)
within milliseconds of each other during a real Wi-Fi<->cellular handoff,
and each one queues its own BridgeCommand::NetworkChanged. Each reconnect is
a full sequential handshake (up to ~1.2s x 4 attempts x mux_sessions) run
synchronously inside the bridge's select-loop iteration - so without
coalescing, the FIRST queued NetworkChanged often starts reconnecting before
the OS has actually finished switching networks, races the dying interface,
and only fails after burning its full attempt budget. Only THEN does the
SECOND (correct) NetworkChanged get to run its own reconnect. A sub-second
handoff was turning into several extra seconds of outage on every
occurrence, and multiple back-to-back handoffs (common walking in/out of
Wi-Fi range) compounded this every time.

Fix: on NetworkChanged, drain any additional same-kind events already
queued before starting the reconnect, so a burst collapses into one attempt
using the freshest signal. A different command found while draining isn't
dropped - it's dispatched immediately (recursing into handle_bridge_cmd)
so nothing queued behind the burst gets lost or reordered incorrectly.
This commit is contained in:
ospab 2026-07-21 17:57:39 +03:00
parent 9a891310f9
commit cddd623ad0
1 changed files with 28 additions and 1 deletions

View File

@ -231,7 +231,7 @@ impl Bridge {
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await; self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
} }
cmd = bridge_rx.recv() => { cmd = bridge_rx.recv() => {
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await { if !self.handle_bridge_cmd(cmd, &mut bridge_rx, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
break; break;
} }
} }
@ -374,6 +374,7 @@ impl Bridge {
async fn handle_bridge_cmd( async fn handle_bridge_cmd(
&mut self, &mut self,
cmd: Option<BridgeCommand>, cmd: Option<BridgeCommand>,
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
sessions_opt: &mut Option<Vec<SessionState>>, sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>, udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>, proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
@ -465,6 +466,32 @@ impl Bridge {
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok(); tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
} }
Some(BridgeCommand::NetworkChanged) => { Some(BridgeCommand::NetworkChanged) => {
// A real network handoff (Wi-Fi <-> cellular) commonly fires
// onLost + onAvailable within milliseconds of each other on
// Android, queuing several NetworkChanged commands back to
// back. Each reconnect below is a full sequential handshake
// (up to ~1.2s x 4 attempts x mux_sessions) run synchronously
// in this select-loop iteration, so without coalescing, the
// first attempt often races the OS's own network switch and
// fails on the now-dead interface, then the SECOND queued
// NetworkChanged only starts its own full reconnect after
// that first one finishes - multiplying a sub-second handoff
// into many seconds of extra outage. Drain same-kind repeats
// so a burst collapses into one reconnect on the freshest
// signal; a different command found while draining is
// handled immediately rather than dropped.
while let Ok(next) = bridge_rx.try_recv() {
if !matches!(next, BridgeCommand::NetworkChanged) {
let more = Box::pin(self.handle_bridge_cmd(
Some(next), bridge_rx, sessions_opt, udp_rx_opt, proxy_guard, stream_map, tx, proxy_tx,
)).await;
if !more {
return false;
}
break;
}
}
if self.running { if self.running {
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await; let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
self.metrics.connection_state.store(1, Ordering::Relaxed); self.metrics.connection_state.store(1, Ordering::Relaxed);