From cddd623ad0ef267b3712304c64225170707f095d Mon Sep 17 00:00:00 2001 From: ospab Date: Tue, 21 Jul 2026 17:57:39 +0300 Subject: [PATCH] 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. --- ostp-client/src/bridge.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index dce1de7..58d164d 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -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; } 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; } } @@ -374,6 +374,7 @@ impl Bridge { async fn handle_bridge_cmd( &mut self, cmd: Option, + bridge_rx: &mut mpsc::Receiver, sessions_opt: &mut Option>, udp_rx_opt: &mut Option>, proxy_guard: &mut Option, @@ -465,6 +466,32 @@ impl Bridge { tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok(); } 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 { let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await; self.metrics.connection_state.store(1, Ordering::Relaxed);