mirror of https://github.com/ospab/ostp.git
fix(client): bound the UoT connect; green aura + self-updating ping on mobile
UoT took 20-30s (sometimes 1-2 min) to come up on mobile. The TCP connect had no timeout, so it inherited the kernel's SYN retry budget. Callers resolve every address for the server and deliberately try IPv6 first (perform_handshake_with_id sorts is_ipv6 to the front); a mobile network that advertises IPv6 without a working route blackholes the SYN instead of rejecting it, so the client sat through that entire budget before reaching the IPv4 address that would have connected immediately. UDP never showed this because connect() on a UDP socket just sets the default peer and returns. Capped at 4s per address, so a blackholed candidate costs seconds and the next one is tried. Left the IPv6-first ordering alone: it is what makes IPv6-only and NAT64 networks work, and with the cap its worst case is now bounded. (A further win would be remembering which family last succeeded and trying that first, removing even those 4s — not done here.) Also, per the earlier UI requests: - The connected state drew its aura, ring, icon and status dot from the theme's `secondary`, which is #AAAAAA and reads as plain white, giving no confirmation the tunnel was actually up. Now green, reusing the green already used for a healthy ping so "green = good" stays consistent. Applied at the call sites rather than to the theme, since `secondary` also paints routing toggles, the download metric and settings switches. - Ping now updates itself from the metrics stream that was already arriving, instead of needing the "Test Ping" button, and is rendered as a compact icon + value.
This commit is contained in:
parent
77e42b77f7
commit
7473278cc2
|
|
@ -16,6 +16,13 @@ use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
||||||
use crate::config::ClientConfig;
|
use crate::config::ClientConfig;
|
||||||
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||||
|
|
||||||
|
/// Per-address ceiling on the UoT/TCP connect attempt. Long enough that a
|
||||||
|
/// genuinely slow mobile path still completes its handshake, short enough that
|
||||||
|
/// a blackholed address (typically IPv6 advertised without a working route)
|
||||||
|
/// costs seconds instead of the kernel's full SYN-retry budget before the next
|
||||||
|
/// candidate address is tried.
|
||||||
|
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
pub fn set_socket_protector<F>(f: F)
|
pub fn set_socket_protector<F>(f: F)
|
||||||
|
|
@ -1084,7 +1091,27 @@ impl Bridge {
|
||||||
) -> Result<crate::transport::Transport> {
|
) -> Result<crate::transport::Transport> {
|
||||||
let mode = self.transport_mode.to_lowercase();
|
let mode = self.transport_mode.to_lowercase();
|
||||||
if mode == "uot" || mode == "tcp" {
|
if mode == "uot" || mode == "tcp" {
|
||||||
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
// Bound the TCP connect. Without this it inherits the kernel's SYN
|
||||||
|
// retry budget, which is tens of seconds (and can reach ~2 minutes).
|
||||||
|
// That is exactly what made UoT appear to hang on mobile: callers
|
||||||
|
// resolve every address for the server and try IPv6 first (see the
|
||||||
|
// sort in perform_handshake_with_id), and a mobile network that
|
||||||
|
// advertises IPv6 without a working route blackholes the SYN rather
|
||||||
|
// than rejecting it — so the client sat through the full retry
|
||||||
|
// budget before it ever reached the IPv4 address that would have
|
||||||
|
// connected immediately. UDP never showed this because connect() on
|
||||||
|
// a UDP socket only sets the default peer and returns at once.
|
||||||
|
let stream = tokio::time::timeout(
|
||||||
|
UOT_CONNECT_TIMEOUT,
|
||||||
|
tokio::net::TcpStream::connect((target_ip, port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"TCP connect to {target_ip}:{port} timed out after {:?}",
|
||||||
|
UOT_CONNECT_TIMEOUT
|
||||||
|
)
|
||||||
|
})??;
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
let (mut read_half, mut write_half) = stream.into_split();
|
let (mut read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import '../models/connection_state_enum.dart';
|
||||||
import '../models/ostp_profile.dart';
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Success green for the "connected" state — the button aura/border/icon and
|
||||||
|
/// the top-bar status dot. The theme's `secondary` (#AAAAAA) reads as plain
|
||||||
|
/// white here, which gave no visual confirmation that the tunnel actually came
|
||||||
|
/// up. Reuses the same green already used for a healthy ping value, so
|
||||||
|
/// "green = good" stays consistent across the UI.
|
||||||
|
const Color kConnectedGreen = Color(0xFF22D3A5);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
const HomeScreen({super.key, required this.prefs});
|
const HomeScreen({super.key, required this.prefs});
|
||||||
|
|
@ -45,8 +52,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late AnimationController _spinController;
|
late AnimationController _spinController;
|
||||||
|
|
||||||
bool _isCheckingPing = false;
|
String _pingText = '-- ms';
|
||||||
String _pingText = 'Target Ping: -- ms';
|
|
||||||
Color _pingColor = Colors.white54;
|
Color _pingColor = Colors.white54;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -420,8 +426,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_prevBytesSent = bytesSent;
|
_prevBytesSent = bytesSent;
|
||||||
_downSpeed = '${_formatBytes(dRecv)}/s';
|
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||||
_upSpeed = '${_formatBytes(dSent)}/s';
|
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||||
if (rttMs > 0 && !_isCheckingPing) {
|
if (rttMs > 0) {
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
_pingText = '$rttMs ms';
|
||||||
if (rttMs < 100) {
|
if (rttMs < 100) {
|
||||||
_pingColor = const Color(0xFF22D3A5);
|
_pingColor = const Color(0xFF22D3A5);
|
||||||
} else if (rttMs < 250) {
|
} else if (rttMs < 250) {
|
||||||
|
|
@ -447,47 +453,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkConnectionLatency() async {
|
|
||||||
if (_state != ConnectionStateEnum.connected) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = true;
|
|
||||||
_pingText = 'Updating...';
|
|
||||||
_pingColor = Colors.white70;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
|
||||||
if (metricsJson != null && metricsJson.isNotEmpty) {
|
|
||||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
if (rttMs > 0) {
|
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
|
||||||
_pingColor = rttMs < 100
|
|
||||||
? const Color(0xFF22D3A5)
|
|
||||||
: rttMs < 250
|
|
||||||
? Colors.amberAccent
|
|
||||||
: Colors.redAccent;
|
|
||||||
} else {
|
|
||||||
_pingText = 'Server Ping: -- ms';
|
|
||||||
_pingColor = Colors.white54;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Failed to check latency: $e");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setDisconnected() {
|
void _setDisconnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -498,9 +463,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_upSpeed = '0 B/s';
|
_upSpeed = '0 B/s';
|
||||||
_prevBytesRecv = 0;
|
_prevBytesRecv = 0;
|
||||||
_prevBytesSent = 0;
|
_prevBytesSent = 0;
|
||||||
_pingText = 'Target Ping: -- ms';
|
_pingText = '-- ms';
|
||||||
_pingColor = Colors.white54;
|
_pingColor = Colors.white54;
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 0.0;
|
_pulseController.value = 0.0;
|
||||||
|
|
@ -578,12 +542,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary
|
? kConnectedGreen
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary.withOpacity(0.5)
|
? kConnectedGreen.withOpacity(0.5)
|
||||||
: theme.colorScheme.primary.withOpacity(0.5),
|
: theme.colorScheme.primary.withOpacity(0.5),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
)
|
)
|
||||||
|
|
@ -637,7 +601,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Widget _buildStage(ThemeData theme) {
|
Widget _buildStage(ThemeData theme) {
|
||||||
Color getAccentColor() {
|
Color getAccentColor() {
|
||||||
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
|
if (_state == ConnectionStateEnum.connected) return kConnectedGreen;
|
||||||
return theme.colorScheme.primary;
|
return theme.colorScheme.primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,37 +739,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.03),
|
color: Colors.white.withOpacity(0.03),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
|
||||||
child: Column(
|
const SizedBox(width: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'CONNECTION TEST',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white38,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
Text(
|
||||||
_pingText,
|
_pingText,
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: _pingColor,
|
color: _pingColor,
|
||||||
),
|
),
|
||||||
|
|
@ -813,32 +763,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
_isCheckingPing
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
|
||||||
)
|
|
||||||
: TextButton.icon(
|
|
||||||
onPressed: _checkConnectionLatency,
|
|
||||||
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
|
||||||
label: Text(
|
|
||||||
'Test Ping',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue