修复 bgfilter_worker 测试端口 TOCTOU:不再借用内核临时端口

- server-rs/crates/api-server/src/bgfilter_worker.rs:reserved_loopback_port 不再 bind("127.0.0.1:0") 取临时端口后 drop,
  改为在内核动态端口范围之外的固定测试带(20000-29999)内按进程内游标递增分配,并先探测可用性落点
- 同一文件:4 处调用点去掉不再需要的 .await(该 helper 现在是同步分配)

这样「先保持端口关闭、约 700ms 后才开始监听」的重启用例在整个空窗期内独占该端口,
重新 bind 不会再被同一测试二进制里其它用例的 bind(0) 抢走。
根因与证据见 #327 问题 2.1。本机 Windows 复现不出该抢占(动态端口范围 49152-65535,
连续 200 次 bind(0) 无一重复、也已实测刚释放的端口不被立即重新派发),它是 Linux 侧特性。
This commit is contained in:
2026-09-11 16:31:17 +08:00
parent a6f5ab9d23
commit f9e24f4e16
@@ -3558,13 +3558,41 @@ mod tests {
}
}
async fn reserved_loopback_port() -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
port
/// 测试端口带的上下界:**避开内核动态端口范围**。
///
/// Linux 默认 `net.ipv4.ip_local_port_range` 是 32768-60999Windows/macOS 默认动态
/// 端口范围是 49152-6553520000-29999 落在两者之外,内核不会把该带内的端口当作临时
/// 端口派发出去。
const TEST_PORT_BAND_START: u16 = 20_000;
const TEST_PORT_BAND_END: u16 = 29_999;
/// 进程内分配游标:保证同一测试进程的两个用例不会拿到同一个端口。
static TEST_PORT_CURSOR: AtomicUsize = AtomicUsize::new(0);
/// 测试专用:分配一个「先保持关闭、稍后才监听」的 loopback 端口。
///
/// 不能再用 `bind("127.0.0.1:0")` 取临时端口再 drop:本组用例需要在监听尚未开始的
/// 那段空窗里独占该端口(父侧必须先在 connect 失败上重试),而空窗期内同一测试
/// 二进制里其它用例的 `bind(0)` 完全可能被内核派到同一个端口,于是重新 bind 时报
/// `AddrInUse`#327;本文件两条「重启窗口」用例都踩,run 1950 报的就是它)。
///
/// 改为从动态端口范围之外的固定测试带里递增分配,并先探测可用性:内核不会自动派发
/// 该带内的端口,只要本进程不重号,空窗期内就没人能抢走它。
fn reserved_loopback_port() -> u16 {
const BAND_LEN: usize = (TEST_PORT_BAND_END - TEST_PORT_BAND_START + 1) as usize;
let start = TEST_PORT_CURSOR.fetch_add(1, AtomicOrdering::Relaxed) % BAND_LEN;
for step in 0..BAND_LEN {
let offset = (start + step) % BAND_LEN;
let port = TEST_PORT_BAND_START + u16::try_from(offset).expect("offset fits u16");
// 探测:宿主机上真有进程占用该端口时顺延到下一个。探测用的 listener 立即释放,
// 但这不是新的 TOCTOU——该端口不参与内核动态派发,本进程也不会再分配同一个端口。
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
panic!(
"测试端口带 {TEST_PORT_BAND_START}-{TEST_PORT_BAND_END} 全部不可用,无法分配 loopback 测试端口"
);
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
@@ -3575,7 +3603,7 @@ mod tests {
#[tokio::test]
async fn connect_retry_exhausts_flat_quota_then_returns_connect_error() {
let port = reserved_loopback_port().await;
let port = reserved_loopback_port();
let state = parent_client_state(port);
// 标记已连通:本测试验证的是常规档(运行中途故障)的 flat 快速收口配额。
state.mark_bgfilter_worker_reached();
@@ -3611,7 +3639,7 @@ mod tests {
async fn connect_retry_crosses_worker_restart_window_and_stops_on_http_response() {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let port = reserved_loopback_port().await;
let port = reserved_loopback_port();
let state = parent_client_state(port);
let audit = parent_audit(None);
let server = tokio::spawn(async move {
@@ -3685,7 +3713,7 @@ mod tests {
#[tokio::test]
async fn connect_retry_stops_without_sleeping_when_deadline_cannot_fit_next_round() {
let port = reserved_loopback_port().await;
let port = reserved_loopback_port();
let state = parent_client_state(port);
let call_budget = Duration::from_millis(state.config.bgfilter_call_budget_ms());
// 父剩余刚好放得下第一次调用(约 300ms 排队额度),放不下「退避 + 再一次完整调用」。
@@ -3763,7 +3791,7 @@ mod tests {
async fn cold_start_flat_retries_past_regular_quota_until_worker_listens() {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let port = reserved_loopback_port().await;
let port = reserved_loopback_port();
// 不标记已连通:模拟主机开机后 flat 首次调用,worker 约 2.8s 后才监听——
// 超出常规档 1.5s 配额,冷启动档必须继续退避跨过窗口。
let state = parent_client_state(port);