mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
fix(port): stop the mixed port climbing on every restart
The startup fallback persisted its replacement, so a port blocked once was never asked for again. It now overrides the Runtime Config for the session only. Most of those conflicts were not real: SO_REUSEADDR cannot reuse a TIME_WAIT socket owned by another user, and a service-managed core runs as root while the app does not. A refused bind is now confirmed by asking whether anything answers. Also restores the borrowed port when saving proxy ports fails.
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
#[cfg(target_os = "macos")]
|
||||
use clash_verge_logging::{Type as LogType, logging};
|
||||
#[cfg(target_os = "macos")]
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
|
||||
@@ -97,7 +96,7 @@ impl ListenerBindScope {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
|
||||
pub(crate) fn probe_mixed_port(&self, port: u16) -> ListenerProbeOutcome {
|
||||
let claims = self
|
||||
.addresses
|
||||
.iter()
|
||||
@@ -108,7 +107,13 @@ impl ListenerBindScope {
|
||||
.map(move |transport| BindClaim::new("mixed", address, port, transport))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
matches!(probe_claims(&claims), ListenerProbeOutcome::Available)
|
||||
probe_claims(&claims)
|
||||
}
|
||||
|
||||
/// Whether `port` is provably free — for picking a replacement, where anything short of
|
||||
/// proof should skip the candidate.
|
||||
pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
|
||||
matches!(self.probe_mixed_port(port), ListenerProbeOutcome::Available)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,12 +340,58 @@ fn claims_overlap(left: &BindClaim, right: &BindClaim) -> bool {
|
||||
&& (left.address == right.address || left.address.is_unspecified() || right.address.is_unspecified())
|
||||
}
|
||||
|
||||
/// Whether a refused bind can be dismissed because nothing is actually serving the claim.
|
||||
///
|
||||
/// A refused bind is not proof a port is in use: `SO_REUSEADDR` cannot reuse a `TIME_WAIT` socket
|
||||
/// owned by a *different* user, and a service-managed Core runs as root while this process does
|
||||
/// not. So every port the Core used is briefly unbindable here even though the Core itself could
|
||||
/// rebind it at once (measured on macOS 26). "Is anything answering?" survives that asymmetry;
|
||||
/// "can I bind?" does not.
|
||||
///
|
||||
/// Restrictions, both deliberate: a wildcard claim can be blocked by a listener on any address, so
|
||||
/// one refused connection cannot clear it — leaving `allow-lan` setups exposed to the same false
|
||||
/// positive. UDP has no lingering state, so a refused UDP bind is always a live socket.
|
||||
fn nothing_is_serving(claim: &BindClaim) -> bool {
|
||||
const ANSWER_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200);
|
||||
|
||||
if claim.transport != ListenerTransport::Tcp || claim.address.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
// Only an explicit refusal proves absence; a timeout leaves the question open.
|
||||
matches!(
|
||||
std::net::TcpStream::connect_timeout(&claim.socket_addr(), ANSWER_TIMEOUT),
|
||||
Err(error) if error.kind() == io::ErrorKind::ConnectionRefused
|
||||
)
|
||||
}
|
||||
|
||||
fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
|
||||
let mut sockets = Vec::with_capacity(claims.len());
|
||||
for claim in claims {
|
||||
match bind_claim(claim) {
|
||||
Ok(mut bound) => sockets.append(&mut bound),
|
||||
Err(error) if is_bind_conflict(&error) => return conflict_outcome(claim),
|
||||
Err(error) if is_bind_conflict(&error) => {
|
||||
if nothing_is_serving(claim) {
|
||||
logging!(
|
||||
info,
|
||||
LogType::Network,
|
||||
"{} could not bind {} ({}), but nothing answers there; treating it as free",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logging!(
|
||||
warn,
|
||||
LogType::Network,
|
||||
"{} cannot claim {} over {}: {}",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
transport_name(claim.transport),
|
||||
error
|
||||
);
|
||||
return conflict_outcome(claim);
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
@@ -370,7 +421,8 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_assert!(sockets.len() >= claims.len());
|
||||
// No one-socket-per-claim assertion: a dismissed claim is one this process never bound.
|
||||
drop(sockets);
|
||||
ListenerProbeOutcome::Available
|
||||
}
|
||||
|
||||
@@ -425,7 +477,19 @@ fn bind_claim(claim: &BindClaim) -> io::Result<Vec<Socket>> {
|
||||
match bind_socket(&BindClaim::new(claim.name, address, claim.port, claim.transport)) {
|
||||
Ok(socket) => sockets.push(socket),
|
||||
// Only a conflict proves the port is taken; a vanished guard address proves nothing.
|
||||
Err(error) if is_bind_conflict(&error) => return Err(error),
|
||||
Err(error) if is_bind_conflict(&error) => {
|
||||
// The caller only sees the claim, so name the address that actually lost.
|
||||
logging!(
|
||||
warn,
|
||||
LogType::Network,
|
||||
"{} claiming {} could not hold the overlapping address {}: {}",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
SocketAddr::new(address, claim.port),
|
||||
error
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -629,14 +693,14 @@ const fn transport_name(transport: ListenerTransport) -> &'static str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, probe_listener,
|
||||
probe_proxy_port_change,
|
||||
BindClaim, ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, nothing_is_serving,
|
||||
probe_listener, probe_proxy_port_change,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_yaml_ng::Mapping;
|
||||
#[cfg(unix)]
|
||||
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
|
||||
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
|
||||
use std::net::{IpAddr, Ipv4Addr, TcpListener, UdpSocket};
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
io::Read as _,
|
||||
@@ -718,6 +782,53 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Guards the startup false positive: another user's `TIME_WAIT` makes the kernel refuse the
|
||||
/// bind while nothing is serving. That refusal needs a second uid to stage, so this asserts
|
||||
/// on the classifier directly.
|
||||
#[test]
|
||||
fn a_refused_bind_with_nothing_answering_is_not_a_conflict() -> anyhow::Result<()> {
|
||||
let vacant = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = vacant.local_addr()?.port();
|
||||
drop(vacant);
|
||||
|
||||
assert!(
|
||||
nothing_is_serving(&BindClaim::new(
|
||||
"mixed",
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port,
|
||||
ListenerTransport::Tcp
|
||||
)),
|
||||
"a port nobody listens on must be dismissable"
|
||||
);
|
||||
|
||||
let serving = TcpListener::bind((Ipv4Addr::LOCALHOST, port))?;
|
||||
assert!(
|
||||
!nothing_is_serving(&BindClaim::new(
|
||||
"mixed",
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port,
|
||||
ListenerTransport::Tcp
|
||||
)),
|
||||
"a live listener must never be dismissed"
|
||||
);
|
||||
drop(serving);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// UDP has no lingering state, so a refused UDP bind is always a live socket.
|
||||
#[test]
|
||||
fn a_refused_udp_bind_is_never_dismissed() -> anyhow::Result<()> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = socket.local_addr()?.port();
|
||||
assert!(!nothing_is_serving(&BindClaim::new(
|
||||
"mixed",
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port,
|
||||
ListenerTransport::Udp
|
||||
)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn probe_reuses_port_held_only_by_a_stale_tcp_connection() -> anyhow::Result<()> {
|
||||
@@ -848,6 +959,19 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The startup fallback only moves on a proven `Conflict`, so a taken port must report one.
|
||||
#[test]
|
||||
fn startup_scope_reports_a_conflict_rather_than_an_unreachable_answer() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let scope = ListenerBindScope::from_mapping(&mapping("ipv6: false\n")?)?;
|
||||
assert!(matches!(
|
||||
scope.probe_mixed_port(port),
|
||||
ListenerProbeOutcome::Conflict { .. }
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scope_rejects_udp_only_conflicts() -> anyhow::Result<()> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
|
||||
Reference in New Issue
Block a user