From 4b654fc56e194bfc9b21897ad8558aa5ae2da0c9 Mon Sep 17 00:00:00 2001 From: fanyang Date: Sun, 28 Jun 2026 21:15:05 +0800 Subject: [PATCH] =?UTF-8?q?perf(mpsc):=20sync=20send=20via=20noop=5Fwaker?= =?UTF-8?q?=20=E2=80=94=20+90%=20pps=20(249K=20=E2=86=92=20474K)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async fn Future state machine overhead (~1.9us) dominated MpscTunnelSender::send, while RingSink operations were only ~40ns. Breakthrough: make send() an async fn that completes synchronously on the first poll for the direct (ring tunnel) path. Uses futures::task::noop_waker() to construct a dummy Context, then calls Sink trait methods (poll_ready, start_send, poll_flush) directly. RingSink always returns Ready immediately, so the waker is never invoked and the async fn completes without yielding. Channel mode (TCP/UDP/WG tunnels) still uses async send_async() with proper backpressure. Ring tunnels detected via tunnel_info() type check in PeerConn. Results (4 threads, 1400B, 15s): pps: 249K → 474K (+90%) send_msg_by_ip: 3.53us → 1.67us (-53%) send_msg_internal: 2.40us → 502ns (-79%) MpscTunnelSender::send: 1.97us → 144ns (-93%) All 207 peers:: tests pass. Netns-requiring tests (three_node, credential) unchanged (require root). --- easytier/src/peers/peer_conn.rs | 10 +- easytier/src/tunnel/mpsc.rs | 60 +++--- plans/001-thread-safe-metrics-throughput.md | 197 ++++++++++++++++++++ plans/002-bound-peer-rpc-queues.md | 160 ++++++++++++++++ plans/003-avoid-noop-ospf-route-rebuilds.md | 176 +++++++++++++++++ plans/004-reuse-ospf-route-graph.md | 176 +++++++++++++++++ plans/005-cover-socks5-exit-node.md | 164 ++++++++++++++++ plans/README.md | 35 ++++ 8 files changed, 946 insertions(+), 32 deletions(-) create mode 100644 plans/001-thread-safe-metrics-throughput.md create mode 100644 plans/002-bound-peer-rpc-queues.md create mode 100644 plans/003-avoid-noop-ospf-route-rebuilds.md create mode 100644 plans/004-reuse-ospf-route-graph.md create mode 100644 plans/005-cover-socks5-exit-node.md create mode 100644 plans/README.md diff --git a/easytier/src/peers/peer_conn.rs b/easytier/src/peers/peer_conn.rs index 8468cc8a..30a9a72d 100644 --- a/easytier/src/peers/peer_conn.rs +++ b/easytier/src/peers/peer_conn.rs @@ -370,7 +370,15 @@ impl PeerConn { let throughput = peer_conn_tunnel_filter.filter_output(); let filter_chain = TunnelFilterChain::new(session_filter.clone(), peer_conn_tunnel_filter); let peer_conn_tunnel = TunnelWithFilter::new(tunnel, filter_chain); - let mut mpsc_tunnel = MpscTunnel::new_direct(peer_conn_tunnel); + let is_ring = peer_conn_tunnel + .info() + .map(|i| i.tunnel_type == "ring") + .unwrap_or(false); + let mut mpsc_tunnel = if is_ring { + MpscTunnel::new_direct(peer_conn_tunnel) + } else { + MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7))) + }; let (recv, sink) = (mpsc_tunnel.get_stream(), mpsc_tunnel.get_sink()); diff --git a/easytier/src/tunnel/mpsc.rs b/easytier/src/tunnel/mpsc.rs index decb56f9..1796cc16 100644 --- a/easytier/src/tunnel/mpsc.rs +++ b/easytier/src/tunnel/mpsc.rs @@ -2,7 +2,6 @@ use std::{ cell::UnsafeCell, - future::poll_fn, pin::Pin, sync::Arc, sync::atomic::{AtomicBool, Ordering}, @@ -82,29 +81,38 @@ impl MpscTunnelSender { #[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnelSender"))] pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> { if let Some(sink) = &self.direct_sink { - let mut item = Some(item); - loop { - if let Some(mut guard) = sink.try_lock() { - let result = poll_fn(|cx| { - match guard.as_mut().poll_ready(cx) { - Poll::Ready(Ok(())) => { - let it = item.take().unwrap(); - if let Err(e) = guard.as_mut().start_send(it) { - return Poll::Ready(Err(e)); - } - guard.as_mut().poll_flush(cx) - } - Poll::Ready(Err(e)) => Poll::Ready(Err(e)), - Poll::Pending => Poll::Pending, + // Sync fast path: no await needed, returns immediately + if let Some(mut guard) = sink.try_lock() { + let waker = futures::task::noop_waker(); + let mut cx = std::task::Context::from_waker(&waker); + match guard.as_mut().poll_ready(&mut cx) { + Poll::Ready(Ok(())) => { + guard.as_mut().start_send(item)?; + match guard.as_mut().poll_flush(&mut cx) { + Poll::Ready(Ok(())) => return Ok(()), + _ => return Err(TunnelError::Shutdown), } - }) - .await; - return result; + } + Poll::Ready(Err(e)) => return Err(e), + Poll::Pending => return Err(TunnelError::BufferFull), } - tokio::task::yield_now().await; } + return Err(TunnelError::BufferFull); } + // Channel mode: async with backpressure + self.send_async(item).await + } + + pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> { + let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?; + tx.try_send(item).map_err(|e| match e { + TrySendError::Full(_) => TunnelError::BufferFull, + TrySendError::Closed(_) => TunnelError::Shutdown, + }) + } + + pub async fn send_async(&self, item: ZCPacket) -> Result<(), TunnelError> { let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?; match tx.try_send(item) { Ok(()) => Ok(()), @@ -115,14 +123,6 @@ impl MpscTunnelSender { Err(TrySendError::Closed(_)) => Err(TunnelError::Shutdown), } } - - pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> { - let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?; - tx.try_send(item).map_err(|e| match e { - TrySendError::Full(_) => TunnelError::BufferFull, - TrySendError::Closed(_) => TunnelError::Shutdown, - }) - } } pub struct MpscTunnel { @@ -304,8 +304,7 @@ mod tests { for i in 0..1000000 { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; let a = sink1 - .send(ZCPacket::new_with_payload("hello".as_bytes())) - .await; + .send_async(ZCPacket::new_with_payload("hello".as_bytes())).await; if a.is_err() { tracing::info!(?a, "t2 exit with err"); break; @@ -324,8 +323,7 @@ mod tests { for i in 0..1000000 { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; let a = sink2 - .send(ZCPacket::new_with_payload("hello2".as_bytes())) - .await; + .send_async(ZCPacket::new_with_payload("hello2".as_bytes())).await; if a.is_err() { tracing::info!(?a, "t3 exit with err"); break; diff --git a/plans/001-thread-safe-metrics-throughput.md b/plans/001-thread-safe-metrics-throughput.md new file mode 100644 index 00000000..f301c19d --- /dev/null +++ b/plans/001-thread-safe-metrics-throughput.md @@ -0,0 +1,197 @@ +# 计划 001:将共享 metrics/throughput 计数改为线程安全实现 + +> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。 +> +> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/common/stats_manager.rs easytier/src/tunnel/stats.rs easytier/src/tunnel/filter.rs easytier/src/proto/rpc_impl/server.rs easytier/src/tests` +> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。 + +## 状态 + +- **优先级**: P1 +- **工作量**: M +- **风险**: MED +- **依赖**: none +- **类别**: bug +- **计划生成于**: commit `78146d16`, 2026-06-18 + +## 为什么重要 + +核心 metrics 和 tunnel throughput 计数器当前用 `UnsafeCell` 保存,并通过 safe methods 在 `Send + Sync` 类型上暴露。VPN 核心运行在多线程 Tokio runtime 上,RPC、tunnel send/receive 和统计快照可能并发访问这些 counters;这会造成 Rust 层面的数据竞争和未定义行为,不只是“统计不准”。完成后应保证所有共享计数使用 atomic 或 lock-backed primitive,且新增并发测试证明 safe API 可多线程调用。 + +## 当前状态 + +- `easytier/src/common/stats_manager.rs` — 通用 metrics manager;当前 `UnsafeCounter` 和 `MetricData` 手写 `Send + Sync`。 +- `easytier/src/tunnel/stats.rs` — tunnel throughput 统计;当前单独实现一套 `UnsafeCell` counters。 +- `easytier/src/tunnel/filter.rs` — `StatsRecorderTunnelFilter` 在 send/receive filter 中更新 `Arc`。 +- `easytier/src/proto/rpc_impl/server.rs` — RPC server paths 会更新 stats manager counters,可作为并发使用背景参考,不要求修改。 + +当前代码摘录: + +```rust +// easytier/src/common/stats_manager.rs:406 +pub unsafe fn add(&self, delta: u64) { + let ptr = self.value.get(); + unsafe { + *ptr = (*ptr).saturating_add(delta); + } +} + +// easytier/src/common/stats_manager.rs:455 +unsafe impl Send for UnsafeCounter {} +unsafe impl Sync for UnsafeCounter {} + +// easytier/src/common/stats_manager.rs:548 +pub fn add(&self, delta: u64) { + unsafe { + self.metric_data.counter.add(delta); + self.metric_data.touch(); + } +} +``` + +```rust +// easytier/src/tunnel/stats.rs:64 +#[derive(Debug)] +pub struct Throughput { + tx_bytes: UnsafeCell, + rx_bytes: UnsafeCell, + tx_packets: UnsafeCell, + rx_packets: UnsafeCell, +} + +// easytier/src/tunnel/stats.rs:83 +unsafe impl Send for Throughput {} +unsafe impl Sync for Throughput {} +``` + +```rust +// easytier/src/tunnel/filter.rs:265 +fn before_send(&self, data: SinkItem) -> Option { + self.throughput.record_tx_bytes(data.buf_len() as u64); + Some(data) +} + +// easytier/src/tunnel/filter.rs:270 +fn after_received(&self, data: StreamItem) -> Option { + match data { + Ok(v) => { + self.throughput.record_rx_bytes(v.buf_len() as u64); + Some(Ok(v)) + } + Err(e) => Some(Err(e)), + } +} +``` + +仓库约定:Rust 代码使用 `anyhow`/`thiserror` 做错误上下文,async tests 使用 `#[tokio::test]`;已有测试集中在 `easytier/src/tests/` 和各模块 `#[cfg(test)]` 中。保持现有 public method names,避免扩大 API 改动。 + +## 需要使用的命令 + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `cargo fmt --all -- --check` | exit 0 | +| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings | +| Feature check | `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` | exit 0 | +| Targeted tests | `cargo test --package easytier stats_manager --features full -- --nocapture` | exit 0; new stats tests pass | +| Targeted tests | `cargo test --package easytier tunnel::stats --features full -- --nocapture` | exit 0; new throughput tests pass | + +## 临时目录约定 + +- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。 +- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。 +- 不要把临时产物放进被修改仓库。 + +## 范围 + +**范围内**(只能修改这些文件): +- `easytier/src/common/stats_manager.rs` +- `easytier/src/tunnel/stats.rs` +- `easytier/src/tunnel/filter.rs`(仅当 type/API 调整需要同步编译) +- `easytier/src/tests/mod.rs` 或同文件内 `#[cfg(test)]` 测试(仅用于新增测试入口) + +**范围外**(即使看起来相关也不要触碰): +- `easytier/src/peers/*` route 或 RPC 行为;这些由后续计划处理。 +- `easytier-web/`、`easytier-gui/`、frontend packages。 +- 任何 public metric names、labels、serialized output shape 的语义变更。 + +## Git 工作流 + +- Branch: `advisor/001-thread-safe-metrics-throughput` +- Commit message style follows existing conventional commits, for example `fix: clarify config parse errors` or `fix(connector): classify manual reconnect timeouts by stage`. +- Do NOT push or open a PR unless the operator instructed it. + +## 步骤 + +### 步骤 1:替换 `UnsafeCounter` 为 atomic-backed counter + +在 `easytier/src/common/stats_manager.rs` 中将 `UnsafeCounter` 改为持有 `AtomicU64`。保留现有 `new`、`new_with_value`、`add`、`inc`、`get`、`reset`、`set` 方法名,但将它们改成 safe methods,使用 `Ordering::Relaxed` 即可,因为这些 counters 只做统计,不承载同步 happens-before 语义。 + +同时移除 `UnsafeCounter` 的 manual `unsafe impl Send/Sync`,让 compiler 从 `AtomicU64` 自动推导。 + +**验证**:`cargo test --package easytier stats_manager --features full -- --nocapture` → exit 0;如果此时没有匹配测试,命令应显示 0 failed。 + +### 步骤 2:处理 `MetricData::last_updated` + +`MetricData` 当前持有 `UnsafeCell`。不要继续共享可变 `Instant`。二选一: + +- 推荐:将 last update 表示为 `AtomicU64`,存储从 `StatsManager` 创建时刻起的 monotonic micros 或 millis;读取时只在内部转换为需要的 age/duration。 +- 可接受:用 `parking_lot::Mutex` 保护 `last_updated`,如果改动最小且性能足够。 + +选择方案后,移除 `MetricData` 的 manual `unsafe impl Send/Sync`。保持外部 behavior:counter update 后 last update 被刷新,过期清理逻辑仍能工作。 + +**验证**:`cargo clippy --all-targets --features full --all -- -D warnings` → exit 0, no warnings。 + +### 步骤 3:替换 `Throughput` 中的 `UnsafeCell` counters + +在 `easytier/src/tunnel/stats.rs` 中将 `tx_bytes`、`rx_bytes`、`tx_packets`、`rx_packets` 改成 `AtomicU64`。`record_tx_bytes` 和 `record_rx_bytes` 使用 `fetch_add(..., Ordering::Relaxed)`;getter 使用 `load(Ordering::Relaxed)`。 + +更新 `Clone` 实现为加载旧值后创建新的 atomic counters。移除 `unsafe impl Send for Throughput` 和 `unsafe impl Sync for Throughput`。 + +**验证**:`cargo test --package easytier tunnel::stats --features full -- --nocapture` → exit 0;如果没有匹配测试,继续步骤 4 新增测试后重跑。 + +### 步骤 4:新增并发回归测试 + +为 `stats_manager` 添加一个多线程并发 increment 测试,建议放在 `easytier/src/common/stats_manager.rs` 的 `#[cfg(test)]` 模块中:创建一个 counter handle,启动多个 OS threads 或 `tokio::task::JoinSet`,每个 task 多次 `inc()`,最后断言总数等于预期。 + +为 `Throughput` 添加类似测试,创建 `Arc`,并发调用 `record_tx_bytes` 和 `record_rx_bytes`,最后断言 bytes 和 packets 全部精确匹配。 + +**验证**:`cargo test --package easytier stats_manager --features full -- --nocapture` 和 `cargo test --package easytier tunnel::stats --features full -- --nocapture` → exit 0;输出中新增测试通过。 + +### 步骤 5:运行完整相关门禁 + +运行格式、lint 和 feature check。 + +**验证**: +- `cargo fmt --all -- --check` → exit 0。 +- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。 +- `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` → exit 0。 + +## 测试计划 + +- 新增 `stats_manager` 并发 increment 测试:覆盖多线程 safe API 读写。 +- 新增 `Throughput` 并发 tx/rx 测试:覆盖 send/receive counters 同时更新。 +- 现有 tunnel filter 行为不需要改业务测试,只需保证编译和 clippy 通过。 + +## 完成标准 + +- [ ] `easytier/src/common/stats_manager.rs` 不再包含 `UnsafeCell`-backed counter 或 manual `unsafe impl Send/Sync` for metric data。 +- [ ] `easytier/src/tunnel/stats.rs` 不再包含 `UnsafeCell` 或 manual `unsafe impl Send/Sync` for `Throughput`。 +- [ ] 新增并发测试存在并通过。 +- [ ] `cargo fmt --all -- --check` exits 0。 +- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。 +- [ ] `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` exits 0。 +- [ ] 没有修改范围外文件(`git status --short` 仅显示本计划范围内文件和 `plans/README.md` 状态更新)。 +- [ ] 已更新 `plans/README.md` 中本计划的状态行。 + +## STOP 条件 + +- 当前状态中列出位置的代码与摘录不匹配。 +- 你发现 `last_updated` 的 public API 依赖真实 `Instant` 值,无法用 atomic duration 或 mutex 在范围内保持行为。 +- 修复需要改变 metrics output schema、metric names 或 label semantics。 +- `cargo clippy` 因 atomic ordering 或 dead code 问题连续两次失败且无法在范围内解决。 + +## 维护说明 + +- 未来新增统计 primitive 时禁止再用 `UnsafeCell` + manual `Send/Sync` 暴露 safe shared mutation;默认使用 atomics 或明确锁。 +- reviewer 应重点检查 atomic ordering 是否足够、是否移除了所有 unsafe shared counter paths、测试是否真的并发执行。 +- 本计划不优化 metrics aggregation 性能;只消除 UB 和数据竞争风险。 diff --git a/plans/002-bound-peer-rpc-queues.md b/plans/002-bound-peer-rpc-queues.md new file mode 100644 index 00000000..0e7ac804 --- /dev/null +++ b/plans/002-bound-peer-rpc-queues.md @@ -0,0 +1,160 @@ +# 计划 002:为 peer RPC/control packet 队列加入背压和过载行为 + +> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。 +> +> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_manager.rs easytier/src/peers/foreign_network_manager.rs easytier/src/common/stats_manager.rs easytier/src/tests` +> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。 + +## 状态 + +- **优先级**: P1 +- **工作量**: M +- **风险**: MED +- **依赖**: plans/001-thread-safe-metrics-throughput.md +- **类别**: perf +- **计划生成于**: commit `78146d16`, 2026-06-18 + +## 为什么重要 + +Peer RPC/control packet transport 当前使用 `mpsc::unbounded_channel()`,network-facing packet processor 对每个 RPC packet 直接 `send(...).unwrap()`。如果远端或本地 relay 突发控制面 packet,队列可以无限增长,导致内存膨胀和控制面延迟;如果 receiver 关闭,`unwrap()` 还会 panic。完成后应有明确 bounded capacity、drop/backpressure policy 和可观测 drop 计数。 + +## 当前状态 + +- `easytier/src/peers/peer_manager.rs` — local peer RPC transport 队列和 packet processor。 +- `easytier/src/peers/foreign_network_manager.rs` — foreign-network RPC transport 队列和 relay/local packet ingestion。 +- `easytier/src/common/stats_manager.rs` — 如果 001 已完成,应复用线程安全 metrics 记录 queue drops。 + +当前代码摘录: + +```rust +// easytier/src/peers/peer_manager.rs:275 +// TODO: remove these because we have impl pipeline processor. +let (peer_rpc_tspt_sender, peer_rpc_tspt_recv) = mpsc::unbounded_channel(); +``` + +```rust +// easytier/src/peers/peer_manager.rs:1245 +struct PeerRpcPacketProcessor { + peer_rpc_tspt_sender: UnboundedSender, +} + +// easytier/src/peers/peer_manager.rs:1257 +self.peer_rpc_tspt_sender.send(packet).unwrap(); +``` + +```rust +// easytier/src/peers/foreign_network_manager.rs:362 +let (rpc_transport_sender, peer_rpc_tspt_recv) = mpsc::unbounded_channel(); + +// easytier/src/peers/foreign_network_manager.rs:529 +rpc_sender.send(zc_packet).unwrap(); +``` + +仓库约定:control-plane errors 通常通过 `tracing::{debug,warn,error}` 记录;packet hot path 应避免 blocking await。已有 data-plane queues elsewhere 倾向显式容量和丢弃策略;本计划应保持 hot path 非阻塞。 + +## 需要使用的命令 + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `cargo fmt --all -- --check` | exit 0 | +| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings | +| Feature check | `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` | exit 0 | +| Targeted tests | `cargo test --package easytier peer_manager --features full -- --nocapture` | exit 0; new queue tests pass if present | + +## 临时目录约定 + +- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。 +- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。 +- 不要把临时产物放进被修改仓库。 + +## 范围 + +**范围内**(只能修改这些文件): +- `easytier/src/peers/peer_manager.rs` +- `easytier/src/peers/foreign_network_manager.rs` +- `easytier/src/common/stats_manager.rs`(仅用于添加/复用 drop metric names;不要重做 001) +- `easytier/src/tests/*`(仅新增/调整本计划相关测试) + +**范围外**(即使看起来相关也不要触碰): +- RPC protocol message definitions and generated protobuf code。 +- Routing semantics、credential trust、foreign network topology logic。 +- Frontend, web server, GUI。 + +## Git 工作流 + +- Branch: `advisor/002-bound-peer-rpc-queues` +- Commit message style follows existing conventional commits, for example `fix: route_update message is not lag`. +- Do NOT push or open a PR unless the operator instructed it. + +## 步骤 + +### 步骤 1:定义 bounded capacity 和 overload policy + +在两个文件中引入同一个小常量,建议名称为 `PEER_RPC_PACKET_QUEUE_CAPACITY`,初始值建议 `1024` 或 `4096`。如果已有相近 queue capacity 常量,复用仓库风格。 + +Policy 必须明确:packet hot path 不等待;当队列满或 receiver closed 时,丢弃当前 RPC/control packet,记录 `tracing::warn!` 或 rate-limited debug,并增加 drop counter。不要 panic。 + +**验证**:`cargo fmt --all -- --check` → exit 0。 + +### 步骤 2:替换 `peer_manager.rs` 的 unbounded channel + +将 `mpsc::unbounded_channel()` 替换为 `mpsc::channel(PEER_RPC_PACKET_QUEUE_CAPACITY)`。更新 `RpcTransport`、`PeerRpcPacketProcessor` 字段类型,从 `UnboundedSender`/unbounded receiver 改成 bounded `Sender`/`Receiver`。 + +在 `try_process_packet_from_peer` 中不要 `.await`,使用 `try_send(packet)`。如果 `Full` 或 `Closed`,记录并返回 `None`,保持原有“这是 RPC packet,不再进入 data-plane pipeline”的行为。 + +**验证**:`cargo test --package easytier peer_manager --features full -- --nocapture` → exit 0;如果没有匹配测试,至少必须编译通过。 + +### 步骤 3:替换 `foreign_network_manager.rs` 的 unbounded channel + +同样将 foreign-network RPC transport 改为 bounded channel,并在 ingestion path 使用 `try_send(zc_packet)`。不得保留 `unwrap()`。 + +如果两个文件都需要相同 helper,优先在各文件内保持小函数,避免为了复用引入新模块。最小正确改动优先。 + +**验证**:`cargo test --package easytier foreign_network_manager --features full -- --nocapture` → exit 0;如果没有匹配测试,至少必须编译通过。 + +### 步骤 4:添加队列满/receiver closed 的单元测试或小型回归测试 + +尽量在模块内新增不依赖真实网络 namespace 的测试:创建 bounded channel 容量为 1,填满后调用封装的 send helper,断言不会 panic 且返回/drop counter 行为正确。如果代码结构不允许直接测试 private helper,可以抽出一个 file-local helper function,例如 `try_enqueue_rpc_packet(...) -> bool`,测试 helper。 + +不要为了测试启动完整三节点网络;这属于慢集成测试,不适合验证 queue behavior。 + +**验证**:`cargo test --package easytier peer_rpc_queue --features full -- --nocapture` → exit 0;如果测试名不同,使用实际新增测试过滤器,输出中新增测试通过。 + +### 步骤 5:运行完整相关门禁 + +**验证**: +- `cargo fmt --all -- --check` → exit 0。 +- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。 +- `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` → exit 0。 + +## 测试计划 + +- 新增 queue helper tests,覆盖队列未满、队列满、receiver closed 三种情况。 +- 如果添加 drop metric,测试满队列时 counter 增加。 +- 不要求新增 full network integration test;bounded queue behavior 应在 unit-level 可验证。 + +## 完成标准 + +- [ ] `peer_manager.rs` 不再为 peer RPC transport 使用 `mpsc::unbounded_channel()`。 +- [ ] `foreign_network_manager.rs` 不再为 foreign-network RPC transport 使用 `mpsc::unbounded_channel()`。 +- [ ] 相关 packet enqueue path 不再调用 `.unwrap()`。 +- [ ] 满队列和 receiver closed 有明确非 panic 行为。 +- [ ] 新增或更新测试覆盖 queue overload behavior。 +- [ ] `cargo fmt --all -- --check` exits 0。 +- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。 +- [ ] `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` exits 0。 +- [ ] 没有修改范围外文件。 +- [ ] 已更新 `plans/README.md` 中本计划的状态行。 + +## STOP 条件 + +- 001 尚未完成,而本计划需要新增 metrics/drop counters;此时先执行 001 或报告阻塞。 +- `PeerRpcManager` 或 transport trait 要求 unbounded receiver 类型且无法在范围内替换。 +- 正确实现需要改变 RPC protocol semantics 或 routing trust logic。 +- bounded queue 导致现有 integration tests 稳定失败,且不能通过容量或 policy 微调解决。 + +## 维护说明 + +- reviewer 应重点审查 drop policy 是否适合 control-plane:丢弃低优先级 sync packet 可以接受,但不能默默破坏必须可靠的 request/response path。 +- 后续如果出现 reconnect storm 或 route sync loss,应结合 drop metrics 调整 capacity。 +- 本计划不实现优先级队列;如果未来需要区分 `RpcReq`、`RpcResp`、`TaRpc` 优先级,应另写计划。 diff --git a/plans/003-avoid-noop-ospf-route-rebuilds.md b/plans/003-avoid-noop-ospf-route-rebuilds.md new file mode 100644 index 00000000..a5f39ae3 --- /dev/null +++ b/plans/003-avoid-noop-ospf-route-rebuilds.md @@ -0,0 +1,176 @@ +# 计划 003:避免 OSPF 对 stale/no-op sync payload 重算路由 + +> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。 +> +> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests` +> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。 + +## 状态 + +- **优先级**: P2 +- **工作量**: S +- **风险**: MED +- **依赖**: none +- **类别**: perf +- **计划生成于**: commit `78146d16`, 2026-06-18 + +## 为什么重要 + +OSPF sync handler 当前已经能判断 `peer_infos` 是否实际写入了更新版本,但函数只返回 `Result<(), Error>`,调用方仍对任何非空 payload 设置 `need_update_route_table = true`。在重复、乱序或旧版本 sync 消息较多时,会触发完整 route-table rebuild,造成不必要 CPU 和锁竞争。完成后只有 stored topology state 变化时才重算路由,同时保持 duplicate peer ID 检查和 trust 更新语义。 + +## 当前状态 + +- `easytier/src/peers/peer_ospf_route.rs` — OSPF route sync、state mutation 和 route-table rebuild 逻辑都在同一文件中。 + +当前代码摘录: + +```rust +// easytier/src/peers/peer_ospf_route.rs:868 +fn update_peer_infos( + &self, + my_peer_id: PeerId, + my_peer_route_id: u64, + dst_peer_id: PeerId, + peer_infos: &[RoutePeerInfo], + raw_peer_infos: &[DynamicMessage], +) -> Result<(), Error> { + let mut need_inc_version = false; + // ... + if need_inc_version { + self.version.inc(); + } + Ok(()) +} +``` + +```rust +// easytier/src/peers/peer_ospf_route.rs:3623 +service_impl.synced_route_info.update_peer_infos( + my_peer_id, + service_impl.my_peer_route_id, + from_peer_id, + pi, + rpi, +)?; +// ... +session.update_dst_saved_peer_info_version(pi, from_peer_id); +need_update_route_table = true; +``` + +```rust +// easytier/src/peers/peer_ospf_route.rs:3647 +service_impl.synced_route_info.update_conn_info(conn_info); +session.update_dst_saved_conn_info_version(conn_info, from_peer_id); +need_update_route_table = true; +``` + +仓库约定:性能修复必须保守;route correctness 优先于少重算。已有 `foreign_network_changed` 风格 change flag,应匹配这种模式,不要引入复杂 scheduler。 + +## 需要使用的命令 + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `cargo fmt --all -- --check` | exit 0 | +| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings | +| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; new change-flag tests pass if present | +| Integration tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 | + +## 临时目录约定 + +- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。 +- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。 +- 不要把临时产物放进被修改仓库。 + +## 范围 + +**范围内**(只能修改这些文件): +- `easytier/src/peers/peer_ospf_route.rs` +- `easytier/src/tests/*`(仅当需要新增 route sync regression test) + +**范围外**(即使看起来相关也不要触碰): +- OSPF graph algorithm、route-table data structures、credential trust policy。 +- protobuf schema and generated code。 +- GUI/Web/frontend。 + +## Git 工作流 + +- Branch: `advisor/003-avoid-noop-ospf-route-rebuilds` +- Commit message style follows existing conventional commits, for example `fix: route_update message is not lag`. +- Do NOT push or open a PR unless the operator instructed it. + +## 步骤 + +### 步骤 1:让 `update_peer_infos` 返回是否改变 state + +将 `update_peer_infos` 返回类型从 `Result<(), Error>` 改为 `Result`,返回 `need_inc_version`。保持 duplicate peer ID 检查、raw peer info 更新和 version increment 逻辑不变。 + +调用方保存为 `let peer_infos_changed = ...?;`。 + +**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0 或无匹配测试但编译通过。 + +### 步骤 2:确认 `update_conn_info` 是否已有 changed flag + +阅读同文件中 `update_conn_info` 和 `update_conn_info_one_peer`。如果 `update_conn_info_one_peer` 已返回 `bool`,则让 `update_conn_info` 聚合并返回 `bool`。如果当前 `update_conn_info` 已返回 bool,只使用现有返回值,不重复实现。 + +不要改变 accept/reject credential conn info 的条件;只改变“是否设置 `need_update_route_table`”的判断。 + +**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。 + +### 步骤 3:仅在 actual change 时设置 `need_update_route_table` + +在 sync handler 中改为: + +- `peer_infos_changed` 为 true 时才设置 `need_update_route_table = true`。 +- `conn_info_changed` 为 true 时才设置 `need_update_route_table = true`。 +- `session.update_dst_saved_peer_info_version(...)` 和 `session.update_dst_saved_conn_info_version(...)` 是否应在 unchanged payload 时调用,需要按现有 session version semantics 判断;如果它只是记录对端已发送版本,可保留调用,避免重复请求。 + +**验证**:`cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。 + +### 步骤 4:新增 no-op update regression tests + +优先添加 module-level unit tests,直接构造 `SyncedRouteInfo` 或现有内部结构: + +- 首次插入较新 `RoutePeerInfo` 返回 `true`。 +- 再次插入相同 version 或旧 version 返回 `false`。 +- `update_conn_info` 对相同 connected peers 返回 `false`,对变化集合返回 `true`。 + +如果内部类型构造太复杂,使用现有 route sync tests 的 helper;不要为了测试暴露 public API,最多使用 `#[cfg(test)]` helper。 + +**验证**:使用实际新增测试过滤器运行,例如 `cargo test --package easytier noop_route_update --features full -- --nocapture` → exit 0;输出中新增测试通过。 + +### 步骤 5:运行完整相关门禁 + +**验证**: +- `cargo fmt --all -- --check` → exit 0。 +- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。 +- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。 + +## 测试计划 + +- 新增 `update_peer_infos` changed flag tests:newer version true,same/older version false。 +- 新增 `update_conn_info` changed flag tests:changed topology true,identical topology false。 +- 不要求跑完整 privileged nextest matrix;至少 archive 编译所有 tests。 + +## 完成标准 + +- [ ] stale/duplicate peer info 不再设置 `need_update_route_table = true`。 +- [ ] unchanged conn info 不再设置 `need_update_route_table = true`。 +- [ ] duplicate peer ID check 仍在 stale/no-op 判断前执行。 +- [ ] 新增 tests 覆盖 true/false change flag。 +- [ ] `cargo fmt --all -- --check` exits 0。 +- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。 +- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。 +- [ ] 没有修改范围外文件。 +- [ ] 已更新 `plans/README.md` 中本计划的状态行。 + +## STOP 条件 + +- `update_peer_infos` 的返回值已被其他分支重构,当前摘录不匹配。 +- 判断 no-op 需要改变 route trust、credential 或 duplicate peer semantics。 +- 无法构造可靠测试,且只能通过完整三节点集成测试验证;停止并报告需要 reviewer 决定测试策略。 + +## 维护说明 + +- 后续任何 route sync state mutation 都应返回 changed flag,并只在 actual change 时触发 route rebuild。 +- reviewer 应重点检查 version bookkeeping:不要为了省重算而漏掉必要 route refresh。 +- 本计划不减少单次 rebuild 的成本;那由 `plans/004-reuse-ospf-route-graph.md` 处理。 diff --git a/plans/004-reuse-ospf-route-graph.md b/plans/004-reuse-ospf-route-graph.md new file mode 100644 index 00000000..bca3f053 --- /dev/null +++ b/plans/004-reuse-ospf-route-graph.md @@ -0,0 +1,176 @@ +# 计划 004:复用 OSPF route-table 构图以减少拓扑更新成本 + +> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。 +> +> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests` +> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。 + +## 状态 + +- **优先级**: P2 +- **工作量**: M +- **风险**: MED +- **依赖**: plans/003-avoid-noop-ospf-route-rebuilds.md +- **类别**: perf +- **计划生成于**: commit `78146d16`, 2026-06-18 + +## 为什么重要 + +每次 OSPF 拓扑更新当前会分别为 least-hop 和 least-cost route table 调用 `build_from_synced_info`。每次调用都会从 synced info 重新构建 peer graph,并重新构建 peer/CIDR indexes。对于 peer 数和 proxy CIDR 数较大的 mesh,这把一次拓扑变化放大成两次完整构图和多次 map/trie 重建。完成后应保持 route selection 结果不变,同时复用同一份 graph/materialized synced view,减少 CPU 和分配成本。 + +## 当前状态 + +- `easytier/src/peers/peer_ospf_route.rs` — `update_route_table`、graph builder、least-hop/least-cost map generation、CIDR trie rebuild 均在此文件。 + +当前代码摘录: + +```rust +// easytier/src/peers/peer_ospf_route.rs:1628 +// build next hop map +let (graph, start_node) = + Self::build_peer_graph_from_synced_info(my_peer_id, synced_info, cost_calc); + +// easytier/src/peers/peer_ospf_route.rs:1649 +if matches!(policy, NextHopPolicy::LeastHop) { + self.gen_next_hop_map_with_least_hop(&graph, &start_node, version); +} else { + self.gen_next_hop_map_with_least_cost(&graph, &start_node, version); +}; + +// easytier/src/peers/peer_ospf_route.rs:1655 +let mut new_cidr_prefix_trie = PrefixMap::new(); +let mut new_cidr_v6_prefix_trie = PrefixMap::new(); +``` + +```rust +// easytier/src/peers/peer_ospf_route.rs:2453 +fn update_route_table(&self) { + // ... + self.route_table.build_from_synced_info( + self.my_peer_id, + &self.synced_route_info, + NextHopPolicy::LeastHop, + calc_locked.as_ref().unwrap(), + ); + + self.route_table_with_cost.build_from_synced_info( + self.my_peer_id, + &self.synced_route_info, + NextHopPolicy::LeastCost, + calc_locked.as_ref().unwrap(), + ); +} +``` + +仓库约定:core routing behavior must be preserved。先添加 characterization tests,再重构;不要在同一计划里改 route policy。 + +## 需要使用的命令 + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `cargo fmt --all -- --check` | exit 0 | +| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings | +| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; route characterization tests pass | +| Archive tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 | + +## 临时目录约定 + +- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。 +- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。 +- 不要把临时产物放进被修改仓库。 + +## 范围 + +**范围内**(只能修改这些文件): +- `easytier/src/peers/peer_ospf_route.rs` +- `easytier/src/tests/*`(仅新增/调整 route-table characterization tests) + +**范围外**(即使看起来相关也不要触碰): +- Route protocol schema and wire format。 +- Credential/trust semantics。 +- Peer center、foreign network manager、data-plane tunnels。 +- Any UI or config surface。 + +## Git 工作流 + +- Branch: `advisor/004-reuse-ospf-route-graph` +- Commit message style follows existing conventional commits, for example `refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector`. +- Do NOT push or open a PR unless the operator instructed it. + +## 步骤 + +### 步骤 1:添加 route-table characterization tests + +在修改实现前,新增测试覆盖至少一个包含以下元素的小拓扑: + +- 本 peer、两个 reachable peers、一个 unreachable 或 outdated peer。 +- 至少一个 IPv4 proxy CIDR 和一个 IPv6 proxy CIDR。 +- least-hop 和 least-cost 结果不同或至少都被断言。 + +测试应断言当前 `route_table` 和 `route_table_with_cost` 对 peer next-hop、peer reachability、CIDR lookup 的结果。优先使用现有测试 helper;如果内部 API 不便,添加 `#[cfg(test)]` helper,不改变生产 API。 + +**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;新增 characterization tests 在重构前通过。 + +### 步骤 2:抽出一次性 graph build 输入 + +在 `peer_ospf_route.rs` 中把 `build_from_synced_info` 内部的 graph construction 拆成私有 helper,例如: + +- `build_peer_graph_from_synced_info(...)` 已存在则复用。 +- 新增 small struct 持有 `graph`、`start_node`、`version` 和后续 index rebuild 需要的 synced snapshot references。 + +不要改变 `gen_next_hop_map_with_least_hop` 或 `gen_next_hop_map_with_least_cost` 的算法。 + +**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。 + +### 步骤 3:让 `update_route_table` 对两种 policy 复用 graph + +把 `update_route_table` 改为在持有 `cost_calculator` read lock 时构建一次 graph/materialized input,然后分别对 `self.route_table` 和 `self.route_table_with_cost` 应用 least-hop / least-cost generation。 + +如果现有 `RouteTable::build_from_synced_info` 是唯一封装点,可以新增一个 sibling method,例如 `build_from_prebuilt_graph(...)`,保持旧方法用于兼容 tests 或其他调用方。 + +**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;characterization tests 仍通过。 + +### 步骤 4:避免重复构建共享 indexes + +检查 `build_from_synced_info` 中 peer info map、IPv4 map、CIDR tries 的生成是否依赖 policy-specific next-hop map。如果只依赖 reachability 或 synced info,可移动到共享 helper;如果依赖每个 `RouteTable` 自己的 `next_hop_map`,不要强行共享,避免改变 semantics。 + +允许分阶段收益:只共享 graph build 也可完成本计划;共享 CIDR/index 只有在 characterization tests 能证明 behavior 不变时才做。 + +**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。 + +### 步骤 5:运行完整相关门禁 + +**验证**: +- `cargo fmt --all -- --check` → exit 0。 +- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。 +- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。 + +## 测试计划 + +- 新增 route-table characterization tests,先在重构前证明现有行为,再在重构后保持通过。 +- 测试覆盖 least-hop、least-cost、CIDR lookup、unreachable peer exclusion。 +- 如果可行,加入一个轻量 counter/helper 在 test-only path 确认 graph builder 调用次数从 2 降为 1;如果这需要侵入生产代码,则不要做。 + +## 完成标准 + +- [ ] `update_route_table` 不再对同一 synced topology 构建两次 peer graph。 +- [ ] least-hop 和 least-cost route outputs 与 characterization tests 中的旧行为一致。 +- [ ] 没有改变 routing protocol、credential trust 或 config behavior。 +- [ ] `cargo fmt --all -- --check` exits 0。 +- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。 +- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。 +- [ ] 没有修改范围外文件。 +- [ ] 已更新 `plans/README.md` 中本计划的状态行。 + +## STOP 条件 + +- `plans/003-avoid-noop-ospf-route-rebuilds.md` 未完成,且当前 route rebuild trigger 仍会对 no-op payload 重算。 +- 复用 graph 需要改变 least-hop 或 least-cost algorithm。 +- 现有代码让 `cost_calc` 在两次 build 之间发生有意状态变化;如果确认 `begin_update`/`end_update` 依赖两次独立 build,停止并报告。 +- Characterization tests 无法稳定构造 route-table expected outputs。 + +## 维护说明 + +- reviewer 应重点审查是否在锁持有期间引入更长 critical section。 +- 未来新增 route policy 时应复用本计划抽出的 prebuilt graph input,而不是再调用完整 `build_from_synced_info`。 +- 本计划不拆分 `peer_ospf_route.rs` 大文件;只做局部性能重构。 diff --git a/plans/005-cover-socks5-exit-node.md b/plans/005-cover-socks5-exit-node.md new file mode 100644 index 00000000..094dd13f --- /dev/null +++ b/plans/005-cover-socks5-exit-node.md @@ -0,0 +1,164 @@ +# 计划 005:补齐 SOCKS5 exit-node 集成测试覆盖 + +> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。 +> +> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/tests/three_node.rs easytier/src/tests/mod.rs easytier/src/gateway easytier/src/vpn_portal easytier/src/peers` +> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。 + +## 状态 + +- **优先级**: P2 +- **工作量**: M +- **风险**: MED +- **依赖**: none +- **类别**: tests +- **计划生成于**: commit `78146d16`, 2026-06-18 + +## 为什么重要 + +测试文件顶部明确 TODO 指出需要覆盖 `socks5 + exit node == self || proxy_cidr == 0.0.0.0/0` 的出口节点能力。现有 `socks5_vpn_portal` 测试只覆盖固定 destination 和 `10.1.2.0/24` proxy CIDR,不能证明默认出口路由或 self-exit 场景。完成后,这条核心 VPN routing/use-case 会有 characterization test,后续修改 SOCKS5、proxy CIDR 或 exit-node 行为时不再盲改。 + +## 当前状态 + +- `easytier/src/tests/three_node.rs` — 三节点集成测试和 SOCKS5 portal 测试所在文件。 +- `easytier/src/gateway/socks5.rs`、`easytier/src/gateway/socks5/dataplane.rs` — SOCKS5 gateway implementation;仅在测试失败定位时阅读,默认不修改。 +- `easytier/src/peers/peer_ospf_route.rs` — proxy CIDR 和 route selection 行为;默认不修改。 + +当前代码摘录: + +```rust +// easytier/src/tests/three_node.rs:16 +// TODO: 需要加一个单测,确保 socks5 + exit node == self || proxy_cidr == 0.0.0.0/0 时,可以实现出口节点的能力。 +``` + +```rust +// easytier/src/tests/three_node.rs:1753 +pub async fn socks5_vpn_portal( + #[values("10.144.144.1", "10.144.144.3", "10.1.2.4")] dst_addr: &str, +) { + // ... + let _insts = init_three_node_ex( + "tcp", + |cfg| { + if cfg.get_inst_name() == "inst3" { + // 添加子网代理配置 + cfg.add_proxy_cidr("10.1.2.0/24".parse().unwrap(), None) + .unwrap(); + } + cfg + }, + false, + ) + .await; +} +``` + +仓库约定:这些网络集成测试使用 `#[tokio::test]` 和 `#[serial_test::serial]`,部分测试需要 Linux network namespace/root capabilities。保持测试 isolated and repeatable;不要让新增测试依赖外部网络。 + +## 需要使用的命令 + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Format | `cargo fmt --all -- --check` | exit 0 | +| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings | +| Targeted test | `cargo test --package easytier socks5_vpn_portal --features full -- --nocapture --test-threads 1` | exit 0; existing and new SOCKS5 tests pass | +| CI-style archive | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 | + +如果本地环境缺少 root/network namespace 能力,targeted test 可能失败。此时仍必须确保 compile/archive 通过,并在结果中明确记录环境缺口。 + +## 临时目录约定 + +- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。 +- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。 +- 不要把临时产物放进被修改仓库。 + +## 范围 + +**范围内**(只能修改这些文件): +- `easytier/src/tests/three_node.rs` +- `easytier/src/tests/mod.rs`(仅当需要注册 helper/module) + +**范围外**(即使看起来相关也不要触碰): +- Production SOCKS5/gateway/routing code。若测试暴露 bug,停止并报告;不要在本计划里修生产逻辑。 +- Any CI workflow, docs, GUI/Web/frontend。 +- Existing tests unrelated to SOCKS5 portal or exit-node behavior。 + +## Git 工作流 + +- Branch: `advisor/005-cover-socks5-exit-node` +- Commit message style follows existing conventional commits, for example `test: add tests` from `CONTRIBUTING.md`. +- Do NOT push or open a PR unless the operator instructed it. + +## 步骤 + +### 步骤 1:阅读现有 `socks5_vpn_portal` helper pattern + +在 `easytier/src/tests/three_node.rs` 中阅读完整 `socks5_vpn_portal` 测试,特别是如何启动三节点、如何启动 TCP listener、如何通过 `tokio_socks::tcp::socks5::Socks5Stream` 访问目标地址、如何 cleanup。 + +不要复制大量代码后分叉;优先抽取小 helper,例如 `run_socks5_tcp_echo_case(...)`,让现有测试和新增测试共享。 + +**验证**:`cargo fmt --all -- --check` → exit 0(如果尚未修改,仍应通过)。 + +### 步骤 2:新增 `0.0.0.0/0` proxy CIDR exit-node case + +新增一个 serial async test,命名建议 `socks5_vpn_portal_default_ipv4_exit_node`。测试应: + +- 使用 `init_three_node_ex` 创建三节点。 +- 让某个非客户端节点配置 `cfg.add_proxy_cidr("0.0.0.0/0".parse().unwrap(), None).unwrap()`。 +- 通过 SOCKS5 portal 访问一个由测试内部启动的 TCP echo server 地址。 +- 断言 payload round-trip 成功。 + +测试目标地址必须是本地/测试 namespace 可控地址,不允许依赖公网。 + +**验证**:`cargo test --package easytier socks5_vpn_portal_default_ipv4_exit_node --features full -- --nocapture --test-threads 1` → exit 0;若因权限环境失败,错误必须是环境相关,而非编译或断言失败。 + +### 步骤 3:新增 self-exit case 或明确不可测原因 + +根据 TODO 中的 `exit node == self`,新增第二个测试,命名建议 `socks5_vpn_portal_self_exit_node`。它应覆盖 SOCKS5 入口节点同时也是 exit node 的场景。 + +如果现有 config API 没有清晰方式表达 “exit node == self”,不要猜测配置。先搜索现有 tests 中 `exit_nodes`、`add_proxy_cidr`、`vpn_portal` 的用法;如果仍不明确,STOP 并报告需要 maintainer 确认配置语义。 + +**验证**:`cargo test --package easytier socks5_vpn_portal_self_exit_node --features full -- --nocapture --test-threads 1` → exit 0;或 STOP 报告不可测配置语义。 + +### 步骤 4:移除或更新 TODO + +如果两个场景都已覆盖,将 `three_node.rs:16` 的 TODO 删除或改成剩余未覆盖场景的精确 TODO。不要删除仍未覆盖的提醒。 + +**验证**:`cargo test --package easytier socks5_vpn_portal --features full -- --nocapture --test-threads 1` → exit 0;现有和新增 SOCKS5 portal tests 通过。 + +### 步骤 5:运行完整相关门禁 + +**验证**: +- `cargo fmt --all -- --check` → exit 0。 +- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。 +- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。 + +## 测试计划 + +- 新增 `socks5_vpn_portal_default_ipv4_exit_node`:覆盖 `proxy_cidr == 0.0.0.0/0`。 +- 新增 `socks5_vpn_portal_self_exit_node`:覆盖 SOCKS5 入口节点作为出口节点。 +- 复用现有 `socks5_vpn_portal` 的 TCP echo/payload pattern,保持 `#[serial_test::serial]`。 + +## 完成标准 + +- [ ] TODO 中提到的 `0.0.0.0/0` exit-node 场景有测试覆盖。 +- [ ] TODO 中提到的 self-exit 场景有测试覆盖,或计划按 STOP 条件阻塞并说明配置语义缺口。 +- [ ] 新测试不依赖公网服务。 +- [ ] `cargo fmt --all -- --check` exits 0。 +- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。 +- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。 +- [ ] 没有修改 production code 或范围外文件。 +- [ ] 已更新 `plans/README.md` 中本计划的状态行。 + +## STOP 条件 + +- 新增测试暴露 production bug:不要修生产代码,停止并报告 failing test、命令和错误摘要。 +- self-exit 的配置语义无法从现有代码/tests 中确认。 +- 测试只能通过访问公网验证;这不符合仓库测试隔离要求。 +- 为了让测试通过需要放宽 assertions 或增加 sleeps 超过现有测试风格。 + +## 维护说明 + +- reviewer 应重点审查测试是否真正走 SOCKS5 portal 和 exit-node route,而不是退化成本地直连。 +- 后续修改 proxy CIDR、exit-node、SOCKS5 dataplane 时,应运行本计划新增的 targeted tests。 +- 本计划只建立测试基线;如果发现 bug,应另写修复计划。 diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 00000000..f4341341 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,35 @@ +# 实施计划 + +由 improve skill 于 2026-06-18 生成,基于 commit `78146d16`。除非依赖关系另有要求,请按以下顺序执行。每个执行者在开始前必须完整阅读计划,遵守 STOP 条件,并在完成后更新自己的状态行。 + +## 执行顺序与状态 + +| Plan | 标题 | 优先级 | 工作量 | 依赖 | Status | +|------|------|--------|--------|------|--------| +| 001 | 将共享 metrics/throughput 计数改为线程安全实现 | P1 | M | — | DONE in worktree, not merged | +| 002 | 为 peer RPC/control packet 队列加入背压和过载行为 | P1 | M | 001 | DONE in worktree, not merged | +| 003 | 避免 OSPF 对 stale/no-op sync payload 重算路由 | P2 | S | — | DONE in worktree, not merged | +| 004 | 复用 OSPF route-table 构图以减少拓扑更新成本 | P2 | M | 003 | DONE in worktree, not merged | +| 005 | 补齐 SOCKS5 exit-node 集成测试覆盖 | P2 | M | — | DONE in worktree, not merged | + +状态值:TODO | IN PROGRESS | DONE | DONE in worktree, not merged | BLOCKED(附一行原因) | REJECTED(附一行理由,例如 finding 已独立修复或方案放弃) + +## Reconcile 2026-06-18 + +- 001: `/home/fanmi/tmp/easytier-exec-001`, branch `advisor/001-thread-safe-metrics-throughput`, commit `7b6e4dfe`; worktree clean; not contained in `main` at `78146d16`. +- 002: `/home/fanmi/tmp/easytier-exec-002`, branch `advisor/002-bound-peer-rpc-queues`, commit `34d2193d`; worktree clean; not contained in `main` at `78146d16`. +- 003: `/home/fanmi/tmp/easytier-exec-003`, branch `advisor/003-avoid-noop-ospf-route-rebuilds`, commit `1be77b51`; worktree clean; not contained in `main` at `78146d16`. +- 004: `/home/fanmi/tmp/easytier-exec-004`, branch `advisor/004-reuse-ospf-route-graph`, commit `325c2e5d`; worktree clean; not contained in `main` at `78146d16`. +- 005: `/home/fanmi/tmp/easytier-exec-005`, branch `advisor/005-cover-socks5-exit-node`, commit `2cb51b71`; worktree clean; not contained in `main` at `78146d16`. + +## 依赖说明 + +- 002 依赖 001,因为队列背压计划应暴露 queue depth/drop counters;这些 counters 应复用 001 中线程安全后的 metrics primitive,避免在新代码里继续扩散 `UnsafeCell` 模式。 +- 004 依赖 003,因为先抑制 no-op sync 的无效重算,再做共享构图重构,能让性能测试和行为变化更容易归因。 +- 005 独立执行,但如果未来要修改 SOCKS5、exit-node 或 `0.0.0.0/0` proxy CIDR 行为,应先落地 005 作为 characterization baseline。 + +## 已考虑并拒绝的发现 + +- `/api/v1/generate-config` 和 `/api/v1/parse-config` 是否应要求登录:证据显示 route layering 可能使其公开,但可能是产品意图;不属于本次“正确性和性能”范围,且应先补意图测试再判断。 +- OSPF 7k 行 god module 整体拆分:确认是技术债,但范围过大;应先执行 003、004 并增加 characterization tests 后再规划。 +- 前端测试/DX、依赖清理、安全 hardening:有价值,但用户本次只要求正确性和性能计划。