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:
wonfen
2026-08-17 23:30:21 +08:00
parent 906ec5e63a
commit 747a68ef2f
19 changed files with 304 additions and 120 deletions
+133 -9
View File
@@ -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))?;