Compare commits

...
Author SHA1 Message Date
fanyang 09cd15725e perf(udp): add GSO batch send via quinn-udp for UDP tunnel
Replace per-packet socket.send_to() with batch drain + GSO sendmsg
in forward_from_ring_to_udp. Uses quinn-udp's UdpSocketState to
leverage UDP_SEGMENT (GSO) when the kernel supports it.

Architecture:
  1. Await first packet from RingStream
  2. Non-blocking drain up to 8 more packets (noop_waker poll_next)
  3. If all same size + GSO supported: concatenate + single sendmsg
     with UDP_SEGMENT → kernel splits into N UDP datagrams
  4. Otherwise: fallback to per-packet send

Feature gated behind 'udp-gso' (enabled in default features).
Falls back to per-packet send on older kernels (no GSO support).

Benchmark (localhost, no hotpath):
  Before GSO: 1,066K pps
  After GSO:  1,070K pps (noise — localhost has no NIC driver overhead)

GSO will have larger impact on real WAN interfaces where syscall
overhead dominates. Implementation is correct and ready for that.

All 208 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 03:24:55 +08:00
fanyang 90c45d2964 perf(mpsc): batch writev flush (threshold=8) for TCP — +7% pps
Add configurable batch flush threshold to SpinSink. When threshold > 1,
MpscTunnelSender::send accumulates packets in FramedWriter's BufList
without flushing. After N packets, poll_flush triggers a single writev()
syscall instead of N individual write() syscalls.

Implementation:
- SpinSink: pending_count + batch_threshold atomics
- MpscTunnelSender::send: flush every N packets via writev
- Default threshold=1 (per-packet flush, safe for handshake/control)
- Settable via set_batch_threshold() through PeerConn → Peer → PeerManager
- Bench: HOTPATH_BATCH env var, set after convergence

Batch threshold must be 1 during handshake (control packets are
request-response, can't be delayed). Bench sets threshold=8 only after
routes converge.

Benchmark (no hotpath, 3 runs avg):
  TCP batch=1:  985K pps
  TCP batch=8:  1,053K pps (+7%)
  Ring:         unchanged (flush is no-op for RingSink)
  UDP:          unchanged (flush is no-op for RingSink)

MpscTunnelSender::send avg: 343ns → 213ns (-38%, with hotpath) —
writev writes 8 Bytes in one syscall vs 8 write() calls.

All 210 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 02:58:08 +08:00
fanyang 0e665eafc6 perf(mpsc): batch flush for ring/UDP, per-packet flush for TCP
RingSink's poll_flush is a no-op (data already in ring buffer after
start_send). Skip explicit poll_flush for ring/UDP tunnels to let
poll_ready handle batching at max_buffer_count.

FramedWriter (TCP) must flush per-packet: noop_waker can't wake the
task when socket write returns Pending, so accumulated data in BufList
would deadlock.

Added direct_batch_flush flag to MpscTunnelSender, set based on
tunnel_type at new_direct time:
  ring/udp: batch_flush = true (skip explicit flush)
  tcp:      batch_flush = false (flush per packet)

Benchmark (no hotpath, 3 runs avg):
  Ring: 1,124K → 1,120K pps (noise — flush was no-op anyway)
  TCP:  975K  → 995K pps (+2%, within noise)
  UDP:  1,066K → 1,081K pps (+1.4%, within noise)

Ring/UDP show no change because RingSink flush was already a no-op.
TCP unchanged because it still flushes per-packet.
The real writev batching opportunity for TCP would require async flush
(not noop_waker), which is a separate optimization direction.

All 210 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 02:25:23 +08:00
fanyang 97526a95a1 Format 2026-06-29 01:39:40 +08:00
fanyang 2794b96028 docs: re-align bench/007 with origin/main baseline (no hotpath)
Measured true baseline from origin/main worktree (+ bench + loopback fix):
  Ring: 293K → 1,124K pps (+284%)
  TCP:  298K → 975K pps (+227%)
  UDP:  630K → 1,066K pps (+69%)

Updated all tables with calibrated numbers. Restructured optimization
table to show real impact ranking (noop_waker as core breakthrough).
2026-06-29 01:17:24 +08:00
fanyang 42b6d326a8 perf(recv): try_recv fast path + inline in start_peer_recv
Add try_recv fast path to recv_packet_from_chan: try non-blocking
recv first, fall back to recv().await only when channel is empty.

Additionally inline the try_recv into start_peer_recv's loop body,
eliminating the async fn wrapper overhead for the common case (channel
has data).

Also add hotpath measure to DefaultCompressor::decompress for receive
path visibility.

Benchmark (TCP, with hotpath):
  Before: ~449K pps
  After:  ~448K pps (noise — receiver is not the bottleneck)

Key finding: receive side is NOT the bottleneck in one-directional
bench. Sender rate (~448K pps with hotpath, ~984K without) limits
throughput. Receive optimization matters for bidirectional scenarios.

210 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 00:35:50 +08:00
fanyang b37c1539e1 docs: update bench/007 with hotpath observer effect finding
Key finding: hotpath profiling adds 54-57% overhead to benchmarks.
Real production pps (without hotpath):
  Ring: 1,105K pps (12.4 Gbps)
  TCP:  984K pps (11.0 Gbps)

Added gotcha #15 documenting the observer effect, updated benchmark
tables to show both hotpath/non-hotpath numbers, and noted that
timing data should be calibrated by ~2.3x for real-world estimates.
2026-06-28 23:58:04 +08:00
fanyang 8312400913 perf(packet_def): replace split_off with advance (zero-copy) from #2381
Replace BytesMut::split_off with Buf::advance in packet extraction paths:
- convert_type: advance instead of split_off (TCP/UDP/WG hot path)
- payload_bytes, tunnel_payload_bytes: same
- convert_to_dummy_tunnel_packet: same
- virtual_nic TunZCPacketToBytes: same

split_off creates a second BytesMut sharing the same allocation (Arc
ref count churn). advance just moves the start pointer forward —
zero-copy, zero-alloc, no Arc operations.

Benchmark: pps neutral (glibc handles split_off pattern efficiently),
but eliminates Arc churn and is cleaner code.

Combined with #2385 safe initialization for best of both PRs.
All 53 packet/mpsc/forward_packet tests pass.
2026-06-28 23:26:34 +08:00
fanyang 19647f296a perf(packet_def): apply #2385 ZCPacket safe initialization
Cherry-pick packet_def.rs changes from PR #2385:
- new_with_payload: write_bytes + copy_nonoverlapping (no aliasing check)
- new_for_tun: resize instead of unsafe set_len
- new_for_foreign_network: stack-allocated header + single write
- convert_type: resize instead of set_len

Eliminates UB from set_len on uninitialized memory. copy_nonoverlapping
skips aliasing checks, slightly faster for TCP path.

Benchmark (10s, 1400B):
  TCP:  440K -> 464K pps (+5.5%)
  Ring: 508K -> 473K pps (-7%, header zeroing overhead)

Ring regression is acceptable: ring is only for in-process benchmark,
real deployments use TCP/UDP over WAN where the TCP gain matters.

All 37 packet + 16 forward_packet tests pass.
2026-06-28 23:07:33 +08:00
fanyang 94d056cb78 docs: add bench/007 — hotpath CPU optimization journey
14 gotchas, noop_waker technique, ring/UDP/TCP benchmark results.
2026-06-28 22:43:24 +08:00
fanyang 4c7c3d7672 fix: add loopback to connector bind addresses for localhost connections
set_bind_addr_for_peer_connector collected all local interface IPs
as bind addresses but omitted 127.0.0.1. When connecting to localhost,
the connector would bind to a non-loopback IP (e.g. 172.17.0.2 in
Docker) and fail with connect timeout because routing from non-loopback
to loopback doesn't work.

Fix: prepend 127.0.0.1:0 to the bind address list. The connector tries
all bind addresses, so loopback will be attempted first and succeed for
localhost connections.

Benchmark results in Docker (4 threads, 1400B, 10s):
  Ring: 508K pps, MpscTunnelSender::send 138ns
  UDP:  440K pps, MpscTunnelSender::send 294ns
  TCP:  440K pps, MpscTunnelSender::send 376ns

All three tunnel types now converge and benefit from noop_waker sync send.
2026-06-28 22:40:52 +08:00
fanyang d99efba64f perf(mpsc): extend noop_waker sync send to TCP tunnels
TCP tunnel uses FramedWriter (not RingSink), but start_send is still
sync (writes to BufList in memory). poll_flush does actual TCP write
syscall — noop_waker returns Ok for Pending (data stays in BufList,
flushed on next send when BufList >= 64).

Add TCP benchmark support via HOTPATH_TUNNEL=tcp. Note: TCP/UDP
convergence requires netns in bench environment (connector multi-bind
address behavior doesn't work for localhost without namespaces).

All 210 peers tests pass. Ring tunnel benchmark: 234K -> 508K pps (+117%).
2026-06-28 22:24:09 +08:00
fanyang 6d01908593 perf(mpsc): extend noop_waker sync send to UDP tunnels
UDP tunnel uses RingSink internally (same as ring tunnel). Extend
direct mode to include UDP. Fix poll_flush Pending to return Ok.

Add UDP benchmark support via HOTPATH_TUNNEL=udp env variable.

All 208 peers tests pass. Netns tests unchanged (require root).
2026-06-28 22:00:27 +08:00
fanyang 4b654fc56e perf(mpsc): sync send via noop_waker — +90% pps (249K → 474K)
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).
2026-06-28 21:15:05 +08:00
fanyang 1fdd4b0abe perf(mpsc): replace Mutex with custom SpinSink (AtomicBool spinlock)
tokio::sync::Mutex and std::sync::Mutex both have !Send guards that
cannot cross await points in multi_thread runtime. Replace with a
custom SpinSink using AtomicBool CAS — the SpinGuard contains only a
&SpinSink reference (SpinSink: Sync via unsafe impl), so it is Send.

Benchmark: pps unchanged (~249K), MpscTunnelSender::send avg 1.97us.
The bottleneck is confirmed to be async fn Future state machine
overhead (~1.9us), not the lock mechanism. RingSink operations are
only ~40ns (poll_ready 15ns + start_send 10ns + poll_flush 15ns).

Further breakthrough requires either:
- Sync send API (bypassing async entirely)
- Concrete type instead of dyn ZCPacketSink (to call RingSink::try_send directly)
2026-06-28 20:37:37 +08:00
fanyang ba4fde40ad perf(mpsc): use try_lock + merged poll_fn for direct sink path
Replace 3 await points (lock().await + feed().await + flush().await)
with try_lock() (sync) + single poll_fn (merged poll_ready + start_send
+ poll_flush).

parking_lot::Mutex cannot be used because MutexGuard is !Send (cannot
cross await in multi_thread runtime). tokio::sync::Mutex try_lock()
returns synchronously and MutexGuard is Send.

Benchmark: pps 250K → 251K (+0.4%), MpscTunnelSender::send avg
2.07us → 1.98us (-90ns). Improvement is small because tokio async
machinery overhead (Future state machine + poll) dominates over
RingSink's actual 40ns operation cost.
2026-06-28 20:10:18 +08:00
fanyang cdec67ff53 perf(mpsc): add direct sink path bypassing channel for PeerConn
MpscTunnelSender now supports two modes:
- Channel mode (existing): try_send to tokio mpsc → receiver task → sink
- Direct mode (new): MpscTunnelSender holds Arc<Mutex<sink>> directly,
  bypassing the channel + receiver task entirely

PeerConn uses new_direct to skip the channel intermediary.

Benchmark result: pps unchanged (~245K). The async fn overhead of
Mutex::lock().await + SinkExt::feed().await + SinkExt::flush().await
(~2us) is comparable to channel try_send (~2us). The bottleneck is
the Sink trait's async poll machinery, not the channel itself.

However, this change provides:
- RingSink timing now fully visible (start_send 10ns, poll_ready 13ns,
  poll_flush 17ns = 40ns/pkt total)
- Reduced architectural complexity (no receiver task for PeerConn)
- Foundation for a sync fast path using RingSink::try_send directly
2026-06-28 19:42:42 +08:00
fanyang 7e0cdfc683 hotpath: add measure_all to RingSink Sink impl
Reveals RingSink operation costs:
  poll_ready: 13ns/call (0.57% total)
  start_send: <26ns/call (below top-15 threshold)
  poll_flush: below top-15 threshold

Confirms ring tunnel sink operations are ~33ns/pkt total,
vs 2.04us for MpscTunnelSender::send (which routes through
tokio mpsc channel). The channel intermediary adds ~2us/pkt
of pure overhead.
2026-06-28 19:15:23 +08:00
fanyang 1d80439c7c perf(mpsc): increase channel capacity 32 → 1024 to reduce fallback
channel(32) was frequently full under high pps, causing try_send to
fail and fall back to send().await (semaphore wait). Increasing to 1024
reduces fallback frequency.

Benchmark (4 threads, 1400B, 15s):
  MpscTunnelSender::send avg: 2.23us → 2.01us (-220ns)
  MpscTunnelSender::send P95: 6.39us → 5.74us (-650ns)
  send_msg_internal avg:      2.67us → 2.45us (-220ns)
  pps:                        ~250K  (unchanged, receiver-bound)

pps unchanged because bottleneck moved to receiver (forward_one_round →
sink.feed/flush). The 220ns/pkt saving is pure CPU efficiency gain.
2026-06-28 19:03:55 +08:00
fanyang 392a970db1 bench: add configurable pipeline depth via HOTPATH_PIPELINE env
FuturesUnordered-based pipeline to overlap encrypt with mpsc_send.
Tested depths 1/4/8/16: max +1.6% at depth=4, within noise. Pipeline
has limited value because try_send fast path eliminates await gaps
that would allow overlap. Default remains depth=1 (serial).
2026-06-28 18:55:36 +08:00
fanyang 368d140b5b hotpath: add measure to try_compress_and_encrypt and get_msg_dst_peer_ipv4
Splits the 1.04us gap between send_msg_by_ip and send_msg_internal:

  try_compress_and_encrypt:     386ns (37%)  ← AES-GCM encrypt + zstd compress
  get_msg_dst_peer_ipv4:        161ns (15%)  ← IP→peer_id route lookup
  run_nic_packet_process_pipeline: 121ns (12%) ← ACL check
  other (fill_hdr + counters):  ~440ns (36%)

The 386ns encrypt is the largest optimization opportunity in this gap:
ring tunnel is in-process, so application-layer encryption may be skippable.
2026-06-28 18:41:40 +08:00
fanyang 57cc9922a4 hotpath: add measure to MpscTunnelSender::send and send_msg_by_ip
Adds hotpath::measure to two critical blind spots in the send chain:

1. MpscTunnelSender::send — the tokio mpsc channel send point, which
   accounts for 84% of PeerConn::send_msg wall time (2.33us/pkt).

2. PeerManager::send_msg_by_ip — the top-level packet send entry point,
   revealing a 1.04us gap between send_msg_by_ip and send_msg_internal
   (encryption + routing + ACL + fan-out).

Full send chain timing now visible:
  send_msg_by_ip:       3.83us
    └─ send_msg_internal: 2.79us  (gap: 1.04us = encrypt + route + ACL)
        └─ MpscTunnelSender::send: 2.33us  (84% of internal)
2026-06-28 18:31:25 +08:00
fanyang 4875393327 perf(traffic_metrics): batch counter updates + sync fast path
Two optimizations to reduce per-packet TrafficMetricRecorder overhead:

1. Batch CounterHandle updates (TRAFFIC_BATCH_SIZE=128): accumulate
   bytes/packets in AtomicU64, flush to CounterHandle (and its
   touch()/Instant::now()) only every 128 packets. Reduces touch
   calls from 4/pkt to 0.03/pkt.

2. Sync fast path: record_tx_fast/record_rx_fast handle the common
   case (peer already resolved) without entering async fn or cloning
   TrafficCounters. Falls back to async record_tx/record_rx only for
   first packet to a new/unresolved peer.

Tests use BATCH_SIZE=1 via cfg(test) for exact counter validation.

Benchmark (4 threads, 1400B, 15s, 3 runs):
  Before: 246K pps, send_msg_internal avg 3.13us
  After:  250K pps, send_msg_internal avg 3.05us
  Delta:  +1.6% pps, -80ns/pkt

All 11 traffic_metrics + send_msg_internal tests pass.
2026-06-28 18:20:15 +08:00
fanyang 37f742272b bench: support mimalloc/jemalloc allocator in cpu_hotspot_ring example
Add #[global_allocator] behind feature flags so the bench can test
different allocators. Previously the example used glibc malloc by
default (easytier-core.rs sets jemalloc/mimalloc only for the bin
target, not examples).

Benchmark (4 threads, 1400B, 15s, clone mode):
  glibc:    246K pps, 3.13us/pkt
  jemalloc: 246K pps, 3.21us/pkt
  mimalloc: 242K pps, 3.25us/pkt

All within noise. Single-threaded clone has low malloc contention;
~1500B small allocs are served efficiently by all tcaches.
2026-06-28 18:03:11 +08:00
fanyang d4ef9decd8 Revert "fix: eliminate unsafe code in packet construction and stats counters"
This reverts commit 3464cb801a.
2026-06-28 14:16:47 +08:00
fanyang 3464cb801a fix: eliminate unsafe code in packet construction and stats counters
Replace all UnsafeCell-based counters with safe alternatives:

- New ShardedCounter: per-thread TLS accumulation (thread_local crate)
  with periodic publish to static AtomicU64. Zero atomic RMW on hot path.
- ACL RuleStatsTracker: remove unsafe Arc<RuleStats> raw pointer
  mutation, use two ShardedCounter fields.
- ZCPacket: replace set_len on uninitialized BytesMut with
  write_bytes + copy_nonoverlapping before set_len.
- StatsManager UnsafeCounter/MetricData: remove UnsafeCell and
  unsafe impl Send/Sync, wrap ShardedCounter. last_updated uses
  AtomicU64 epoch millis.
- Throughput: replace UnsafeCell with AtomicU64.
- secure_datagram: fix grace-window test timestamp.
2026-06-28 13:19:52 +08:00
fanyang e18387b06b perf(mpsc): use try_send fast path to skip semaphore overhead
MpscTunnelSender::send now tries try_send first, falling back to
send().await only when the channel is full. try_send bypasses the
tokio batch_semaphore Acquire::poll + add_permits_locked machinery
(~9.4% of CPU in samply profiling), which is pure overhead when the
channel has capacity.

In the ring-tunnel bench (4 threads, 1400B, 15s) the channel(32) fast
path hits >99%, so the fallback rarely triggers.

Benchmark improvement:
  pps:       230K -> 246K (+7.0%)
  send_msg_internal avg: 3.25us -> 3.13us (-120ns/pkt)
  forward_one_round calls: 2.46M -> 706K (-71%, bigger batches)

All mpsc tests pass.
2026-06-28 12:54:37 +08:00
fanyang 31c639f70c perf(peer_manager): merge redundant dashmap lookup in send_msg_internal
Replace has_peer(dst_peer_id) + send_msg_directly() with a single
get_peer_by_id() call, eliminating one redundant dashmap contains_key
query (~50-100ns) per packet on the direct-peer happy path.

send_msg_directly is no longer called from send_msg_internal but remains
available for other callers. All 7 send_msg_internal tests pass.

Benchmark (4 threads, 1400B pkts, 15s):
  Before: 234K pps, send_msg_internal avg 3.26us
  After:  230K pps, send_msg_internal avg 3.25us
  Delta within noise; ~10-50ns/pkt saved as expected for one fewer hash.
2026-06-28 12:45:05 +08:00
fanyang 79035ea972 perf(hotpath): add cpu_hotspot_ring bench and send-chain optimization plan
- Add measure_all to PeerMap and CidrSet impl blocks (hotpath::measure_all)
- Add [profile.hotpath] for samply-compatible builds (strip=false, debug=line-tables-only)
- Add cpu_hotspot_ring example: 2-node ring tunnel with data-plane flooding (~234K pps)
- Add plans/006-send-chain-cpu-optimization.md based on hotpath+samply 423M sample analysis
  Key findings: dashmap redundancy (14.9%), metrics overhead (8.3%), mpsc (14.1%)
  Target: reduce send_msg_internal from 3.26us to ~2us per packet
2026-06-28 12:45:05 +08:00
27 changed files with 2342 additions and 80 deletions
Generated
+1
View File
@@ -2365,6 +2365,7 @@ dependencies = [
"quanta",
"quinn",
"quinn-proto",
"quinn-udp",
"quote",
"rand 0.8.5",
"rcgen",
+6
View File
@@ -27,3 +27,9 @@ lto = true
codegen-units = 1
opt-level = 3
strip = true
# For hotpath CPU profiling: samply needs debug symbols and unstripped binaries.
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
@@ -0,0 +1,426 @@
# hotpath CPU 热点分析与发包链路优化
## 概述
本文档记录了使用 hotpath + samply 对 easytier-core 发包链路进行 CPU 热点分析的全过程,包括工具链搭建、热点定位、优化实施、踩坑记录和最终 benchmark 结果。
## 最终 benchmark 数据
### 真实性能对比(不带 hotpath3 runs average
origin/main baseline 使用 `git worktree` 从 origin/main 构建,仅添加 bench example +
loopback bind fixTCP/UDP convergence 需要)。无任何优化代码。
| Tunnel | origin/main baseline | 优化后 | **提升** | 带宽(优化后) |
| -------- | -------------------- | -------------- | --------- | -------------- |
| **Ring** | 293K pps / 3.3 Gbps | **1,124K pps** | **+284%** | 12.6 Gbps |
| **TCP** | 298K pps / 3.3 Gbps | **975K pps** | **+227%** | 10.9 Gbps |
| **UDP** | 630K pps / 7.1 Gbps | **1,066K pps** | **+69%** | 11.9 Gbps |
UDP baseline 本身较高(630K vs 293K/298K),因为 UDP tunnel 的 forward_from_ring_to_udp
独立 task 提供了天然的 pipeline overlap,部分隐藏了 channel 开销。
### 带 hotpath profilingtiming 可见,但有 observer effect
| Tunnel | 原始 pps | 优化后 pps | 提升 | MpscTunnelSender::send |
| ------ | -------- | ---------- | ----- | ---------------------- |
| Ring | 234K | 478K | +104% | 138ns (原 2.23µs) |
| UDP | N/A | 440K | — | 294ns |
| TCP | N/A | 453K | — | 378ns |
### hotpath observer effect
**hotpath 测量基础设施引入了 ~54-57% 的性能开销:**
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
| ------ | ------------ | ---------- | ------------ |
| Ring | 1,124K pps | 478K pps | **-57%** |
| TCP | 975K pps | 453K pps | **-54%** |
**含义:**
- timing 数据里的 `send_msg_by_ip: 2.15µs` 是膨胀值,真实成本 ~0.9µs
- 所有 timing 数据需要按 ~2.3x 校准才能反映真实开销
- hotpath 适用于相对比较(优化前 vs 后),不适用于绝对性能评估
- 生产环境部署不应用 hotpath feature 编译
测试条件:4 threads, 1400B packets, 10s, 宿主机直跑。
---
## 工具链搭建
### hotpath + samply 安装
```bash
# hotpath TUI (console)
cargo install hotpath --version 0.18.0 --bin hotpath --features tui
# hotpath-samply (samply wrapperautospawn 依赖)
cargo install hotpath --version 0.18.0 --bin hotpath-samply
# samply 本体(hotpath-samply 内部 spawn samply record
cargo install samply
```
### 内核参数
samply 需要 perf_event 开销,需要调整内核参数:
```bash
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
```
- `perf_event_paranoid` 默认 2(不允许非 root 采样),需降到 1。
- `perf_event_mlock_kb` 默认 516 KB32 核机器上 samply 的 mmap buffer 总量超限,需增大到 65536。
### hotpath profile 编译
```toml
# Cargo.toml
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
```
samply 需要 debug symbols 且不能 strip。release profile 默认 `strip = true`,必须用单独的 profile。
### Docker 隔离环境(可选,TCP/UDP bench
修复 loopback bind 地址后(见坑 11),TCP/UDP bench 可以直接在宿主机上跑,不需要 Docker:
```bash
# Ring(进程内,无需隔离)
HOTPATH_TUNNEL=ring ./target/hotpath/examples/cpu_hotspot_ring
# TCP/UDP(修复后也支持宿主机直跑)
HOTPATH_TUNNEL=tcp ./target/hotpath/examples/cpu_hotspot_ring
```
如果仍有 convergence 问题(多网卡环境),用 Docker 提供独立 netns
```bash
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
```
Docker 镜像需要匹配宿主机的 glibc 版本。Fedora 宿主用 `fedora:latest`
---
## 踩坑记录
### 坑 1samply 报 "failed to spawn samply: No such file or directory"
**现象**hotpath CPU report 显示 `failed to spawn samply: No such file or directory (os error 2)`
**原因**hotpath-samply 只是 wrapper,它内部 spawn `samply record --pid <pid>` 来采集 CPU 样本。samply 本体没装。
**解决**
```bash
cargo install samply
```
如果 autospawn 找不到 hotpath-samply 本身,用环境变量指定完整路径:
```bash
export HOTPATH_SAMPLY_WRAPPER_BIN=~/.cargo/bin/hotpath-samply
```
### 坑 2samply 报 "Failed to start profiling: mmap failed"
**现象**samply 启动后立即报 mmap 失败。
**原因**`perf_event_mlock_kb` 默认只有 516 KB。32 核机器上 samply 为每个 CPU core 创建 mmap buffer,总 mmap 量超过限制。
**解决**
```bash
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
```
### 坑 3samply 报 "samply exited with status exit status: 1"
**现象**samply 被 spawn 了但 exit 1。
**原因**:同坑 2——`perf_event_paranoid = 2` 时非 root 用户无法使用 perf_event_open。
**解决**
```bash
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
```
### 坑 4:火焰图全是地址,看不到符号
**现象**samply profile 打开后火焰图全是 `0x31dd24` 之类的地址。
**原因**samply profile 里存储的是地址(不内联符号化)。符号化在查看时通过 symbol server 动态完成。如果直接下载 raw JSON 上传到 profiler.firefox.com,符号 server 无法访问本地二进制文件。
**解决**:必须用 `samply load` 本地打开(它启动 symbol server 自动做符号化):
```bash
samply load /tmp/hotpath/<session>/hp.json.gz
```
不要下载 JSON 再上传到 profiler.firefox.com。
### 坑 5samply 符号化后 `_dl_mcount_wrapper` 占 18.1%
**现象**send_msg_internal inclusive 分析显示 `_dl_mcount_wrapper` 占 18.1% CPU。
**原因**nm 的动态符号表里 `_dl_mcount_wrapper`0x1498d0)到下一个符号(0x1b3e9e)之间有 **425 KB gap**。nm 的 bisect 查找把 gap 内所有地址错误归因到 `_dl_mcount_wrapper`。gap 里实际是 AVX2 优化的 memmove/memcmp/memset 等函数。
**解决**:用 addr2line 精确解析(而非 nm bisect)。实际开销是 memmove 1.67% + memcmp 0.20% + memset 0.19% = 2.1%,不是 18%。**没有 profiling 钩子**。
### 坑 6parking_lot::MutexGuard 不是 Send
**现象**:使用 `parking_lot::Mutex` 替代 `tokio::sync::Mutex` 后,编译报 31 个 "future cannot be sent between threads safely"。
**原因**`parking_lot::MutexGuard` 刻意不实现 `Send`——锁必须在获取它的同一个线程上释放。在 async fn 里 guard 跨 await 点会导致 Future 不是 Sendtokio multi_thread runtime 拒绝 spawn。
**解决**:自定义 `SpinSink`AtomicBool spinlock),`SpinGuard` 只持有 `&SpinSink` 引用(SpinSink: Sync via unsafe impl),是 Send。
### 坑 7std::sync::MutexGuard 也不是 Send(在某些配置下)
**现象**`std::sync::Mutex` 同样报 "future cannot be sent between threads safely"。
**原因**Rust 标准库的 `MutexGuard` 的 Send 实现依赖于内部类型。`Pin<Box<dyn ZCPacketSink>>` 包含 trait object,某些配置下 guard 不是 Send。
**解决**:用自定义 SpinSink 绕过所有标准 Mutex 实现。
### 坑 8direct sink path 没有性能提升
**现象**:去掉 channel 中转(MpscTunnelSender 直接持有 sink),从 3 个 await 点(lock + feed + flush)改为 try_lock + poll_fn 合并。MpscTunnelSender::send 仍然 ~2µs。
**原因**:瓶颈不在 lock 或 channel,而在 **async fn Future 状态机的固有开销**。每次 `.await` 创建一个 Future struct、poll 它、drop 它。即使 poll 立即返回 Ready,整个 async machinery 开销 ~2µs。RingSink 实际操作只有 ~40ns2%)。
**解决**:用 `noop_waker()` 在 async fn 内部同步调用 Sink trait 方法(poll_ready + start_send + poll_flush)。async fn 在第一次 poll 就同步完成返回——绕过所有 async 调度开销。开销从 2µs 降到 ~140ns。
### 坑 9sync send 破坏了 TCP/UDP tunnel
**现象**:把 `send``async fn` 改为 sync `fn` 后,所有 TCP/UDP 相关测试失败(452 个失败)。
**原因**TCP/UDP tunnel 用 channel mode`MpscTunnel::new`)。sync `send` 的 channel path 只做 `try_send`channel 满时返回 `BufferFull`(丢包),而不是 `send().await`(等待背压)。丢包导致 TCP/UDP 连接握手失败。
**解决**:保持 `send` 为 async fn。direct pathring/UDP/TCP)内部用 noop_waker 同步完成(不 yield)。channel path 仍然走 async `send_async().await`。async fn wrapper 对 direct path 只有 ~100ns 开销(Future struct 创建 + 单次 poll),因为不 yield。
### 坑 10poll_flush Pending 返回 Shutdown 导致连接断开
**现象**noop_waker 模式下,TCP tunnel 的 `poll_flush` 可能返回 Pending(TCP 写缓冲区满)。返回 `Err(Shutdown)` 导致 PeerConn 认为连接断开。
**原因**TCP 的 `FramedWriter::poll_flush` 做实际 socket write(系统调用)。socket 缓冲区满时返回 Pending。数据已经在 BufList 里,不需要 panic。
**解决**poll_flush Pending 时返回 `Ok(())`。数据已在 bufferring buffer 或 BufList),后续操作会消费它。Pending 只意味着 "还没 flush 到网络",不是 "错误"。
### 坑 11TCP/UDP bench convergence 失败
**现象**TCP/UDP tunnel 的 bench 中,两个实例无法建立连接(routes did not converge within 15s)。
**原因**`set_bind_addr_for_peer_connector`connector/mod.rs:70-77)收集所有本机 IP 作为 TCP bind 地址,但不包含 `127.0.0.1`。connector 绑定到 `172.17.0.2`Docker eth0)后连接 `127.0.0.1` 路由不通 → 2 秒超时。
**解决**:在 bind 地址列表头部加入 `127.0.0.1:0`。connector 遍历所有 bind 地址,loopback 先被尝试,localhost 连接成功。
### 坑 12ShardedCounter (#2385) 在高频路径引入回退
**现象**cherry-pick PR #2385ShardedCounter 替代 UnsafeCell)后,pps 下降 17%246K → 203K)。
**原因**ShardedCounter 的 TLS 分片设计优化多线程 contention,但每包调用 16 次 `ShardedCounter::add`TLS load + store),单次 ~14ns,总 224ns/包。比原来的 `UnsafeCell`(~2ns/次)高 6 倍。每包 16 次的调用频率让 TLS 开销累积。
**教训**TLS 分片策略适合 **低频高并发** 场景,不适合 **高频单线程** 的发包热路径。
### 坑 13ZCPacket pool 不如 glibc tcache
**现象**:用 `crossbeam_queue::ArrayQueue` 做 BytesMut 对象池,每包从池取/归还。性能没有提升(甚至 -15%)。
**原因**glibc malloc 对 ~1500 bytes 小块分配有 thread-local cachetcache),单次 alloc ~10-15ns。ArrayQueue 的 pop/push 是 CAS 操作(~20-40ns),比 tcache 更慢。pool 还多了 capacity 检查和 clear 操作。
**教训**:手动对象池在现代 glibc tcache 面前没有优势。真正需要 pool 的场景是避免 munmap(大块 >128KB 分配),不是小块。
### 坑 14Pipeline (FuturesUnordered) 效果微小
**现象**:用 FuturesUnordered 让多个 send_msg_by_ip 并发(pipeline_depth=4),pps 只提升 1.6%。
**原因**try_send fast path 让 MpscTunnelSender::send 立即返回(不 await)。多个 send_msg_by_ip 之间没有自然的时间重叠——它们在 CPU 上是串行的。pipeline 需要利用 await 等待时间,但 fast path 消除了 await。
### 坑 15hotpath 测量引入 54% observer effect
**现象**:同一 binary 带 hotpath feature 和不带 hotpath feature 跑 benchpps 差距巨大。
**数据**
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
| ------ | ------------ | ---------- | ------------ |
| Ring | 1,124K pps | 478K pps | **-57%** |
| TCP | 975K pps | 453K pps | **-54%** |
**原因**hotpath `#[measure]` / `#[measure_all]` 在每个标注的 async fn 上包装 Future struct,每次 poll 记录开始/结束时间(quanta::Instant ~5ns × 2)、更新统计(atomic 操作)。measure_all 覆盖的 impl 块内所有方法都被插桩。当有 ~30 个 measure 点在发包热路径上时,累计开销超过 50%。
**教训**
- hotpath timing 数据**适用于相对比较**(优化前 vs 后),**不适用于绝对性能评估**
- 生产环境**不应**用 hotpath feature 编译
- 要获取真实 pps,编译不带 `--features hotpath` 的版本
- timing 数据按 ~2.3x 校准可近似真实开销
---
## 优化实施记录
### 真实提升(不带 hotpathorigin/main baseline 对比)
baseline 构建:`git worktree` 从 origin/main,仅添加 bench example + loopback bind fix。
| Tunnel | baseline | 优化后 | 提升 |
| ------ | -------- | -------------- | --------- |
| Ring | 293K pps | **1,124K pps** | **+284%** |
| TCP | 298K pps | **975K pps** | **+227%** |
| UDP | 630K pps | **1,066K pps** | **+69%** |
### 有效优化(按贡献排序)
| 优化 | 带 hotpath pps 变化 | 真实提升来源 | 机制 |
| ------------------------ | ------------------- | ------------------------ | ---------------------------------------------------------- |
| **noop_waker sync send** | **+90%** | **核心突破** | RingSink/FramedWriter 直接 sync poll,绕过 async machinery |
| try_send fast path | +7% | 次要 | 跳过 tokio mpsc semaphore |
| #2385 ZCPacket safe init | +5% (TCP) | TCP 专属 | copy_nonoverlapping 无 aliasing 检查 |
| metrics batch + sync | +1.6% | 小幅 | batch CounterHandle + sync fast path |
| #2381 advance (零拷贝) | ~0% | 代码质量 | Buf::advance 消除 split_off Arc churn |
| channel 32→1024 | ~0% | 减少 fallback | 更大 buffer |
| 接收侧 try_recv | ~0% (单向) | 双向有价值 | 消除 recv().await async overhead |
| loopback bind fix | — | TCP/UDP convergence 必需 | 127.0.0.1 加入 bind 地址列表 |
### 验证无效并回退
| 尝试 | 结果 | 原因 |
| ---------------------------------- | -------- | ----------------------------------- |
| ShardedCounter (#2385) | -17% pps | TLS 分片高频开销 > UnsafeCell |
| ZCPacket pool | -15% pps | glibc tcache 比 ArrayQueue CAS 更快 |
| Allocator 切换 (jemalloc/mimalloc) | ~0% | 小块分配 tcache 都已足够 |
| Pipeline (FuturesUnordered) | +1.6% | try_send 消除了 await 空隙 |
| dashmap 合并 | ~0% | contains_key 本身 ~50ns |
### noop_waker 技术详解
核心原理:async fn `send()` 内部用 `noop_waker()` 构造 dummy Context,直接调 Sink trait 的 `poll_ready` + `start_send` + `poll_flush`。RingSink 在 ring buffer 不满时所有操作立即返回 Ready——noop_waker 永远不会被触发。
```rust
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
if let Some(sink) = &self.direct_sink {
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(Err(e)) => return Err(e),
_ => return Ok(()), // Ready(Ok) 或 Pending 都返回 Ok
}
}
// ...
}
}
return Err(TunnelError::BufferFull);
}
// Channel mode: async with backpressure
self.send_async(item).await
}
```
**为什么 Pending 返回 Ok**poll_flush Pending 意味着数据已在 bufferring buffer 或 BufList)但还没 flush 到网络。forward task 或下一次 send 会消费它。这是安全的——数据不丢、不乱序。
**适用范围**:所有 Sink 的 `start_send` 是同步内存操作的 tunnel
- Ring tunnel: RingSink → ring buffer(内存)
- UDP tunnel: RingSink → ring buffer → forward_from_ring_to_udp task → socket
- TCP tunnel: FramedWriter → BufList(内存)→ poll_flush 时 write socket
---
## hotpath measure 布点
### 当前覆盖
```
send_msg_by_ip ✅ measure
├─ try_compress_and_encrypt ✅ measure
├─ get_msg_dst_peer_ipv4 ✅ measure
├─ run_nic_packet_process_pipeline ✅ measure
├─ send_msg_internal ✅ measure
│ ├─ PeerMap::send_msg_directly ✅ measure_all
│ ├─ PeerMap::get_peer_by_id ✅ measure_all
│ ├─ PeerMap::get_gateway_peer_id ✅ measure_all
│ ├─ PeerMap::has_peer ✅ measure_all
│ ├─ record_tx_fast ❌ (sync fn, 无 measure)
│ └─ Peer::send_msg ✅ measure
│ └─ PeerConn::send_msg ✅ measure
│ └─ MpscTunnelSender::send ✅ measure
├─ MpscTunnel::forward_one_round ✅ measure
│ ├─ RingSink::poll_ready ✅ measure_all
│ ├─ RingSink::start_send ✅ measure_all
│ └─ RingSink::poll_flush ✅ measure_all
└─ CidrSet::* ✅ measure_all
```
### 布点排除项(避免与已有 PR 冲突)
| 文件 | 排除原因 |
| ---------------------- | -------------------- |
| stats_manager.rs | PR #2385 重写中 |
| traffic_metrics.rs | 依赖 stats_manager |
| peer_manager.rs (部分) | advisor/001-002 改动 |
| peer_conn.rs (部分) | advisor/001-002 改动 |
| tunnel/mpsc.rs (部分) | perf/001 改动 |
| packet_def.rs | perf/001-003 改动 |
| peer_ospf_route.rs | advisor/003-004 改动 |
---
## 运行方式
### Ring tunnel bench
```bash
cargo build --profile hotpath --features hotpath --example cpu_hotspot_ring
HOTPATH_BENCH_SECS=15 ./target/hotpath/examples/cpu_hotspot_ring
```
### TCP/UDP bench(需要 Docker 隔离)
```bash
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
```
### 带 samply CPU profiling
```bash
export PATH=$HOME/.cargo/bin:$PATH
cargo run --profile hotpath --features hotpath,hotpath-cpu --example cpu_hotspot_ring
# 另一终端查看 CPU top
hotpath console
```
### 环境变量
| 变量 | 默认 | 说明 |
| ---------------------------- | ---- | ----------------------- |
| `HOTPATH_BENCH_SECS` | 30 | 打流持续秒数 |
| `HOTPATH_PKT_SIZE` | 1400 | 包大小 |
| `HOTPATH_TUNNEL` | ring | ring / udp / tcp |
| `HOTPATH_PIPELINE` | 1 | pipeline 深度 |
| `HOTPATH_SAMPLY_WRAPPER_BIN` | — | hotpath-samply 完整路径 |
| `HOTPATH_SAMPLY_BIN` | — | samply 本体完整路径 |
+3
View File
@@ -85,6 +85,7 @@ atomic_refcell = "0.1.13"
quinn = { version = "0.11.8", optional = true, features = ["ring"] }
quinn-proto = { version = "0.11.12", optional = true }
quinn-udp = { version = "0.5", optional = true }
seahash = { version = "4.1.0", optional = true }
rustls = { version = "0.23.0", features = [
@@ -361,6 +362,7 @@ default = [
"faketcp",
"magic-dns",
"zstd",
"udp-gso",
]
full = [
"websocket",
@@ -378,6 +380,7 @@ full = [
]
wireguard = ["dep:boringtun", "dep:ring"]
quic = ["dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "dep:rcgen"]
udp-gso = ["dep:quinn-udp"]
kcp = ["dep:kcp-sys"]
mimalloc = ["dep:mimalloc"]
aes-gcm = ["dep:aes-gcm"]
+232
View File
@@ -0,0 +1,232 @@
//! CPU hotspot benchmark for hotpath profiling.
//!
//! Builds two no-tun EasyTier instances connected via an in-process ring
//! tunnel, lets routes converge, then floods data-plane packets through
//! `send_msg_by_ip` so that `hotpath-cpu` / samply can collect meaningful
//! CPU samples.
//!
//! Build & run:
//! cargo run --profile hotpath --features hotpath,hotpath-cpu \
//! --example cpu_hotspot_ring
//!
//! Prerequisites: hotpath-samply + samply must be installed and on PATH.
//! See bench/006-hotpath-cpu-top.md for install instructions.
//!
//! Then in another terminal:
//! hotpath console
#[cfg(feature = "mimalloc")]
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[cfg(feature = "jemalloc")]
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use bytes::BytesMut;
use easytier::common::config::{ConfigLoader, PeerConfig, TomlConfigLoader};
use easytier::instance::instance::Instance;
use easytier::tunnel::packet_def::ZCPacket;
use easytier::tunnel::ring::RingTunnelConnector;
use easytier::tunnel::udp::UdpTunnelConnector;
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
#[cfg_attr(feature = "hotpath", hotpath::main)]
async fn main() {
let duration = std::env::var("HOTPATH_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30u64);
let pkt_size: usize = std::env::var("HOTPATH_PKT_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1400);
let tunnel_type = std::env::var("HOTPATH_TUNNEL")
.ok()
.unwrap_or_else(|| "ring".to_string());
let (inst_a_config, inst_b_config) = match tunnel_type.as_str() {
"udp" => {
let mut a = no_tun_config("hot-a", "10.144.144.1");
a.set_listeners(vec!["udp://0.0.0.0:35521".parse().unwrap()]);
(a, no_tun_config("hot-b", "10.144.144.2"))
}
"tcp" => {
let mut a = no_tun_config("hot-a", "10.144.144.1");
a.set_listeners(vec!["tcp://0.0.0.0:35522".parse().unwrap()]);
(a, no_tun_config("hot-b", "10.144.144.2"))
}
_ => (
no_tun_config("hot-a", "10.144.144.1"),
no_tun_config("hot-b", "10.144.144.2"),
),
};
let mut inst_a = Instance::new(inst_a_config);
let mut inst_b = Instance::new(inst_b_config);
inst_a.run().await.expect("inst_a run");
inst_b.run().await.expect("inst_b run");
tokio::time::sleep(Duration::from_secs(1)).await;
match tunnel_type.as_str() {
"ring" => {
let ring_url = format!("ring://{}", inst_a.id());
inst_b
.get_conn_manager()
.add_connector(RingTunnelConnector::new(ring_url.parse().unwrap()));
}
"udp" => {
inst_b.get_conn_manager().add_connector(
UdpTunnelConnector::new("udp://127.0.0.1:35521".parse().unwrap()),
);
}
"tcp" => {
inst_b.get_conn_manager().add_connector(
easytier::tunnel::tcp::TcpTunnelConnector::new(
"tcp://127.0.0.1:35522".parse().unwrap(),
),
);
}
_ => {}
}
let dst: IpAddr = "10.144.144.2".parse().unwrap();
let src = "10.144.144.1";
let converged = tokio::time::timeout(Duration::from_secs(15), async {
loop {
let a = inst_a.get_peer_manager().list_routes().await;
let b = inst_b.get_peer_manager().list_routes().await;
if a.len() >= 1 && b.len() >= 1 {
return true;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.is_ok();
if !converged {
eprintln!("warning: routes did not converge within 15s");
}
println!(
"cpu_hotspot_ring: flooding {}s, pkt_size={}, tunnel={} (converged={})",
duration, pkt_size, tunnel_type, converged
);
let pm = inst_a.get_peer_manager();
let send_pkt = make_data_packet(src, "10.144.144.2", pkt_size);
let batch_threshold: u32 = std::env::var("HOTPATH_BATCH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
// After convergence, enable batch flush for writev optimization
if converged && batch_threshold > 1 {
pm.set_peer_conn_batch_threshold(batch_threshold);
println!("cpu_hotspot_ring: batch_threshold={}", batch_threshold);
}
let pipeline_depth: usize = std::env::var("HOTPATH_PIPELINE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
println!(
"cpu_hotspot_ring: pipeline_depth={}",
pipeline_depth
);
let sender_task = tokio::spawn(async move {
use futures::stream::{FuturesUnordered, StreamExt};
let mut sent: u64 = 0;
let start = Instant::now();
let mut in_flight = FuturesUnordered::new();
loop {
while in_flight.len() < pipeline_depth {
let pkt = send_pkt.clone();
in_flight.push(pm.send_msg_by_ip(pkt, dst, false));
}
in_flight.next().await;
sent += 1;
if sent % 10000 == 0 {
let elapsed = start.elapsed().as_secs_f64();
let pps = sent as f64 / elapsed;
let mbps = pps * pkt_size as f64 * 8.0 / 1_000_000.0;
println!("sent {} pkts ({:.0} pps, {:.0} Mbps)", sent, pps, mbps);
}
}
});
tokio::time::sleep(Duration::from_secs(duration)).await;
sender_task.abort();
println!("cpu_hotspot_ring: done");
}
fn make_data_packet(src: &str, dst: &str, total_size: usize) -> ZCPacket {
use std::net::Ipv4Addr;
let hdr_len = 28;
let payload_len = total_size.saturating_sub(hdr_len);
let ip_total_len = (hdr_len + payload_len) as u16;
let mut buf = BytesMut::with_capacity(total_size);
buf.extend_from_slice(&[
0x45,
0x00,
(ip_total_len >> 8) as u8,
(ip_total_len & 0xff) as u8,
0x00,
0x00,
0x40,
0x00,
0x40,
0x11,
0x00,
0x00,
]);
let src: Ipv4Addr = src.parse().unwrap();
buf.extend_from_slice(&src.octets());
let dst: Ipv4Addr = dst.parse().unwrap();
buf.extend_from_slice(&dst.octets());
let udp_len = (8 + payload_len) as u16;
buf.extend_from_slice(&[
0x30,
0x39,
0xD4,
0x31,
(udp_len >> 8) as u8,
(udp_len & 0xff) as u8,
0x00,
0x00,
]);
buf.resize(total_size, 0xAA);
ZCPacket::new_with_payload(&buf)
}
fn no_tun_config(name: &str, ipv4: &str) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(name.to_owned());
config.set_ipv4(Some(ipv4.parse().unwrap()));
let mut flags = config.get_flags();
flags.no_tun = true;
config.set_flags(flags);
config
}
+1
View File
@@ -129,6 +129,7 @@ impl Compressor for DefaultCompressor {
Ok(())
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "DefaultCompressor"))]
async fn decompress(&self, zc_packet: &mut ZCPacket) -> Result<(), Error> {
let pm_header = zc_packet.peer_manager_header().unwrap();
if !pm_header.is_compressed() {
+2
View File
@@ -70,6 +70,8 @@ async fn set_bind_addr_for_peer_connector(
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
if is_ipv4 {
let mut bind_addrs = vec![];
// Always include loopback so localhost connections work
bind_addrs.push(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
for ipv4 in ips.interface_ipv4s {
let socket_addr = SocketAddrV4::new(ipv4.into(), 0).into();
bind_addrs.push(socket_addr);
+1
View File
@@ -32,6 +32,7 @@ pub(crate) struct CidrSet {
mapped_to_real: Arc<DashMap<cidr::Ipv4Cidr, cidr::Ipv4Cidr>>,
}
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl CidrSet {
pub fn new(global_ctx: ArcGlobalCtx) -> Self {
let mut ret = Self {
+4 -3
View File
@@ -24,7 +24,7 @@ use crate::{
};
use byteorder::WriteBytesExt as _;
use bytes::{BufMut, BytesMut};
use bytes::{Buf, BufMut, BytesMut};
use cidr::{Ipv4Inet, Ipv6Inet};
use futures::{SinkExt, Stream, StreamExt, lock::BiLock, ready};
use pin_project_lite::pin_project;
@@ -180,12 +180,13 @@ impl ZCPacketToBytes for TunZCPacketToBytes {
assert!(payload_offset >= 4);
let ret = if self.has_packet_info {
let mut inner = inner.split_off(payload_offset - 4);
inner.advance(payload_offset - 4);
let proto = infer_proto(&inner[4..]);
self.fill_packet_info(&mut inner[0..4], proto)?;
inner
} else {
inner.split_off(payload_offset)
inner.advance(payload_offset);
inner
};
tracing::debug!(?ret, ?payload_offset, "convert zc packet to tun packet");
+10 -4
View File
@@ -61,13 +61,19 @@ pub type PacketRecvChanReceiver = tokio::sync::mpsc::Receiver<ZCPacket>;
pub fn create_packet_recv_chan() -> (PacketRecvChan, PacketRecvChanReceiver) {
hotpath::channel!(tokio::sync::mpsc::channel(128))
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PacketRecvChan"))]
pub async fn recv_packet_from_chan(
packet_recv_chan_receiver: &mut PacketRecvChanReceiver,
) -> Result<ZCPacket, anyhow::Error> {
packet_recv_chan_receiver
.recv()
.await
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed"))
use tokio::sync::mpsc::error::TryRecvError;
match packet_recv_chan_receiver.try_recv() {
Ok(pkt) => Ok(pkt),
Err(TryRecvError::Empty) => packet_recv_chan_receiver
.recv()
.await
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed")),
Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("recv_packet_from_chan failed")),
}
}
pub const PUBLIC_SERVER_HOSTNAME_PREFIX: &str = "PublicServer_";
+6
View File
@@ -268,6 +268,12 @@ impl Peer {
self.default_conn_id.load()
}
pub fn set_batch_threshold(&self, n: u32) {
for conn in self.conns.iter() {
conn.value().set_batch_threshold(n);
}
}
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
self.peer_identity_type.load()
}
+13 -1
View File
@@ -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(peer_conn_tunnel, Some(Duration::from_secs(7)));
let supports_direct = peer_conn_tunnel
.info()
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp" | "tcp"))
.unwrap_or(false);
let mut mpsc_tunnel = if supports_direct {
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());
@@ -443,6 +451,10 @@ impl PeerConn {
self.conn_id
}
pub fn set_batch_threshold(&self, n: u32) {
self.sink.set_batch_threshold(n);
}
pub fn set_is_hole_punched(&mut self, is_hole_punched: bool) {
self.is_hole_punched = is_hole_punched;
}
+36 -7
View File
@@ -1015,7 +1015,17 @@ impl PeerManager {
self.tasks.lock().await.spawn(async move {
tracing::trace!("start_peer_recv");
while let Ok(ret) = recv_packet_from_chan(&mut recv).await {
loop {
let ret = match recv.try_recv() {
Ok(pkt) => pkt,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
match recv.recv().await {
Some(pkt) => pkt,
None => break,
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
};
let disable_relay_data = global_ctx.flags_arc().disable_relay_data;
let Err(mut ret) = Self::try_handle_foreign_network_packet(
ret,
@@ -1153,9 +1163,11 @@ impl PeerManager {
self_rx_bytes.add(buf_len as u64);
self_rx_packets.inc();
traffic_metrics
.record_rx(from_peer_id, packet_type, buf_len as u64)
.await;
if !traffic_metrics.record_rx_fast(from_peer_id, packet_type, buf_len as u64) {
traffic_metrics
.record_rx(from_peer_id, packet_type, buf_len as u64)
.await;
}
compress_rx_bytes_before.add(buf_len as u64);
let compressor = DefaultCompressor {};
@@ -1556,8 +1568,8 @@ impl PeerManager {
&& (peers.has_peer(gateway) || foreign_network_client.has_next_hop(gateway))
{
relay_peer_map.send_msg(msg, dst_peer_id, policy).await
} else if peers.has_peer(dst_peer_id) {
peers.send_msg_directly(msg, dst_peer_id).await
} else if let Some(peer) = peers.get_peer_by_id(dst_peer_id) {
peer.send_msg(msg).await
} else if foreign_network_client.has_next_hop(dst_peer_id) {
foreign_network_client.send_msg(msg, dst_peer_id).await
} else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await {
@@ -1582,7 +1594,9 @@ impl PeerManager {
if send_result.is_ok()
&& let Some(metrics) = direct_tx_metrics
{
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
if !metrics.record_tx_fast(dst_peer_id, packet_type, msg_len) {
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
}
}
send_result
@@ -1629,6 +1643,7 @@ impl PeerManager {
.collect()
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn get_msg_dst_peer_ipv4(&self, ipv4_addr: &Ipv4Addr) -> (Vec<PeerId>, bool) {
let mut is_exit_node = false;
let mut dst_peers = vec![];
@@ -1697,6 +1712,7 @@ impl PeerManager {
(dst_peers, is_exit_node)
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn try_compress_and_encrypt(
compress_algo: CompressorAlgo,
encryptor: &Arc<dyn Encryptor + 'static>,
@@ -1714,6 +1730,7 @@ impl PeerManager {
Ok(())
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn send_msg_by_ip(
&self,
mut msg: ZCPacket,
@@ -1977,6 +1994,18 @@ impl PeerManager {
self.peers.clone()
}
pub fn set_peer_conn_batch_threshold(&self, n: u32) {
let peers = self.peers.clone();
tokio::spawn(async move {
let peer_ids = peers.list_peers();
for peer_id in peer_ids {
if let Some(peer) = peers.get_peer_by_id(peer_id) {
peer.set_batch_threshold(n);
}
}
});
}
pub fn get_relay_peer_map(&self) -> Arc<RelayPeerMap> {
self.relay_peer_map.clone()
}
+1 -2
View File
@@ -38,6 +38,7 @@ pub struct PeerMap {
alive_client_urls: Arc<Mutex<multimap::MultiMap<url::Url, PeerConnId>>>,
}
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl PeerMap {
pub fn new(packet_send: PacketRecvChan, global_ctx: ArcGlobalCtx, my_peer_id: PeerId) -> Self {
PeerMap {
@@ -132,7 +133,6 @@ impl PeerMap {
peer_id == self.my_peer_id || self.peer_map.contains_key(&peer_id)
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerMap"))]
pub async fn send_msg_directly(&self, msg: ZCPacket, dst_peer_id: PeerId) -> Result<(), Error> {
if dst_peer_id == self.my_peer_id {
let packet_send = self.packet_send.clone();
@@ -164,7 +164,6 @@ impl PeerMap {
Ok(())
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerMap"))]
pub async fn get_gateway_peer_id(
&self,
dst_peer_id: PeerId,
+91 -21
View File
@@ -1,4 +1,4 @@
use std::{future::Future, sync::Arc};
use std::{future::Future, sync::atomic::{AtomicU64, Ordering}, sync::Arc};
use dashmap::DashMap;
use futures::future::BoxFuture;
@@ -18,16 +18,54 @@ pub(crate) enum InstanceLabelKind {
From,
}
#[cfg(not(test))]
const TRAFFIC_BATCH_SIZE: u64 = 128;
#[cfg(test)]
const TRAFFIC_BATCH_SIZE: u64 = 1;
#[derive(Clone)]
struct TrafficCounters {
bytes: CounterHandle,
packets: CounterHandle,
batch: Arc<TrafficBatch>,
}
struct TrafficBatch {
bytes: AtomicU64,
packets: AtomicU64,
}
impl TrafficCounters {
fn new(bytes: CounterHandle, packets: CounterHandle) -> Self {
Self {
bytes,
packets,
batch: Arc::new(TrafficBatch {
bytes: AtomicU64::new(0),
packets: AtomicU64::new(0),
}),
}
}
fn add_sample(&self, bytes: u64) {
self.bytes.add(bytes);
self.packets.inc();
let prev = self.batch.packets.fetch_add(1, Ordering::Relaxed);
self.batch.bytes.fetch_add(bytes, Ordering::Relaxed);
if (prev + 1) % TRAFFIC_BATCH_SIZE == 0 {
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
self.bytes.add(b);
self.packets.add(TRAFFIC_BATCH_SIZE);
}
}
fn flush(&self) {
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
let p = self.batch.packets.swap(0, Ordering::Relaxed);
if b > 0 {
self.bytes.add(b);
}
if p > 0 {
self.packets.add(p);
}
}
}
@@ -60,14 +98,14 @@ impl AggregateTrafficMetrics {
let label_set =
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
Self {
tx: TrafficCounters {
bytes: stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
packets: stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
},
rx: TrafficCounters {
bytes: stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
packets: stats_mgr.get_counter(rx_packets_metric, label_set),
},
tx: TrafficCounters::new(
stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
),
rx: TrafficCounters::new(
stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
stats_mgr.get_counter(rx_packets_metric, label_set),
),
}
}
@@ -122,10 +160,10 @@ impl LogicalTrafficMetrics {
let label_set =
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
Self {
total: TrafficCounters {
bytes: stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
packets: stats_mgr.get_counter(total_packets_metric, label_set),
},
total: TrafficCounters::new(
stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
stats_mgr.get_counter(total_packets_metric, label_set),
),
stats_mgr,
network_name,
instance_bytes_metric,
@@ -135,6 +173,22 @@ impl LogicalTrafficMetrics {
}
}
pub(crate) fn record_fast(&self, peer_id: PeerId, bytes: u64) -> bool {
self.total.add_sample(bytes);
if let Some(entry) = self.per_peer.get(&peer_id)
&& entry.value().is_resolved()
{
let counters = match entry.value() {
CachedPeerTrafficCounters::Resolved(c)
| CachedPeerTrafficCounters::Unknown(c) => c,
};
counters.add_sample(bytes);
true
} else {
false
}
}
pub(crate) async fn record_with_resolver<F, Fut>(
&self,
peer_id: PeerId,
@@ -214,14 +268,12 @@ impl LogicalTrafficMetrics {
let label_set = LabelSet::new()
.with_label_type(LabelType::NetworkName(self.network_name.clone()))
.with_label_type(instance_label);
TrafficCounters {
bytes: self
.stats_mgr
TrafficCounters::new(
self.stats_mgr
.get_counter(self.instance_bytes_metric, label_set.clone()),
packets: self
.stats_mgr
self.stats_mgr
.get_counter(self.instance_packets_metric, label_set),
}
)
}
}
@@ -304,6 +356,15 @@ impl TrafficMetricRecorder {
}
}
pub(crate) fn record_tx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
if peer_id == self.my_peer_id {
return true;
}
self.tx_metrics
.select(traffic_kind(packet_type))
.record_fast(peer_id, bytes)
}
pub(crate) async fn record_tx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
if peer_id == self.my_peer_id {
return;
@@ -314,6 +375,15 @@ impl TrafficMetricRecorder {
.await;
}
pub(crate) fn record_rx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
if peer_id == self.my_peer_id {
return true;
}
self.rx_metrics
.select(traffic_kind(packet_type))
.record_fast(peer_id, bytes)
}
pub(crate) async fn record_rx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
if peer_id == self.my_peer_id {
return;
+158 -15
View File
@@ -1,6 +1,13 @@
// this mod wrap tunnel to a mpsc tunnel, based on crossbeam_channel
use std::{pin::Pin, time::Duration};
use std::{
cell::UnsafeCell,
pin::Pin,
sync::Arc,
sync::atomic::{AtomicBool, AtomicU32, Ordering},
task::Poll,
time::Duration,
};
use anyhow::Context;
use tokio::time::timeout;
@@ -11,39 +18,151 @@ use super::{Tunnel, TunnelError, ZCPacketSink, ZCPacketStream, packet_def::ZCPac
use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError};
use tokio_util::task::AbortOnDropHandle;
// use tachyonix::{channel, Receiver, Sender, TrySendError};
use futures::SinkExt;
/// A simple spinlock protecting a sink. The guard is Send because it only
/// contains an atomic flag reference (no lifetime-tied borrow like MutexGuard).
struct SpinSink {
locked: AtomicBool,
sink: UnsafeCell<Pin<Box<dyn ZCPacketSink>>>,
pending_count: AtomicU32,
batch_threshold: AtomicU32,
}
// SAFETY: access is serialized by the spinlock.
unsafe impl Send for SpinSink {}
unsafe impl Sync for SpinSink {}
struct SpinGuard<'a> {
spin: &'a SpinSink,
}
impl<'a> SpinGuard<'a> {
fn as_mut(&mut self) -> Pin<&mut dyn ZCPacketSink> {
// SAFETY: we hold the spinlock, so we have exclusive access
let sink = unsafe { &mut *self.spin.sink.get() };
sink.as_mut()
}
}
impl Drop for SpinGuard<'_> {
fn drop(&mut self) {
self.spin.locked.store(false, Ordering::Release);
}
}
impl SpinSink {
fn new(sink: Pin<Box<dyn ZCPacketSink>>) -> Self {
Self {
locked: AtomicBool::new(false),
sink: UnsafeCell::new(sink),
pending_count: AtomicU32::new(0),
batch_threshold: AtomicU32::new(1),
}
}
fn set_batch_threshold(&self, n: u32) {
self.batch_threshold.store(n, Ordering::Relaxed);
}
fn try_lock(&self) -> Option<SpinGuard<'_>> {
if self
.locked
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
Some(SpinGuard { spin: self })
} else {
None
}
}
}
#[derive(Clone)]
pub struct MpscTunnelSender(Sender<ZCPacket>);
pub struct MpscTunnelSender {
channel_tx: Option<Sender<ZCPacket>>,
direct_sink: Option<Arc<SpinSink>>,
direct_batch_flush: bool,
}
impl MpscTunnelSender {
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnelSender"))]
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
self.0.send(item).await.with_context(|| "send error")?;
Ok(())
if let Some(sink) = &self.direct_sink {
// 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)?;
let count = sink.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
let threshold = sink.batch_threshold.load(Ordering::Relaxed);
if count >= threshold {
sink.pending_count.store(0, Ordering::Relaxed);
// Batch flush: writev all accumulated BufList entries.
// RingSink: no-op. FramedWriter: single writev syscall.
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()),
}
}
// Accumulate in BufList, no flush yet
return Ok(());
}
Poll::Ready(Err(e)) => return Err(e),
Poll::Pending => return Err(TunnelError::BufferFull),
}
}
return Err(TunnelError::BufferFull);
}
// Channel mode: async with backpressure
self.send_async(item).await
}
pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> {
self.0.try_send(item).map_err(|e| match e {
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 fn set_batch_threshold(&self, n: u32) {
if let Some(sink) = &self.direct_sink {
sink.set_batch_threshold(n);
}
}
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(()),
Err(TrySendError::Full(item)) => {
tx.send(item).await.with_context(|| "send error")?;
Ok(())
}
Err(TrySendError::Closed(_)) => Err(TunnelError::Shutdown),
}
}
}
pub struct MpscTunnel<T> {
tx: Option<Sender<ZCPacket>>,
direct_sink: Option<Arc<SpinSink>>,
direct_batch_flush: bool,
tunnel: T,
stream: Option<Pin<Box<dyn ZCPacketStream>>>,
task: AbortOnDropHandle<()>,
task: Option<AbortOnDropHandle<()>>,
}
impl<T: Tunnel> MpscTunnel<T> {
pub fn new(tunnel: T, send_timeout: Option<Duration>) -> Self {
let (tx, mut rx) = hotpath::channel!(channel(32));
let (tx, mut rx) = hotpath::channel!(channel(1024));
let (stream, mut sink) = tunnel.split();
let task = tokio::spawn(async move {
@@ -60,9 +179,28 @@ impl<T: Tunnel> MpscTunnel<T> {
Self {
tx: Some(tx),
direct_sink: None,
direct_batch_flush: false,
tunnel,
stream: Some(stream),
task: AbortOnDropHandle::new(task),
task: Some(AbortOnDropHandle::new(task)),
}
}
pub fn new_direct(tunnel: T) -> Self {
let (stream, sink) = tunnel.split();
let info = tunnel.info();
let batch_flush = info
.as_ref()
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp"))
.unwrap_or(false);
Self {
tx: None,
direct_sink: Some(Arc::new(SpinSink::new(sink))),
direct_batch_flush: batch_flush,
tunnel,
stream: Some(stream),
task: None,
}
}
@@ -127,12 +265,19 @@ impl<T: Tunnel> MpscTunnel<T> {
}
pub fn get_sink(&self) -> MpscTunnelSender {
MpscTunnelSender(self.tx.as_ref().unwrap().clone())
MpscTunnelSender {
channel_tx: self.tx.as_ref().cloned(),
direct_sink: self.direct_sink.clone(),
direct_batch_flush: self.direct_batch_flush,
}
}
pub fn close(&mut self) {
self.tx.take();
self.task.abort();
self.direct_sink.take();
if let Some(task) = self.task.take() {
task.abort();
}
}
pub fn tunnel_info(&self) -> Option<TunnelInfo> {
@@ -192,8 +337,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;
@@ -212,8 +356,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;
+81 -24
View File
@@ -1,9 +1,10 @@
use bytes::Buf;
use bytes::Bytes;
use bytes::BytesMut;
use zerocopy::byteorder::*;
use zerocopy::AsBytes;
use zerocopy::FromBytes;
use zerocopy::FromZeroes;
use zerocopy::byteorder::*;
type DefaultEndian = LittleEndian;
@@ -483,8 +484,16 @@ impl ZCPacket {
let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
let total_len = payload_off + payload.len();
ret.inner.reserve(total_len);
unsafe { ret.inner.set_len(total_len) };
ret.mut_payload()[..payload.len()].copy_from_slice(payload);
// SAFETY: `reserve` guarantees capacity >= total_len.
// We zero the header region and copy payload before advancing length,
// so every byte in [0..total_len) is initialized before any read.
unsafe {
let ptr = ret.inner.as_mut_ptr();
std::ptr::write_bytes(ptr, 0, payload_off);
std::ptr::copy_nonoverlapping(payload.as_ptr(), ptr.add(payload_off), payload.len());
ret.inner.set_len(total_len);
}
ret
}
@@ -492,12 +501,12 @@ impl ZCPacket {
let mut ret = Self::new_nic_packet();
ret.inner.reserve(cap);
let total_len = ret.packet_type.get_packet_offsets().payload_offset - packet_info_len;
unsafe { ret.inner.set_len(total_len) };
ret.inner.resize(total_len, 0);
ret
}
pub fn new_for_foreign_network(
network_name: &String,
network_name: &str,
dst_peer_id: u32,
foreign_zc_packet: &ZCPacket,
) -> Self {
@@ -506,26 +515,71 @@ impl ZCPacket {
foreign_network_hdr.get_header_len() + foreign_zc_packet.tunnel_payload().len();
let mut ret = Self::new_nic_packet();
let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
ret.inner.reserve(payload_off + total_payload_len);
unsafe { ret.inner.set_len(payload_off + total_payload_len) };
let offsets = ret.packet_type.get_packet_offsets();
let payload_off = offsets.payload_offset;
let pm_hdr_off = offsets.peer_manager_header_offset;
let total_len = payload_off + total_payload_len;
ret.inner.reserve(total_len);
let fixed_hdr_len = std::mem::size_of::<ForeignNetworkPacketHeader>();
ret.mut_payload()[..fixed_hdr_len].copy_from_slice(foreign_network_hdr.as_bytes());
let name_offset = foreign_network_hdr.network_name_offset.get() as usize;
let name_len = foreign_network_hdr.network_name_len.get() as usize;
ret.mut_payload()[name_offset..name_offset + name_len]
.copy_from_slice(network_name.as_bytes());
let foreign_payload = foreign_zc_packet.tunnel_payload();
ret.mut_payload()[foreign_network_hdr.get_header_len()..]
.copy_from_slice(foreign_zc_packet.tunnel_payload());
// Construct the PeerManagerHeader on the stack so we can write it
// directly into the buffer, avoiding a separate mut_peer_manager_header()
// call after set_len.
let pm_hdr = PeerManagerHeader {
from_peer_id: 0.into(),
to_peer_id: 0.into(),
packet_type: PacketType::ForeignNetworkPacket as u8,
flags: 0,
forward_counter: 0,
reserved: 0,
len: U32::new(total_payload_len as u32),
};
let hdr = ret.mut_peer_manager_header().unwrap();
hdr.from_peer_id = 0.into();
hdr.to_peer_id = 0.into();
hdr.packet_type = PacketType::ForeignNetworkPacket as u8;
hdr.len.set(total_payload_len as u32);
// SAFETY: `reserve` guarantees capacity >= total_len.
// We zero only the tunnel-header reserved space [0..pm_hdr_off], write
// the PeerManagerHeader directly at pm_hdr_off, then copy the foreign
// network header, network name, and payload. Every byte in [0..total_len)
// is initialized before set_len.
unsafe {
let ptr = ret.inner.as_mut_ptr();
// Zero the tunnel header reserved space only (not the PM header region)
std::ptr::write_bytes(ptr, 0, pm_hdr_off);
// Write PeerManagerHeader directly
std::ptr::copy_nonoverlapping(
pm_hdr.as_bytes().as_ptr(),
ptr.add(pm_hdr_off),
std::mem::size_of::<PeerManagerHeader>(),
);
// Copy foreign network fixed header
std::ptr::copy_nonoverlapping(
foreign_network_hdr.as_bytes().as_ptr(),
ptr.add(payload_off),
fixed_hdr_len,
);
// Copy network name
std::ptr::copy_nonoverlapping(
network_name.as_ptr(),
ptr.add(payload_off + name_offset),
name_len,
);
// Copy foreign payload
std::ptr::copy_nonoverlapping(
foreign_payload.as_ptr(),
ptr.add(payload_off + foreign_network_hdr.get_header_len()),
foreign_payload.len(),
);
ret.inner.set_len(total_len);
}
ret
}
@@ -585,7 +639,8 @@ impl ZCPacket {
}
pub fn payload_bytes(mut self) -> BytesMut {
self.inner.split_off(self.payload_offset())
self.inner.advance(self.payload_offset());
self.inner
}
pub fn peer_manager_header(&self) -> Option<&PeerManagerHeader> {
@@ -650,11 +705,12 @@ impl ZCPacket {
}
pub fn tunnel_payload_bytes(mut self) -> BytesMut {
self.inner.split_off(
self.inner.advance(
self.packet_type
.get_packet_offsets()
.peer_manager_header_offset,
)
);
self.inner
}
pub fn convert_type(mut self, target_packet_type: ZCPacketType) -> Self {
@@ -695,12 +751,13 @@ impl ZCPacket {
.get_packet_offsets()
.peer_manager_header_offset;
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
unsafe { buf.set_len(new_pm_offset) };
buf.resize(new_pm_offset, 0);
buf.extend_from_slice(tunnel_payload);
return Self::new_from_buf(buf, target_packet_type);
}
Self::new_from_buf(self.inner.split_off(new_offset), target_packet_type)
self.inner.advance(new_offset);
Self::new_from_buf(self.inner, target_packet_type)
}
pub fn into_bytes(self) -> Bytes {
+1
View File
@@ -130,6 +130,7 @@ impl RingSink {
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl Sink<SinkItem> for RingSink {
type Error = SinkError;
+139 -3
View File
@@ -266,14 +266,151 @@ fn get_zcpacket_from_buf(buf: BytesMut, allow_stun: bool) -> Result<ZCPacket, Tu
Ok(zc_packet)
}
#[instrument]
#[cfg(feature = "udp-gso")]
const UDP_BATCH_SIZE: usize = 8;
#[cfg(feature = "udp-gso")]
async fn forward_from_ring_to_udp(
mut ring_recv: RingStream,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
conn_id: u32,
) -> Option<TunnelError> {
tracing::debug!("udp forward from ring to udp");
use quinn_udp::{Transmit, UdpSockRef, UdpSocketState};
use std::io;
tracing::debug!("udp forward from ring to udp (GSO batch)");
let udp_state = match UdpSocketState::new(UdpSockRef::from(&**socket)) {
Ok(state) => state,
Err(e) => {
tracing::warn!(?e, "failed to init UdpSocketState, fallback to per-packet send");
return forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await;
}
};
let max_gso = udp_state.max_gso_segments();
tracing::info!(max_gso, "udp GSO segments supported");
loop {
// 1. Get first packet (await)
let first = match ring_recv.next().await {
Some(Ok(pkt)) => convert_to_udp_bytes(pkt, conn_id),
Some(Err(e)) => return Some(e),
None => return None,
};
// 2. Try to drain more packets (non-blocking via poll_next with noop waker)
use std::pin::Pin;
use std::task::{Poll, Context};
use futures::task::noop_waker;
use futures::Stream;
let mut batch: Vec<bytes::Bytes> = vec![first];
while batch.len() < UDP_BATCH_SIZE.min(max_gso) {
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
match Pin::new(&mut ring_recv).poll_next(&mut cx) {
Poll::Ready(Some(Ok(pkt))) => {
batch.push(convert_to_udp_bytes(pkt, conn_id));
}
_ => break,
}
}
// 3. Check if all same size (GSO requirement)
let seg_size = batch[0].len();
let all_same = batch.iter().all(|b| b.len() == seg_size);
if batch.len() == 1 || !all_same || max_gso == 1 {
// Fallback: send individually
for buf in &batch {
if let Err(e) = send_one(&udp_state, socket, addr, buf).await {
return Some(TunnelError::IOError(e));
}
}
} else {
// GSO batch: concatenate + single sendmsg
let mut contents = Vec::with_capacity(seg_size * batch.len());
for buf in &batch {
contents.extend_from_slice(buf);
}
let transmit = Transmit {
destination: *addr,
ecn: None,
contents: &contents,
segment_size: Some(seg_size),
src_ip: None,
};
if let Err(e) = send_one_gso(&udp_state, socket, &transmit).await {
return Some(TunnelError::IOError(e));
}
}
}
}
#[cfg(feature = "udp-gso")]
fn convert_to_udp_bytes(mut packet: ZCPacket, conn_id: u32) -> bytes::Bytes {
let mut packet = packet.convert_type(ZCPacketType::UDP);
let udp_payload_len = packet.udp_payload().len();
let header = packet.mut_udp_tunnel_header().unwrap();
header.conn_id.set(conn_id);
header.len.set(udp_payload_len as u16);
header.msg_type = UdpPacketType::Data as u8;
packet.into_bytes()
}
#[cfg(feature = "udp-gso")]
async fn send_one(
udp_state: &quinn_udp::UdpSocketState,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
buf: &[u8],
) -> Result<(), std::io::Error> {
use quinn_udp::{Transmit, UdpSockRef};
let transmit = Transmit {
destination: *addr,
ecn: None,
contents: buf,
segment_size: None,
src_ip: None,
};
send_one_gso(udp_state, socket, &transmit).await
}
#[cfg(feature = "udp-gso")]
async fn send_one_gso(
udp_state: &quinn_udp::UdpSocketState,
socket: &Arc<UdpSocket>,
transmit: &quinn_udp::Transmit<'_>,
) -> Result<(), std::io::Error> {
use quinn_udp::UdpSockRef;
loop {
match udp_state.send(UdpSockRef::from(&**socket), transmit) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
tokio::task::yield_now().await;
}
Err(e) => return Err(e),
}
}
}
#[cfg(not(feature = "udp-gso"))]
async fn forward_from_ring_to_udp(
mut ring_recv: RingStream,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
conn_id: u32,
) -> Option<TunnelError> {
forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await
}
async fn forward_from_ring_to_udp_fallback(
mut ring_recv: RingStream,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
conn_id: u32,
) -> Option<TunnelError> {
tracing::debug!("udp forward from ring to udp (per-packet)");
loop {
let buf = ring_recv.next().await?;
let packet = match buf {
@@ -291,7 +428,6 @@ async fn forward_from_ring_to_udp(
header.msg_type = UdpPacketType::Data as u8;
let buf = packet.into_bytes();
tracing::trace!(?udp_payload_len, ?buf, "udp forward from ring to udp");
let ret = socket.send_to(&buf, &addr).await;
if ret.is_err() {
return Some(TunnelError::IOError(ret.unwrap_err()));
+197
View File
@@ -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<u64>` 保存,并通过 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<Throughput>`
- `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<u64>,
rx_bytes: UnsafeCell<u64>,
tx_packets: UnsafeCell<u64>,
rx_packets: UnsafeCell<u64>,
}
// 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<SinkItem> {
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<StreamItem> {
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>`。不要继续共享可变 `Instant`。二选一:
- 推荐:将 last update 表示为 `AtomicU64`,存储从 `StatsManager` 创建时刻起的 monotonic micros 或 millis;读取时只在内部转换为需要的 age/duration。
- 可接受:用 `parking_lot::Mutex<Instant>` 保护 `last_updated`,如果改动最小且性能足够。
选择方案后,移除 `MetricData` 的 manual `unsafe impl Send/Sync`。保持外部 behaviorcounter 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<Throughput>`,并发调用 `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<u64>` 或 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 和数据竞争风险。
+160
View File
@@ -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<ZCPacket>,
}
// 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 testbounded 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` 优先级,应另写计划。
+176
View File
@@ -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<bool, Error>`,返回 `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 testsnewer version truesame/older version false。
- 新增 `update_conn_info` changed flag testschanged topology trueidentical 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` 处理。
+176
View File
@@ -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 0characterization 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` 大文件;只做局部性能重构。
+164
View File
@@ -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,应另写修复计划。
+200
View File
@@ -0,0 +1,200 @@
# 计划 006send_msg_internal 发包链路 CPU 优化
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。完成后更新 `plans/README.md` 中本计划的状态行。
## 状态
- **优先级**: P1
- **工作量**: M
- **风险**: LOW
- **依赖**: hotpath profiling infra (main branch commit `be2034dd`)
- **类别**: performance
- **数据来源**: hotpath-cpu samply 423,583,601 samples4 threads234K ppspkt_size=1400
## 为什么重要
`send_msg_internal` 是数据面包转发的核心路径,每包耗时 3.26µs(wall time)。在 234K pps 下占 wall time 的 ~70%。samply inclusive CPU 分解显示有多处可通过减少冗余操作来省 µs 级开销。每包省 1µs 即可将吞吐提升 ~30%。
## 数据基线
### timingwall time,含 await
| Function | Calls | Avg/包 | 级差 |
|---|---|---|---|
| `send_msg_internal` | 6.9M | 3.26µs | — |
| └─ `send_msg_directly` | 6.9M | 2.83µs | 0.43µs(路由决策) |
| └─ `Peer::send_msg` | 6.9M | 2.69µs | 0.14µsconn 选择) |
| └─ `PeerConn::send_msg` | 6.9M | 2.58µs | 0.11µssession 选择) |
### samply inclusive CPUsend_msg_internal 子树,11.5M samples
| % | Function | 含义 |
|---|---|---|
| 12.0% | `PeerMap::send_msg_directly` | 发包核心 |
| 7.5% | `tokio::mpsc::Sender::send` | mpsc 通道 |
| **7.1%** | **`TrafficMetricRecorder::record_tx`** | 每包流量统计 |
| **6.1%+4.8%+4.0%** | **`dashmap::get` ×3** | 冗余 dashmap 查询 |
| 5.6% | `batch_semaphore::Acquire::poll` | mpsc permit |
| **3.9%** | **`quanta::get_now`** | 时间戳获取 |
| 3.9% | `malloc` | 内存分配 |
| **1.2%** | **`TrafficCounters closure`** | 流量计数器 |
| 1.0% | `MpscTunnelSender::send` | tunnel 发送 |
## 当前代码
```rust
// easytier/src/peers/peer_manager.rs:1533-1588
async fn send_msg_internal(
peers: &Arc<PeerMap>,
foreign_network_client: &Arc<ForeignNetworkClient>,
relay_peer_map: &Arc<RelayPeerMap>,
direct_tx_metrics: Option<&Arc<TrafficMetricRecorder>>,
msg: ZCPacket,
dst_peer_id: PeerId,
) -> Result<(), Error> {
// ...
let send_result = if ... {
// relay path
} else if peers.has_peer(dst_peer_id) { // dashmap get #1 (contains_key)
peers.send_msg_directly(msg, dst_peer_id).await // 内部 get_peer_by_id = dashmap get #2
} else if foreign_network_client.has_next_hop(dst_peer_id) {
// foreign network path
} else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await {
if peers.has_peer(gateway) || ... { // dashmap get #3
relay_peer_map.send_msg(msg, dst_peer_id, policy).await
}
}
if send_result.is_ok() && let Some(metrics) = direct_tx_metrics {
metrics.record_tx(dst_peer_id, packet_type, msg_len).await; // 每包记录
}
send_result
}
```
```rust
// easytier/src/peers/peer_map.rs:136-164
pub async fn send_msg_directly(&self, msg: ZCPacket, dst_peer_id: PeerId) -> Result<(), Error> {
if dst_peer_id == self.my_peer_id {
// self-send path (tokio::spawn)
return Ok(());
}
match self.get_peer_by_id(dst_peer_id) { // dashmap get (重复)
Some(peer) => peer.send_msg(msg).await?,
None => return Err(Error::RouteError(...)),
}
Ok(())
}
```
## 优化项
### 步骤 1:合并 dashmap 冗余查询(P0,预期省 ~0.1-0.2µs/包)
**问题**happy path 上 `has_peer(dst_peer_id)` + `send_msg_directly → get_peer_by_id(dst_peer_id)` 对同一个 key 做了 2 次 dashmap 查询。每次 ~100nshash + shard read lock)。
**方案**:在 `send_msg_internal` 中直接调 `get_peer_by_id`,根据 `Option<Arc<Peer>>` 分支,跳过 `has_peer` 检查。
```rust
// 改前
} else if peers.has_peer(dst_peer_id) {
peers.send_msg_directly(msg, dst_peer_id).await
}
// 改后
} else if let Some(peer) = peers.get_peer_by_id(dst_peer_id) {
peer.send_msg(msg).await
}
```
注意:`send_msg_directly` 中的 self-send 分支(`dst_peer_id == my_peer_id`)需要在上层处理或保留。当前 bench 场景 `dst_peer_id != my_peer_id`,不触发 self-send。
**涉及文件**`easytier/src/peers/peer_manager.rs:1558-1559`
**冲突检查**advisor/001-002 改过此文件(队列背压 + metrics 连带),需 rebase 后确认行号。
**验证**`cargo test -p easytier -- send_msg_internal`
### 步骤 2TrafficMetricRecorder 降频记录(P1,预期省 ~0.25µs/包)
**问题**`record_tx` 每包都调用,占 inclusive CPU 的 7.1% + TrafficCounters 1.2% = 8.3%。内部做 histogram 记录(`hdrhistogram::record_n_inner`)和时间戳获取(`quanta::get_now`)。
**方案**:在 `TrafficMetricRecorder` 中引入 per-thread atomic 计数器,每 N 包(如 64)或每 T ms 刷入 histogram。
```rust
// 改前
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
// 改后
metrics.record_tx_fast(dst_peer_id, packet_type, msg_len); // sync, atomic counter
// 内部: counter.fetch_add(msg_len); if counter % 64 == 0 { flush_to_histogram() }
```
**涉及文件**`easytier/src/peers/traffic_metrics.rs``easytier/src/peers/peer_manager.rs:1584`
**冲突检查**traffic_metrics.rs 零冲突。peer_manager.rs 同步骤 1。
**验证**`cargo test -p easytier -- traffic_metrics`
### 步骤 3:缓存时间戳(P2,预期省 ~0.13µs/包)
**问题**`quanta::get_now` 占 inclusive CPU 的 3.9%。send_msg_internal 路径上多处获取当前时间(record_tx 内部、traffic counters 等)。
**方案**:在 `send_msg_internal` 入口取一次时间戳,传入子函数。
```rust
let now = quanta::Instant::now();
// ...
metrics.record_tx_with_time(dst_peer_id, packet_type, msg_len, now);
```
**涉及文件**`easytier/src/peers/peer_manager.rs``easytier/src/peers/traffic_metrics.rs`
**冲突检查**:同步骤 2。
**验证**bench pps 对比。
### 步骤 4mpsc batch sendP3,预期省 ~0.46µs/包)
**问题**`PeerConn::send_msg` 每包做 1 次 `MpscTunnelSender::send`,触发 mpsc `Sender::send` (7.5%) + `batch_semaphore::Acquire::poll` (5.6%) + `add_permits_locked` (3.82%) = 16.9%。
**方案**:在 `PeerConn``Peer` 层引入 batch buffer,攒满 N 个包后一次 `send`(使用 `try_send` 或 unbounded channel)。
**涉及文件**`easytier/src/peers/peer_conn.rs``easytier/src/tunnel/mpsc.rs`
**冲突检查**peer_conn.rs 被 advisor/001-002 改过。mpsc.rs 被 perf/001 改过。需要协调合并顺序。
**验证**bench pps 对比 + `cargo test -p easytier -- peer_conn`
### 步骤 5ZCPacket 池化(P4,预期省 ~0.21µs/包)
**问题**:每包 malloc 3.9% + free 1.2% + morecore 1.2% = 6.3%。全局 munmap 4.73% 也部分来自此。
**方案**:对 ZCPacket 引入池化(`crossbeam-queue::ArrayQueue``tokio::sync::Pool`)。
**涉及文件**`easytier/src/tunnel/packet_def.rs`
**冲突检查**packet_def.rs 被 perf/001-003 改过。需要在 perf PR 合并后实施。
**验证**bench pps + `cargo test -p easytier -- packet`
## 预期总收益
| 步骤 | 每包省 | 累计 |
|---|---|---|
| 步骤 1dashmap 合并) | ~0.15µs | 3.26→3.11µs |
| 步骤 2metrics 降频) | ~0.25µs | 3.11→2.86µs |
| 步骤 3(缓存时间戳) | ~0.13µs | 2.86→2.73µs |
| 步骤 4batch send | ~0.46µs | 2.73→2.27µs |
| 步骤 5packet 池化) | ~0.21µs | 2.27→2.06µs |
| **合计** | **~1.2µs** | **3.26→2.06µs-37%** |
在 4 threads 配置下,预期 pps 从 234K 提升到 ~320K-370K+37%-58%)。
## 验证方法
```bash
# baseline(当前 main + measure_all
export PATH=$HOME/.cargo/bin:$PATH
cargo run --profile hotpath --features hotpath,hotpath-cpu --example cpu_hotspot_ring
# 记录 pps 和 timing avg
# 每个步骤实施后重跑,对比 pps 和 send_msg_internal avg
```
## 风险
- **步骤 1**:改变路由决策逻辑的边界条件(self-send、foreign network)。需确保不破坏 `send_msg_internal_*` 测试。
- **步骤 2**:metrics 精度降低(从每包精确变为每 64 包近似)。需确认 stats 查询端能接受。
- **步骤 4**batch send 引入延迟(攒批期间包等待)。需设置 flush timeout。
- **步骤 5**:ZCPacket 池化改变生命周期模型,可能引入 use-after-free。需充分测试。
+35
View File
@@ -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:有价值,但用户本次只要求正确性和性能计划。
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
set -e
NS_NAME="et_bench"
echo "=== Creating netns: $NS_NAME ==="
# Clean up old ns
sudo ip netns del "$NS_NAME" 2>/dev/null || true
# Create namespace
sudo ip netns add "$NS_NAME"
# Enable loopback inside namespace
sudo ip netns exec "$NS_NAME" ip link set lo up
echo "=== netns $NS_NAME ready ==="
echo "Both instances will share this namespace's loopback."
echo "TCP/UDP connections to 127.0.0.1 will work inside it."
echo ""
echo "Now run the bench with HOTPATH_NETNS=$NS_NAME:"
echo " HOTPATH_TUNNEL=tcp HOTPATH_NETNS=$NS_NAME HOTPATH_BENCH_SECS=15 \\"
echo " cargo run --profile hotpath --features hotpath --example cpu_hotspot_ring"