Compare commits

..
Author SHA1 Message Date
fanyang b8d1d6b32c perf(smoltcp): unify device packet type on BytesMut for zero-copy TX
The smoltcp -> ZCPacket TX path still memcpy'd each outbound packet:
the device emitted Vec<u8>, and `Vec<u8> -> BytesMut` has no zero-copy
conversion in the bytes crate, so socks5/tcp_proxy paid a
`BytesMut::from(Bytes::from(data))` copy per packet.

Switch tokio_smoltcp's packet type to BytesMut end to end so smoltcp
writes into the headroom-reserved buffer that ZCPacket wraps directly.

- device: `Packet = BytesMut`; BufferTxToken allocates via
  BytesMut::with_capacity + resize; BufferRxToken derefs to &[u8].
- channel_device: Stream/Sink/channel carry BytesMut instead of Vec<u8>.
- socks5 / tcp_proxy: TX wraps the BytesMut via new_from_buf directly,
  removing the per-packet memcpy; inbound keeps an equivalent copy via
  BytesMut::from(payload).
- reactor: adapts through the Packet alias, no code change.
2026-06-27 12:30:35 +08:00
fanyang b092e78523 refactor(smoltcp): address review on NetConfig visibility and zcpacket bench
- tokio_smoltcp::NetConfig: narrow `packet_tx_headroom` to `pub(crate)`.
  The struct is already `#[non_exhaustive]` and the field is only read
  internally (BufferDevice creation); the `with_packet_tx_headroom`
  builder remains public.
- packet_def: harden the zcpacket benchmark by asserting copy/zerocopy
  payload equivalence outside the timed section, and black_box the
  constructed packet so the compiler cannot elide the construction.
2026-06-27 11:14:38 +08:00
fanyang 626a5fb4f1 perf: avoid smoltcp packet copy
Reserve NIC packet headroom in BufferDevice's TxToken so smoltcp writes
the IP packet directly into a buf already carrying the ZCPacket NIC
offset. socks5/tcp_proxy then wrap that buf zero-copy via
ZCPacket::new_from_buf instead of copying through new_with_payload.

Benchmark: tunnel::packet_def::tests::smoltcp_zcpacket_construct_bench
  copy (new_with_payload)    1280B: 13.5M pps
  zerocopy (new_from_buf)    1280B: 28.3M pps  (2.09x)
  copy (new_with_payload)    4096B: 10.7M pps
  zerocopy (new_from_buf)    4096B: 19.1M pps  (1.79x)

Environment: AMD Ryzen 9 9955HX, rustc 1.95.0, --release, median of 3 runs
2026-06-26 21:09:29 +08:00
fanyang 130d89a057 test: add smoltcp zcpacket construction benchmark
Benchmark the two ZCPacket construction paths used around the smoltcp
gateway: copy via new_with_payload (pre-f5ce0848) vs zero-copy via
new_from_buf with NIC headroom reserved (f5ce0848).
2026-06-26 21:09:29 +08:00
84 changed files with 577 additions and 7848 deletions
+6 -13
View File
@@ -33,19 +33,6 @@ runs:
sudo apt-get install -qqy build-essential mold musl-tools
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
version: '35.1'
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
- name: Verify protoc version
run: |
version="$(protoc --version | tr -d '\r')"
test "$version" = "libprotoc 35.1"
shell: bash
- name: Setup Frontend Environment
if: ${{ inputs.pnpm == 'true' }}
uses: ./.github/actions/prepare-pnpm
@@ -95,3 +82,9 @@ runs:
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
+2 -2
View File
@@ -41,8 +41,8 @@ runs:
pnpm -r install
if [ -n "${{ inputs.build-filter }}" ]; then
echo "Building with filter: ${{ inputs.build-filter }}"
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
pnpm -r --filter "${{ inputs.build-filter }}" build
else
echo "No build filter provided, building all packages"
pnpm -r --workspace-concurrency=1 build
pnpm -r build
fi
-1
View File
@@ -34,7 +34,6 @@ easytier-panic.log
# web
node_modules
easytier-web/frontend-lib/src/generated/
.vite
Generated
+1 -187
View File
@@ -257,12 +257,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -1254,12 +1248,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cidr"
version = "0.3.1"
@@ -2100,16 +2088,6 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
@@ -2133,17 +2111,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users 0.4.5",
"winapi",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -2322,7 +2289,6 @@ dependencies = [
"hickory-resolver",
"hickory-server",
"hmac",
"hotpath",
"http",
"http_req",
"humansize",
@@ -2362,10 +2328,8 @@ dependencies = [
"prost-reflect",
"prost-reflect-build",
"prost-wkt-types",
"quanta",
"quinn",
"quinn-proto",
"quinn-udp",
"quote",
"rand 0.8.5",
"rcgen",
@@ -2666,12 +2630,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding"
version = "0.2.33"
@@ -3930,61 +3888,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "hotpath"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc2c28b1fa962e433f800ed1ea0bf53dc028d3745cf2acec6cfd28b65ac96afa"
dependencies = [
"arc-swap",
"cfg-if",
"crossbeam-channel",
"flate2",
"flume 0.12.0",
"futures-util",
"hdrhistogram",
"hotpath-macros",
"hotpath-meta",
"libc",
"object",
"parking_lot",
"pin-project-lite",
"prettytable-rs",
"quanta",
"regex",
"rustc-demangle",
"serde",
"serde_json",
"tiny_http",
"tokio",
]
[[package]]
name = "hotpath-macros"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a585238d8daf746e27df0f24d1bbdcd2410e9febff63f9a0173f90d7e71c50f6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "hotpath-macros-meta"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "309f63c2f755dead454dd4b3ea8ab5c947f14f8ea435fbcd37fa820e17290e80"
[[package]]
name = "hotpath-meta"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68faa91a9e1114dff668cd90560f332da6bbde40dae37ec28ea1c43ca5ce3be3"
dependencies = [
"hotpath-macros-meta",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -4592,17 +4495,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
@@ -5721,7 +5613,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -5953,15 +5845,6 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -6832,19 +6715,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "prettytable-rs"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
dependencies = [
"encode_unicode",
"is-terminal",
"lazy_static",
"term",
"unicode-width 0.1.11",
]
[[package]]
name = "primeorder"
version = "0.13.6"
@@ -7027,12 +6897,9 @@ version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9"
dependencies = [
"base64 0.22.1",
"prost 0.14.3",
"prost-reflect-derive",
"prost-types 0.14.3",
"serde",
"serde-value",
]
[[package]]
@@ -7146,21 +7013,6 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi 0.11.0+wasi-snapshot-preview1",
"web-sys",
"winapi",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -7414,15 +7266,6 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.8.0",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -7904,12 +7747,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "2.1.0"
@@ -9834,17 +9671,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "term"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
dependencies = [
"dirs-next",
"rustversion",
"winapi",
]
[[package]]
name = "terminal_size"
version = "0.4.1"
@@ -10000,18 +9826,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "825f6c8a18bc36d56a62f66af7296385b628c9c5543a8663d4c217fc920bfefd"
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.7.6"
-6
View File
@@ -27,9 +27,3 @@ 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"
@@ -1,426 +0,0 @@
# hotpath CPU 热点分析与发包链路优化
## 概述
本文档记录了使用 hotpath + samply 对 easytier-core 发包链路进行 CPU 热点分析的全过程,包括工具链搭建、热点定位、优化实施、踩坑记录和最终 benchmark 结果。
## 最终 benchmark 数据
### 真实性能对比(不带 hotpath,3 runs average)
origin/main baseline 使用 `git worktree` 从 origin/main 构建,仅添加 bench example +
loopback bind fix(TCP/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 profiling(timing 可见,但有 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 wrapper,autospawn 依赖)
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 KB,32 核机器上 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`。
---
## 踩坑记录
### 坑 1:samply 报 "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
```
### 坑 2:samply 报 "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
```
### 坑 3:samply 报 "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。
### 坑 5:samply 符号化后 `_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 钩子**。
### 坑 6:parking_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 不是 Send,tokio multi_thread runtime 拒绝 spawn。
**解决**:自定义 `SpinSink`(AtomicBool spinlock),`SpinGuard` 只持有 `&SpinSink` 引用(SpinSink: Sync via unsafe impl),是 Send。
### 坑 7:std::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 实现。
### 坑 8:direct 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 实际操作只有 ~40ns(2%)。
**解决**:用 `noop_waker()` 在 async fn 内部同步调用 Sink trait 方法(poll_ready + start_send + poll_flush)。async fn 在第一次 poll 就同步完成返回——绕过所有 async 调度开销。开销从 2µs 降到 ~140ns。
### 坑 9:sync 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 path(ring/UDP/TCP)内部用 noop_waker 同步完成(不 yield)。channel path 仍然走 async `send_async().await`。async fn wrapper 对 direct path 只有 ~100ns 开销(Future struct 创建 + 单次 poll),因为不 yield。
### 坑 10:poll_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(())`。数据已在 buffer(ring buffer 或 BufList),后续操作会消费它。Pending 只意味着 "还没 flush 到网络",不是 "错误"。
### 坑 11:TCP/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 连接成功。
### 坑 12:ShardedCounter (#2385) 在高频路径引入回退
**现象**:cherry-pick PR #2385(ShardedCounter 替代 UnsafeCell)后,pps 下降 17%(246K → 203K)。
**原因**:ShardedCounter 的 TLS 分片设计优化多线程 contention,但每包调用 16 次 `ShardedCounter::add`(TLS load + store),单次 ~14ns,总 224ns/包。比原来的 `UnsafeCell`(~2ns/次)高 6 倍。每包 16 次的调用频率让 TLS 开销累积。
**教训**:TLS 分片策略适合 **低频高并发** 场景,不适合 **高频单线程** 的发包热路径。
### 坑 13:ZCPacket pool 不如 glibc tcache
**现象**:用 `crossbeam_queue::ArrayQueue` 做 BytesMut 对象池,每包从池取/归还。性能没有提升(甚至 -15%)。
**原因**:glibc malloc 对 ~1500 bytes 小块分配有 thread-local cache(tcache),单次 alloc ~10-15ns。ArrayQueue 的 pop/push 是 CAS 操作(~20-40ns),比 tcache 更慢。pool 还多了 capacity 检查和 clear 操作。
**教训**:手动对象池在现代 glibc tcache 面前没有优势。真正需要 pool 的场景是避免 munmap(大块 >128KB 分配),不是小块。
### 坑 14:Pipeline (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。
### 坑 15:hotpath 测量引入 54% observer effect
**现象**:同一 binary 带 hotpath feature 和不带 hotpath feature 跑 bench,pps 差距巨大。
**数据**:
| 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 校准可近似真实开销
---
## 优化实施记录
### 真实提升(不带 hotpath,origin/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 意味着数据已在 buffer(ring 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 本体完整路径 |
+2 -2
View File
@@ -5,8 +5,8 @@
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint . --ignore-pattern src-tauri",
+5 -16
View File
@@ -13,18 +13,12 @@
"./*.css": "./dist/*.css"
},
"scripts": {
"codegen:proto": "node scripts/codegen-proto.mjs",
"dev": "pnpm codegen:proto && vite",
"build": "pnpm codegen:proto && vue-tsc -b && vite build",
"test": "pnpm test:config-ui && pnpm test:network-config",
"test:config-ui": "pnpm codegen:proto && vitest run --config vitest.config.ts",
"test:network-config": "pnpm build && node scripts/test-network-config.mjs",
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"@protobuf-ts/runtime": "2.11.1",
"@protobuf-ts/runtime-rpc": "2.11.1",
"@vueuse/core": "^11.1.0",
"axios": "^1.13.5",
"chart.js": "^4.5.0",
@@ -39,13 +33,9 @@
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@protobuf-ts/plugin": "2.11.1",
"@protobuf-ts/protoc": "2.11.1",
"@types/node": "^22.8.6",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.11",
"autoprefixer": "^10.4.20",
"happy-dom": "16.8.1",
"postcss": "^8.4.47",
"postcss-import": "^16.1.0",
"postcss-nested": "^7.0.2",
@@ -53,11 +43,10 @@
"typescript": "~5.6.3",
"vite": "^5.4.21",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.10"
},
"peerDependencies": {
"primevue": "^4.3.9",
"vue": "^3.5.12"
"vue": "^3.5.12",
"primevue": "^4.3.9"
}
}
}
+10 -2482
View File
File diff suppressed because it is too large Load Diff
@@ -1,121 +0,0 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { delimiter, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const protoRoot = resolve(root, '../../easytier/src/proto')
const generatedRoot = resolve(root, 'src/generated')
const outDir = resolve(generatedRoot, 'proto')
const nodeBinDir = resolve(root, 'node_modules/.bin')
const protocWrapper = require.resolve('@protobuf-ts/protoc/protoc.js')
const protobufTsPluginRoot = dirname(require.resolve('@protobuf-ts/plugin/package.json'))
const protoFiles = [
'common.proto',
'acl.proto',
'api_instance.proto',
'api_manage.proto',
'peer_rpc.proto',
'error.proto',
]
function installGeneratedFiles(fromDir, toDir) {
mkdirSync(toDir, { recursive: true })
for (const entry of readdirSync(fromDir)) {
const source = resolve(fromDir, entry)
const target = resolve(toDir, entry)
if (statSync(source).isDirectory()) {
installGeneratedFiles(source, target)
continue
}
renameSync(source, target)
}
}
function findExecutableInPath(command, extensions = ['']) {
const envPath = process.env[pathEnvKey()]
if (typeof envPath !== 'string') return undefined
const nodeBinSuffix = ['node_modules/.bin', 'node_modules\\.bin']
for (const entry of envPath.split(delimiter)) {
if (!entry || nodeBinSuffix.some((suffix) => entry.endsWith(suffix))) continue
for (const extension of extensions) {
const candidate = resolve(entry, `${command}${extension}`)
if (existsSync(candidate)) return candidate
}
}
return undefined
}
function pathEnvKey() {
return Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
}
function withNodeBinPath() {
const key = pathEnvKey()
const currentPath = process.env[key]
return {
...process.env,
[key]: currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir,
}
}
function getProtocCommand() {
const extensions = process.platform === 'win32' ? ['.exe'] : ['']
const systemProtoc = findExecutableInPath('protoc', extensions)
if (systemProtoc) {
return {
command: systemProtoc,
argsPrefix: ['--proto_path', protobufTsPluginRoot],
}
}
return {
command: process.execPath,
argsPrefix: [protocWrapper],
}
}
mkdirSync(generatedRoot, { recursive: true })
const tmpDir = mkdtempSync(resolve(generatedRoot, '.proto-'))
const protocCommand = getProtocCommand()
try {
const result = spawnSync(protocCommand.command, [
...protocCommand.argsPrefix,
'-I',
protoRoot,
`--ts_out=${tmpDir}`,
'--ts_opt=use_proto_field_name,server_none,client_none,ts_nocheck',
...protoFiles.map((file) => resolve(protoRoot, file)),
], {
cwd: root,
env: withNodeBinPath(),
stdio: 'inherit',
shell: false,
})
if (result.error) {
throw result.error
}
const status = result.status ?? 1
if (status === 0) {
installGeneratedFiles(tmpDir, outDir)
}
process.exit(status)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
@@ -1,478 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import ts from 'typescript'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(__dirname, '..')
const generatedApiManagePath = path.join(projectRoot, 'src/generated/proto/api_manage.ts')
const distPath = path.join(projectRoot, 'dist/easytier-frontend-lib.js')
const { NetworkTypes } = await import(pathToFileURL(distPath))
const {
AclAction,
AclChainType,
AclProtocol,
CompressionAlgoPb,
DEFAULT_NETWORK_CONFIG,
NetworkingMethod,
normalizeNetworkConfig,
toBackendNetworkConfig,
} = NetworkTypes
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'enable_vpn_portal',
'advanced_settings',
'latency_first',
'use_smoltcp',
'disable_ipv6',
'enable_kcp_proxy',
'disable_kcp_input',
'disable_p2p',
'bind_device',
'no_tun',
'enable_exit_node',
'relay_all_peer_rpc',
'multi_thread',
'enable_relay_network_whitelist',
'enable_manual_routes',
'proxy_forward_by_system',
'disable_encryption',
'enable_socks5',
'disable_udp_hole_punching',
'enable_magic_dns',
'enable_private_mode',
'enable_quic_proxy',
'disable_quic_input',
'disable_sym_hole_punching',
'p2p_only',
'lazy_p2p',
'need_p2p',
'disable_upnp',
'ipv6_public_addr_provider',
'ipv6_public_addr_auto',
'disable_relay_data',
'enable_udp_broadcast_relay',
'disable_tcp_hole_punching',
]
function readGeneratedNetworkConfigFields() {
const source = ts.createSourceFile(
generatedApiManagePath,
fs.readFileSync(generatedApiManagePath, 'utf8'),
ts.ScriptTarget.Latest,
true,
)
for (const statement of source.statements) {
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'NetworkConfig') {
continue
}
return statement.members
.filter(ts.isPropertySignature)
.map((member) => member.name.getText(source).replace(/^['"]|['"]$/g, ''))
}
throw new Error(`NetworkConfig interface not found in ${generatedApiManagePath}`)
}
function expectNoCamelCaseKeys(value, pathSegments = []) {
if (!value || typeof value !== 'object') {
return
}
if (Array.isArray(value)) {
value.forEach((item, index) => expectNoCamelCaseKeys(item, [...pathSegments, String(index)]))
return
}
for (const [key, child] of Object.entries(value)) {
assert.equal(
/[A-Z]/.test(key),
false,
`JSON key should use proto field name: ${[...pathSegments, key].join('.')}`,
)
expectNoCamelCaseKeys(child, [...pathSegments, key])
}
}
function allFieldFixture() {
return {
...DEFAULT_NETWORK_CONFIG(),
instance_id: '11111111-2222-3333-4444-555555555555',
dhcp: false,
virtual_ipv4: '10.9.8.7',
network_length: 25,
hostname: 'frontend-e2e',
network_name: 'full-field-network',
network_secret: 'full-field-secret',
networking_method: NetworkingMethod.Manual,
public_server_url: 'tcp://public.example:11010',
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
enable_vpn_portal: true,
vpn_portal_listen_port: 23000,
vpn_portal_client_network_addr: '10.88.0.0',
vpn_portal_client_network_len: 24,
advanced_settings: true,
listener_urls: ['tcp://0.0.0.0:12010', 'udp://0.0.0.0:12010'],
latency_first: true,
dev_name: 'et-full',
use_smoltcp: true,
disable_ipv6: true,
enable_kcp_proxy: true,
disable_kcp_input: true,
disable_p2p: true,
bind_device: false,
no_tun: true,
enable_exit_node: true,
relay_all_peer_rpc: true,
multi_thread: false,
enable_relay_network_whitelist: true,
relay_network_whitelist: ['10.0.0.0/8', 'fd00::/8'],
enable_manual_routes: true,
routes: ['10.20.0.0/16', 'fd00:20::/64'],
exit_nodes: ['10.9.8.1', 'fd00::1'],
proxy_forward_by_system: true,
disable_encryption: true,
enable_socks5: true,
socks5_port: 1081,
disable_udp_hole_punching: true,
mtu: 1280,
mapped_listeners: ['tcp://127.0.0.1:13010'],
enable_magic_dns: true,
enable_private_mode: true,
enable_quic_proxy: true,
disable_quic_input: true,
quic_listen_port: 14010,
port_forwards: [
{
proto: 'tcp',
bind_ip: '127.0.0.1',
bind_port: 8080,
dst_ip: '10.9.8.7',
dst_port: 80,
},
{
proto: 'udp',
bind_ip: '0.0.0.0',
bind_port: 5353,
dst_ip: '10.9.8.8',
dst_port: 53,
},
],
disable_sym_hole_punching: true,
p2p_only: true,
data_compress_algo: CompressionAlgoPb.Zstd,
encryption_algorithm: 'aes-gcm',
disable_tcp_hole_punching: true,
secure_mode: {
enabled: true,
local_private_key: 'private-key',
local_public_key: 'public-key',
},
acl: {
acl_v1: {
group: {
declares: [
{
group_name: 'ops',
group_secret: 'ops-secret',
},
],
members: ['node-a', 'node-b'],
},
chains: [
{
name: 'forward-chain',
chain_type: AclChainType.Forward,
description: 'forward traffic',
enabled: true,
default_action: AclAction.Drop,
rules: [
{
name: 'allow-web',
description: 'allow web traffic',
priority: 100,
enabled: true,
protocol: AclProtocol.TCP,
ports: ['80', '443'],
source_ips: ['10.0.0.0/8'],
destination_ips: ['10.9.8.7/32'],
source_ports: ['1024-65535'],
action: AclAction.Allow,
rate_limit: 1000,
burst_limit: 2000,
stateful: true,
source_groups: ['ops'],
destination_groups: ['web'],
},
],
},
],
},
},
credential_file: '/tmp/easytier-credential.toml',
lazy_p2p: true,
need_p2p: true,
instance_recv_bps_limit: '9007199254740993',
disable_upnp: true,
ipv6_public_addr_provider: true,
ipv6_public_addr_auto: true,
ipv6_public_addr_prefix: '2001:db8:1::/64',
disable_relay_data: true,
enable_udp_broadcast_relay: true,
socket_mark: 1234,
}
}
function assertFixtureCoversGeneratedFields() {
const generatedFields = readGeneratedNetworkConfigFields()
const fixtureFields = new Set(Object.keys(allFieldFixture()))
const missing = generatedFields.filter((field) => !fixtureFields.has(field))
assert.deepEqual(missing, [], 'all generated NetworkConfig fields should be represented in the fixture')
}
function assertFullFieldRoundTrip() {
const input = allFieldFixture()
const normalized = normalizeNetworkConfig(input)
assert.equal(normalized.peer_urls.join(','), 'tcp://peer-a:11010,udp://peer-b:11010')
assert.equal(normalized.instance_recv_bps_limit, '9007199254740993')
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
const backend = toBackendNetworkConfig(normalized)
expectNoCamelCaseKeys(backend)
for (const field of readGeneratedNetworkConfigFields()) {
assert.ok(field in backend, `backend JSON should include fixture field ${field}`)
}
assert.equal(backend.networking_method, 'Manual')
assert.equal(backend.public_server_url, '')
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
assert.equal(backend.secure_mode.enabled, true)
assert.equal(backend.secure_mode.local_private_key, 'private-key')
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
assert.equal(backend.acl.acl_v1.chains[0].default_action, 'Drop')
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
assert.equal(backend.acl.acl_v1.chains[0].rules[0].action, 'Allow')
assert.equal(backend.port_forwards[1].proto, 'udp')
assert.equal(backend.socket_mark, 1234)
}
function assertBooleanFieldValuesPreserved() {
const input = allFieldFixture()
const normalized = normalizeNetworkConfig(input)
const backend = toBackendNetworkConfig(normalized)
for (const field of BOOLEAN_CONFIG_FIELDS) {
assert.equal(
normalized[field],
input[field],
`normalized config should preserve boolean field ${field}`,
)
assert.equal(
backend[field],
input[field],
`backend JSON should preserve boolean field ${field}`,
)
}
}
function assertEnumCompatibility() {
const normalized = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'Manual',
data_compress_algo: 'Zstd',
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [
{
chain_type: 'Forward',
default_action: 'Drop',
rules: [
{
protocol: 'TCP',
action: 'Allow',
},
],
},
],
},
},
})
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
assert.equal(normalized.acl.acl_v1.chains[0].default_action, AclAction.Drop)
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].action, AclAction.Allow)
const backend = toBackendNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
data_compress_algo: 'Zstd',
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [
{
chain_type: 'Forward',
default_action: 'Drop',
rules: [
{
protocol: 'TCP',
action: 'Allow',
},
],
},
],
},
},
})
assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
}
function assertAclDefaultsAndExplicitZero() {
const partialAcl = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [{ rules: [{}] }],
},
},
})
const defaultedChain = partialAcl.acl.acl_v1.chains[0]
assert.equal(defaultedChain.chain_type, AclChainType.UnspecifiedChain)
assert.equal(defaultedChain.default_action, AclAction.Allow)
assert.equal(defaultedChain.rules[0].protocol, AclProtocol.Any)
assert.equal(defaultedChain.rules[0].action, AclAction.Allow)
const explicitZero = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
acl: {
acl_v1: {
group: { declares: [], members: [] },
chains: [
{
chain_type: 0,
default_action: 0,
rules: [{ protocol: 0, action: 0 }],
},
],
},
},
})
const zeroChain = explicitZero.acl.acl_v1.chains[0]
assert.equal(zeroChain.chain_type, AclChainType.UnspecifiedChain)
assert.equal(zeroChain.default_action, AclAction.Noop)
assert.equal(zeroChain.rules[0].protocol, AclProtocol.Unspecified)
assert.equal(zeroChain.rules[0].action, AclAction.Noop)
}
function assertNetworkingMethodNormalization() {
const publicServer = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'PublicServer',
public_server_url: ' tcp://public.example:11010 ',
peer_urls: ['tcp://manual.example:11010'],
})
assert.equal(publicServer.networking_method, NetworkingMethod.Manual)
assert.equal(publicServer.public_server_url, '')
assert.deepEqual(publicServer.peer_urls, ['tcp://public.example:11010'])
const standalone = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'Standalone',
peer_urls: ['tcp://manual.example:11010'],
})
assert.equal(standalone.networking_method, NetworkingMethod.Manual)
assert.deepEqual(standalone.peer_urls, [])
const missing = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: undefined,
peer_urls: [' tcp://one ', '', 'udp://two '],
})
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
}
function assertNumberBoundaries() {
const safeLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '12345',
})
assert.equal(safeLimit.instance_recv_bps_limit, 12345)
const largeLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '9007199254740993',
})
assert.equal(largeLimit.instance_recv_bps_limit, '9007199254740993')
assert.equal(toBackendNetworkConfig(largeLimit).instance_recv_bps_limit, '9007199254740993')
const invalidNumbers = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
mtu: Number.NaN,
instance_recv_bps_limit: Number.POSITIVE_INFINITY,
})
assert.equal(invalidNumbers.mtu, null)
assert.equal(invalidNumbers.instance_recv_bps_limit, null)
const emptyLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '',
})
assert.equal(emptyLimit.instance_recv_bps_limit, null)
const zeroLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '0',
})
assert.equal(zeroLimit.instance_recv_bps_limit, null)
assert.equal(toBackendNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: 0,
}).instance_recv_bps_limit, undefined)
const oversizedLimit = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
instance_recv_bps_limit: '18446744073709551616',
})
assert.equal(oversizedLimit.instance_recv_bps_limit, null)
}
const tests = [
assertFixtureCoversGeneratedFields,
assertFullFieldRoundTrip,
assertBooleanFieldValuesPreserved,
assertEnumCompatibility,
assertAclDefaultsAndExplicitZero,
assertNetworkingMethodNormalization,
assertNumberBoundaries,
]
for (const test of tests) {
test()
console.log(`ok ${test.name}`)
}
@@ -9,7 +9,7 @@ import {
normalizeNetworkConfig,
removeRow
} from '../types/network'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AclManager from './acl/AclManager.vue'
import UrlListInput from './UrlListInput.vue'
@@ -134,7 +134,6 @@ function savePortForward() {
const portForwardContainer = ref<HTMLElement | null>(null);
const isCompact = ref(false);
const UINT64_MAX = (1n << 64n) - 1n
onMounted(() => {
if (portForwardContainer.value) {
@@ -162,39 +161,6 @@ function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
}
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
function parseInstanceRecvBpsLimitInput(value: string): number | string | null | undefined {
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
if (!/^\d+$/.test(trimmed)) {
return undefined
}
const limit = BigInt(trimmed)
if (limit === 0n) {
return null
}
if (limit > UINT64_MAX) {
return undefined
}
return limit <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(limit) : limit.toString()
}
const instanceRecvBpsLimitInput = computed<string>({
get: () => {
const limit = curNetwork.value.instance_recv_bps_limit
return limit == null ? '' : String(limit)
},
set: (value) => {
const limit = parseInstanceRecvBpsLimitInput(value)
if (limit !== undefined) {
curNetwork.value.instance_recv_bps_limit = limit
}
},
})
</script>
<template>
@@ -351,9 +317,9 @@ const instanceRecvBpsLimitInput = computed<string>({
<span class="pi pi-question-circle ml-2 self-center"
v-tooltip="t('instance_recv_bps_limit_help')"></span>
</div>
<InputText id="instance_recv_bps_limit" v-model="instanceRecvBpsLimitInput"
aria-describedby="instance_recv_bps_limit-help" inputmode="numeric" pattern="[0-9]*"
:placeholder="t('instance_recv_bps_limit_placeholder')" fluid />
<InputNumber id="instance_recv_bps_limit" v-model="curNetwork.instance_recv_bps_limit"
aria-describedby="instance_recv_bps_limit-help" :format="false"
:placeholder="t('instance_recv_bps_limit_placeholder')" :min="1" fluid />
</div>
</div>
+172 -96
View File
@@ -1,49 +1,166 @@
import { v4 as uuidv4 } from 'uuid'
import {
NetworkConfig as NetworkConfigPb,
NetworkingMethod,
type NetworkConfig as ProtoNetworkConfig,
type PortForwardConfig,
} from '../generated/proto/api_manage'
import {
Action as AclAction,
ChainType as AclChainType,
Protocol as AclProtocol,
type Acl,
type AclV1,
type Chain as AclChain,
type GroupIdentity,
type GroupInfo,
type Rule as AclRule,
} from '../generated/proto/acl'
import { CompressionAlgoPb, NatType, type SecureModeConfig } from '../generated/proto/common'
import { prepareNetworkConfigForProtoJson } from './networkCompat'
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig }
export type NetworkConfig = Omit<
ProtoNetworkConfig,
'instance_id' | 'instance_recv_bps_limit' | 'mtu' | 'networking_method'
> & {
instance_id: string
mtu: number | null
instance_recv_bps_limit: number | string | null
networking_method: NetworkingMethod | string
export enum NetworkingMethod {
PublicServer = 0,
Manual = 1,
Standalone = 2,
}
const UINT64_MAX = (1n << 64n) - 1n
export interface SecureModeConfig {
enabled: boolean
// Keep protocol compatibility with backend/import-export flows even though the GUI
// does not render secure-mode or credential inputs.
local_private_key?: string
local_public_key?: string
}
interface NetworkingConfigFields {
export enum AclProtocol {
Unspecified = 0,
TCP = 1,
UDP = 2,
ICMP = 3,
ICMPv6 = 4,
Any = 5,
}
export enum AclAction {
Noop = 0,
Allow = 1,
Drop = 2,
}
export enum AclChainType {
UnspecifiedChain = 0,
Inbound = 1,
Outbound = 2,
Forward = 3,
}
export interface AclRule {
name: string
description: string
priority: number
enabled: boolean
protocol: AclProtocol
ports: string[]
source_ips: string[]
destination_ips: string[]
source_ports: string[]
action: AclAction
rate_limit: number
burst_limit: number
stateful: boolean
source_groups: string[]
destination_groups: string[]
}
export interface AclChain {
name: string
chain_type: AclChainType
description: string
enabled: boolean
rules: AclRule[]
default_action: AclAction
}
export interface GroupIdentity {
group_name: string
group_secret: string
}
export interface GroupInfo {
declares: GroupIdentity[]
members: string[]
}
export interface AclV1 {
chains: AclChain[]
group?: GroupInfo
}
export interface Acl {
acl_v1?: AclV1
}
export interface NetworkConfig {
instance_id: string
dhcp: boolean
virtual_ipv4: string
network_length: number
hostname?: string
network_name: string
network_secret?: string
credential_file?: string
secure_mode?: SecureModeConfig
networking_method: NetworkingMethod
public_server_url: string
peer_urls: string[]
public_server_url?: string
networking_method?: NetworkingMethod | string
proxy_cidrs: string[]
enable_vpn_portal: boolean
vpn_portal_listen_port: number
vpn_portal_client_network_addr: string
vpn_portal_client_network_len: number
advanced_settings: boolean
listener_urls: string[]
latency_first: boolean
dev_name: string
use_smoltcp?: boolean
disable_ipv6?: boolean
ipv6_public_addr_auto?: boolean
enable_kcp_proxy?: boolean
disable_kcp_input?: boolean
enable_quic_proxy?: boolean
disable_quic_input?: boolean
disable_p2p?: boolean
p2p_only?: boolean
lazy_p2p?: boolean
bind_device?: boolean
no_tun?: boolean
enable_exit_node?: boolean
relay_all_peer_rpc?: boolean
need_p2p?: boolean
multi_thread?: boolean
proxy_forward_by_system?: boolean
disable_encryption?: boolean
disable_tcp_hole_punching?: boolean
disable_udp_hole_punching?: boolean
disable_upnp?: boolean
enable_udp_broadcast_relay?: boolean
disable_sym_hole_punching?: boolean
enable_relay_network_whitelist?: boolean
relay_network_whitelist: string[]
enable_manual_routes: boolean
routes: string[]
exit_nodes: string[]
enable_socks5?: boolean
socks5_port: number
mtu: number | null
instance_recv_bps_limit: number | null
mapped_listeners: string[]
enable_magic_dns?: boolean
enable_private_mode?: boolean
port_forwards: PortForwardConfig[]
acl?: Acl
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
return {
...NetworkConfigPb.create(),
instance_id: uuidv4(),
dhcp: true,
@@ -126,82 +243,33 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
}
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
if (v == null) return null
try {
const n = typeof v === 'bigint' ? v : BigInt(v)
if (n === 0n || n > UINT64_MAX) return null
return n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : n.toString()
} catch {
return null
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
const normalized: NetworkConfig = {
...config,
peer_urls: cleanPeerUrls(config.peer_urls),
}
}
function normalizeNumberForInput(v: number | string | null | undefined): number | null {
if (v == null) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
function toBackendUint64(v: number | bigint | string | null | undefined): bigint | undefined {
if (v == null || v === '') return undefined
try {
const n = typeof v === 'bigint' ? v : BigInt(v)
return n > 0n && n <= UINT64_MAX ? n : undefined
} catch {
return undefined
}
}
function applyNetworkingMethod(config: NetworkingConfigFields): void {
config.peer_urls = cleanPeerUrls(config.peer_urls)
const publicServerUrl = config.public_server_url?.trim() ?? ''
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
switch (networkingMethod) {
switch (normalized.networking_method) {
case NetworkingMethod.PublicServer:
config.peer_urls = publicServerUrl ? [publicServerUrl] : []
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
break
case NetworkingMethod.Manual:
break
case NetworkingMethod.Standalone:
default:
config.peer_urls = []
normalized.peer_urls = []
break
}
config.networking_method = NetworkingMethod.Manual
config.public_server_url = ''
}
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
const normalized = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
ignoreUnknownFields: true,
}) as unknown as NetworkConfig
applyNetworkingMethod(normalized)
normalized.mtu = normalizeNumberForInput(normalized.mtu)
normalized.instance_recv_bps_limit = normalizeUint64ForInput(
normalized.instance_recv_bps_limit as any,
)
normalized.networking_method = NetworkingMethod.Manual
normalized.public_server_url = ''
return normalized
}
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
const backend = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
ignoreUnknownFields: true,
})
applyNetworkingMethod(backend)
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
return NetworkConfigPb.toJson(backend, {
useProtoFieldName: true,
}) as unknown as NetworkConfig
return normalizeNetworkConfig(config)
}
export interface NetworkInstance {
@@ -329,6 +397,14 @@ export interface PeerConnStats {
latency_us: number
}
export interface PortForwardConfig {
bind_ip: string,
bind_port: number,
dst_ip: string,
dst_port: number,
proto: string
}
// 添加新行
export const addRow = (rows: PortForwardConfig[]) => {
rows.push({
@@ -1,85 +0,0 @@
import {
Action as AclAction,
ChainType as AclChainType,
Protocol as AclProtocol,
} from '../generated/proto/acl'
import type { NetworkConfig } from './network'
const UINT64_MAX = (1n << 64n) - 1n
type JsonRecord = Record<string, unknown>
export function prepareNetworkConfigForProtoJson(config: NetworkConfig): NetworkConfig {
const prepared = dropUnsupportedJsonValues(applyLegacyAclDefaults(config)) as NetworkConfig
normalizeLegacyOptionalUint64(prepared as JsonRecord, 'instance_recv_bps_limit')
return prepared
}
function applyLegacyAclDefaults(config: NetworkConfig): NetworkConfig {
const acl = config.acl
const aclV1 = acl?.acl_v1
if (!Array.isArray(aclV1?.chains)) return config
return {
...config,
acl: {
...acl,
acl_v1: {
...aclV1,
chains: aclV1.chains.map((chain) => ({
...chain,
chain_type: chain.chain_type ?? AclChainType.UnspecifiedChain,
default_action: chain.default_action ?? AclAction.Allow,
rules: (chain.rules ?? []).map((rule) => ({
...rule,
protocol: rule.protocol ?? AclProtocol.Any,
action: rule.action ?? AclAction.Allow,
})),
})),
},
},
}
}
function dropUnsupportedJsonValues(value: unknown): unknown {
if (value === undefined) return undefined
if (typeof value === 'number' && !Number.isFinite(value)) return undefined
if (Array.isArray(value)) {
return value.map(dropUnsupportedJsonValues).filter((v) => v !== undefined)
}
if (isJsonRecord(value)) {
return Object.fromEntries(
Object.entries(value)
.map(([k, v]) => [k, dropUnsupportedJsonValues(v)])
.filter(([, v]) => v !== undefined),
)
}
return value
}
function isJsonRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null
}
function normalizeLegacyOptionalUint64(obj: JsonRecord, key: string): void {
const value = obj[key]
if (typeof value !== 'string') return
const trimmed = value.trim()
if (!isPositiveUint64String(trimmed)) {
delete obj[key]
return
}
obj[key] = trimmed
}
function isPositiveUint64String(value: string): boolean {
if (!/^\d+$/.test(value)) return false
const n = BigInt(value)
return n > 0n && n <= UINT64_MAX
}
@@ -1,563 +0,0 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, reactive } from 'vue'
import Config from '../src/components/Config.vue'
import {
DEFAULT_NETWORK_CONFIG,
toBackendNetworkConfig,
type NetworkConfig,
} from '../src/types/network'
const CONFIG_FLAG_FIELDS = [
'latency_first',
'use_smoltcp',
'disable_ipv6',
'ipv6_public_addr_auto',
'enable_kcp_proxy',
'disable_kcp_input',
'enable_quic_proxy',
'disable_quic_input',
'disable_p2p',
'p2p_only',
'lazy_p2p',
'bind_device',
'no_tun',
'enable_exit_node',
'relay_all_peer_rpc',
'need_p2p',
'multi_thread',
'proxy_forward_by_system',
'disable_encryption',
'disable_tcp_hole_punching',
'disable_udp_hole_punching',
'enable_udp_broadcast_relay',
'disable_upnp',
'disable_sym_hole_punching',
'enable_magic_dns',
'enable_private_mode',
] as const satisfies readonly (keyof NetworkConfig)[]
const CONFIG_CHECKBOX_FIELDS = [
['dhcp', '#virtual_ip_auto'],
...CONFIG_FLAG_FIELDS.map((field) => [field, `#${field}`] as const),
] as const satisfies readonly (readonly [keyof NetworkConfig, string])[]
const CONFIG_TOGGLE_FIELDS = [
'enable_vpn_portal',
'enable_relay_network_whitelist',
'enable_manual_routes',
'enable_socks5',
] as const satisfies readonly (keyof NetworkConfig)[]
const CONFIG_UI_BOOLEAN_FIELDS = [
...CONFIG_CHECKBOX_FIELDS.map(([field]) => field),
...CONFIG_TOGGLE_FIELDS,
] as const satisfies readonly (keyof NetworkConfig)[]
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, values?: unknown[]) => values ? `${key}:${values.join(',')}` : key,
}),
}))
const PassThrough = defineComponent({
name: 'PassThrough',
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
const PanelStub = defineComponent({
name: 'Panel',
props: {
header: String,
},
setup(props, { slots }) {
return () => h('section', { 'data-stub': 'panel', 'data-header': props.header }, slots.default?.())
},
})
const DividerStub = defineComponent({
name: 'Divider',
setup() {
return () => h('hr', { 'data-stub': 'divider' })
},
})
function splitList(value: string): string[] {
return value.split(',').map((item) => item.trim()).filter((item) => item.length > 0)
}
const InputTextStub = defineComponent({
name: 'InputText',
props: {
modelValue: [String, Number],
id: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
disabled: props.disabled,
value: props.modelValue ?? '',
'data-stub': 'input-text',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
})
const PasswordStub = defineComponent({
name: 'Password',
props: {
modelValue: [String, Number],
id: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
disabled: props.disabled,
type: 'password',
value: props.modelValue ?? '',
'data-stub': 'password',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
})
const InputNumberStub = defineComponent({
name: 'InputNumber',
props: {
modelValue: Number,
id: String,
inputId: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id ?? props.inputId,
disabled: props.disabled,
type: 'number',
value: props.modelValue ?? '',
'data-stub': 'input-number',
onInput: (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value === '' ? null : Number(value))
},
})
},
})
const CheckboxStub = defineComponent({
name: 'Checkbox',
props: {
modelValue: Boolean,
inputId: String,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.inputId,
checked: props.modelValue,
type: 'checkbox',
'data-stub': 'checkbox',
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
})
},
})
const ToggleButtonStub = defineComponent({
name: 'ToggleButton',
props: {
modelValue: Boolean,
onIcon: String,
offIcon: String,
onLabel: String,
offLabel: String,
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('button', {
type: 'button',
'aria-pressed': String(Boolean(props.modelValue)),
'data-stub': 'toggle-button',
onClick: () => emit('update:modelValue', !props.modelValue),
}, props.modelValue ? props.onLabel : props.offLabel)
},
})
const AutoCompleteStub = defineComponent({
name: 'AutoComplete',
props: {
modelValue: Array,
id: String,
multiple: Boolean,
},
emits: ['update:modelValue', 'complete'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
value: (props.modelValue ?? []).join(','),
'data-stub': 'auto-complete',
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
})
},
})
const UrlListInputStub = defineComponent({
name: 'UrlListInput',
props: {
modelValue: Array,
id: String,
addLabel: String,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.id,
value: (props.modelValue ?? []).join(','),
'data-stub': 'url-list-input',
'data-add-label': props.addLabel,
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
})
},
})
const SelectButtonStub = defineComponent({
name: 'SelectButton',
props: {
modelValue: String,
options: Array,
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('select', {
value: props.modelValue,
'data-stub': 'select-button',
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
}, (props.options ?? []).map((option) => h('option', { value: option as string }, option as string)))
},
})
const ButtonStub = defineComponent({
name: 'Button',
props: {
label: String,
icon: String,
disabled: Boolean,
},
emits: ['click'],
setup(props, { slots, emit }) {
return () => h('button', {
type: 'button',
disabled: props.disabled,
'data-label': props.label ?? props.icon,
onClick: (event: MouseEvent) => emit('click', event),
}, slots.default?.() ?? props.label ?? props.icon)
},
})
const DialogStub = defineComponent({
name: 'Dialog',
props: {
visible: Boolean,
},
setup(props, { slots }) {
return () => h('div', { hidden: !props.visible, 'data-stub': 'dialog' }, [
slots.default?.(),
slots.footer?.(),
])
},
})
const AclManagerStub = defineComponent({
name: 'AclManager',
props: {
modelValue: Object,
},
emits: ['update:modelValue'],
setup(props) {
return () => h('pre', { 'data-stub': 'acl-manager' }, JSON.stringify(props.modelValue))
},
})
function makeConfig(): NetworkConfig {
const config = DEFAULT_NETWORK_CONFIG()
return {
...config,
dhcp: false,
virtual_ipv4: '10.1.2.3',
network_length: 24,
network_name: 'mesh-a',
network_secret: 'secret-a',
peer_urls: ['tcp://peer-a:11010', 'udp://peer-b:11010'],
latency_first: true,
use_smoltcp: true,
disable_ipv6: true,
no_tun: true,
hostname: 'host-a',
proxy_cidrs: ['10.10.0.0/16', '172.16.1.0/24'],
enable_vpn_portal: true,
vpn_portal_client_network_addr: '10.144.0.0',
vpn_portal_listen_port: 22023,
listener_urls: ['tcp://0.0.0.0:12010'],
dev_name: 'tun-test',
mtu: 1280,
instance_recv_bps_limit: '9007199254740993',
enable_relay_network_whitelist: true,
relay_network_whitelist: ['network-a'],
enable_manual_routes: true,
routes: ['192.168.0.0/16'],
enable_socks5: true,
socks5_port: 1086,
exit_nodes: ['exit-a'],
mapped_listeners: ['tcp://127.0.0.1:22000'],
port_forwards: [{
proto: 'udp',
bind_ip: '0.0.0.0',
bind_port: 18080,
dst_ip: '10.0.0.2',
dst_port: 8080,
}],
}
}
function mountConfig(config: NetworkConfig = makeConfig()) {
const curNetwork = reactive(config) as NetworkConfig
const wrapper = mount(Config, {
props: {
curNetwork,
hostname: 'host-from-prop',
},
global: {
directives: {
tooltip: () => {},
},
stubs: {
AclManager: AclManagerStub,
AutoComplete: AutoCompleteStub,
Button: ButtonStub,
Checkbox: CheckboxStub,
Dialog: DialogStub,
Divider: DividerStub,
InputGroup: PassThrough,
InputGroupAddon: PassThrough,
InputNumber: InputNumberStub,
InputText: InputTextStub,
Panel: PanelStub,
Password: PasswordStub,
SelectButton: SelectButtonStub,
ToggleButton: ToggleButtonStub,
UrlListInput: UrlListInputStub,
},
},
})
return { curNetwork, wrapper }
}
function input(wrapper: VueWrapper, selector: string): HTMLInputElement {
return wrapper.find(selector).element as HTMLInputElement
}
async function setInput(wrapper: VueWrapper, selector: string, value: string) {
await wrapper.find(selector).setValue(value)
await nextTick()
}
describe('Config.vue network config projection', () => {
it('projects config values into the visible form controls', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
expect(input(wrapper, '#network_name').value).toBe('mesh-a')
expect(input(wrapper, '#network_secret').value).toBe('secret-a')
expect(input(wrapper, '#virtual_ip').value).toBe('10.1.2.3')
expect(input(wrapper, '#initial_nodes').value).toBe('tcp://peer-a:11010,udp://peer-b:11010')
expect(input(wrapper, '#virtual_ip_auto').checked).toBe(false)
expect(input(wrapper, '#latency_first').checked).toBe(true)
expect(input(wrapper, '#use_smoltcp').checked).toBe(true)
expect(input(wrapper, '#disable_ipv6').checked).toBe(true)
expect(input(wrapper, '#no_tun').checked).toBe(true)
expect(input(wrapper, '#hostname').value).toBe('host-a')
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
expect(input(wrapper, 'input[placeholder="vpn_portal_client_network"]').value).toBe('10.144.0.0')
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
expect(input(wrapper, '#mtu').value).toBe('1280')
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993')
expect(input(wrapper, '#relay_network_whitelist').value).toBe('network-a')
expect(input(wrapper, '#routes').value).toBe('192.168.0.0/16')
expect(input(wrapper, '#socks5_port').value).toBe('1086')
expect(input(wrapper, '#exit_nodes').value).toBe('exit-a')
expect(input(wrapper, 'input[data-add-label="add_listener_url"]').value).toBe('tcp://0.0.0.0:12010')
expect(input(wrapper, 'input[data-add-label="add_mapped_listener"]').value).toBe('tcp://127.0.0.1:22000')
expect(wrapper.find<HTMLSelectElement>('select[data-stub="select-button"]').element.value).toBe('udp')
expect(input(wrapper, 'input[placeholder="port_forwards_bind_addr"]').value).toBe('0.0.0.0')
expect(input(wrapper, 'input[placeholder="port_forwards_dst_addr"]').value).toBe('10.0.0.2')
expect(wrapper.findComponent(AclManagerStub).props('modelValue')).toStrictEqual(curNetwork.acl)
})
it('projects form edits back into config and backend JSON', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
await wrapper.find('#virtual_ip_auto').setValue(false)
await setInput(wrapper, '#network_name', 'mesh-edited')
await setInput(wrapper, '#network_secret', 'secret-edited')
await setInput(wrapper, '#virtual_ip', '10.7.7.7')
await setInput(wrapper, '#initial_nodes', ' tcp://peer-x:11010, , udp://peer-y:11010 ')
await wrapper.find('#no_tun').setValue(false)
await wrapper.find('#disable_ipv6').setValue(false)
await setInput(wrapper, '#hostname', 'host-edited')
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
await setInput(wrapper, 'input[placeholder="vpn_portal_client_network"]', '10.200.0.0')
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
await setInput(wrapper, '#dev_name', 'tun-edited')
await setInput(wrapper, '#mtu', '1260')
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
await setInput(wrapper, '#relay_network_whitelist', 'network-edited')
await setInput(wrapper, '#routes', '192.168.10.0/24')
await setInput(wrapper, '#socks5_port', '1089')
await setInput(wrapper, '#exit_nodes', 'exit-edited')
await setInput(wrapper, 'input[data-add-label="add_mapped_listener"]', 'tcp://127.0.0.1:23000')
await wrapper.find('select[data-stub="select-button"]').setValue('tcp')
await setInput(wrapper, 'input[placeholder="port_forwards_bind_addr"]', '127.0.0.1')
await setInput(wrapper, 'input[placeholder="port_forwards_dst_addr"]', '10.9.0.2')
const portNumbers = wrapper.findAll<HTMLInputElement>('input#horizontal-buttons')
await portNumbers[1].setValue('19090')
await portNumbers[2].setValue('9090')
expect(curNetwork).toMatchObject({
dhcp: false,
virtual_ipv4: '10.7.7.7',
network_name: 'mesh-edited',
network_secret: 'secret-edited',
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
no_tun: false,
disable_ipv6: false,
hostname: 'host-edited',
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
vpn_portal_client_network_addr: '10.200.0.0',
listener_urls: ['tcp://0.0.0.0:13010'],
dev_name: 'tun-edited',
mtu: 1260,
instance_recv_bps_limit: '9007199254740993',
relay_network_whitelist: ['network-edited'],
routes: ['192.168.10.0/24'],
socks5_port: 1089,
exit_nodes: ['exit-edited'],
mapped_listeners: ['tcp://127.0.0.1:23000'],
port_forwards: [{
proto: 'tcp',
bind_ip: '127.0.0.1',
bind_port: 19090,
dst_ip: '10.9.0.2',
dst_port: 9090,
}],
})
const backend = toBackendNetworkConfig(curNetwork)
expect(backend).toMatchObject({
virtual_ipv4: '10.7.7.7',
network_name: 'mesh-edited',
network_secret: 'secret-edited',
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
listener_urls: ['tcp://0.0.0.0:13010'],
mtu: 1260,
instance_recv_bps_limit: '9007199254740993',
port_forwards: [{
proto: 'tcp',
bind_ip: '127.0.0.1',
bind_port: 19090,
dst_ip: '10.9.0.2',
dst_port: 9090,
}],
})
})
it('round-trips every visible boolean config control into backend JSON', async () => {
const config = makeConfig()
const originalFlagValues = new Map(
CONFIG_UI_BOOLEAN_FIELDS.map((field, index) => {
const value = index % 2 === 0
config[field] = value
return [field, value]
}),
)
const { curNetwork, wrapper } = mountConfig(config)
await nextTick()
for (const [field, selector] of CONFIG_CHECKBOX_FIELDS) {
const value = originalFlagValues.get(field)
expect(input(wrapper, selector).checked, `${field} should project into UI`).toBe(value)
await wrapper.find(selector).setValue(!value)
await nextTick()
}
const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]')
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length)
for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) {
const value = originalFlagValues.get(field)
expect(toggleButtons[index].attributes('aria-pressed'), `${field} should project into UI`)
.toBe(String(value))
await toggleButtons[index].trigger('click')
await nextTick()
}
const backend = toBackendNetworkConfig(curNetwork) as Record<string, unknown>
for (const [field, value] of originalFlagValues) {
const expectedValue = !value
expect(curNetwork[field], `${field} should update config`).toBe(expectedValue)
expect(backend[field], `${field} should be preserved in backend JSON`).toBe(expectedValue)
}
})
it('keeps uint64 input editable without losing large values', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
await setInput(wrapper, '#instance_recv_bps_limit', '1234')
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
await setInput(wrapper, '#instance_recv_bps_limit', 'not-a-number')
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
await setInput(wrapper, '#instance_recv_bps_limit', '0')
expect(curNetwork.instance_recv_bps_limit).toBeNull()
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('')
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
await setInput(wrapper, '#instance_recv_bps_limit', '18446744073709551616')
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
await setInput(wrapper, '#instance_recv_bps_limit', '')
expect(curNetwork.instance_recv_bps_limit).toBeNull()
})
it('emits runNetwork with the current projected config', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
await setInput(wrapper, '#network_name', 'mesh-running')
await wrapper.find('button[data-label="run_network"]').trigger('click')
expect(wrapper.emitted('runNetwork')?.[0]).toEqual([curNetwork])
expect((wrapper.emitted('runNetwork')?.[0][0] as NetworkConfig).network_name).toBe('mesh-running')
})
})
@@ -1,228 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import RemoteManagement from '../src/components/RemoteManagement.vue'
import {
DEFAULT_NETWORK_CONFIG,
type NetworkConfig,
} from '../src/types/network'
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'enable_vpn_portal',
'advanced_settings',
'latency_first',
'use_smoltcp',
'disable_ipv6',
'enable_kcp_proxy',
'disable_kcp_input',
'disable_p2p',
'bind_device',
'no_tun',
'enable_exit_node',
'relay_all_peer_rpc',
'multi_thread',
'enable_relay_network_whitelist',
'enable_manual_routes',
'proxy_forward_by_system',
'disable_encryption',
'enable_socks5',
'disable_udp_hole_punching',
'enable_magic_dns',
'enable_private_mode',
'enable_quic_proxy',
'disable_quic_input',
'disable_sym_hole_punching',
'p2p_only',
'lazy_p2p',
'need_p2p',
'disable_upnp',
'ipv6_public_addr_provider',
'ipv6_public_addr_auto',
'disable_relay_data',
'enable_udp_broadcast_relay',
'disable_tcp_hole_punching',
] as const satisfies readonly (keyof NetworkConfig)[]
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
vi.mock('primevue', async () => {
const { defineComponent, h } = await import('vue')
const PassThrough = defineComponent({
name: 'PassThrough',
props: {
label: String,
value: String,
},
setup(props, { slots }) {
return () => h('div', {
'data-label': props.label,
'data-value': props.value,
'data-stub': 'pass-through',
}, slots.default?.())
},
})
const ButtonStub = defineComponent({
name: 'Button',
props: {
label: String,
icon: String,
disabled: Boolean,
},
emits: ['click'],
setup(props, { slots, emit }) {
return () => h('button', {
type: 'button',
disabled: props.disabled,
'data-label': props.label ?? props.icon,
onClick: (event: MouseEvent) => emit('click', event),
}, slots.default?.() ?? props.label ?? props.icon)
},
})
const SelectStub = defineComponent({
name: 'Select',
props: {
modelValue: Object,
options: Array,
},
emits: ['update:modelValue'],
setup(props, { slots }) {
return () => h('div', { 'data-stub': 'select' }, [
slots.value?.({ value: props.modelValue, placeholder: '' }),
])
},
})
const MenuStub = defineComponent({
name: 'Menu',
setup(_, { expose }) {
expose({ toggle: vi.fn() })
return () => h('div', { 'data-stub': 'menu' })
},
})
return {
Button: ButtonStub,
ConfirmPopup: PassThrough,
Divider: PassThrough,
IftaLabel: PassThrough,
Menu: MenuStub,
Message: PassThrough,
Select: SelectStub,
Tag: PassThrough,
useConfirm: () => ({ require: vi.fn() }),
useToast: () => ({ add: vi.fn() }),
}
})
const INSTANCE_ID = '00000000-0000-0000-0000-000000000001'
const INSTANCE_UUID = {
part1: 0,
part2: 0,
part3: 0,
part4: 1,
}
function makeFlagConfig(): NetworkConfig {
const config = {
...DEFAULT_NETWORK_CONFIG(),
instance_id: INSTANCE_ID,
network_name: 'mesh-save',
}
BOOLEAN_CONFIG_FIELDS.forEach((field, index) => {
config[field] = index % 2 === 0
})
return config
}
function cloneConfig(config: NetworkConfig): NetworkConfig {
return JSON.parse(JSON.stringify(config)) as NetworkConfig
}
function snapshotBooleanConfigFields(config: NetworkConfig): Record<string, unknown> {
return Object.fromEntries(
BOOLEAN_CONFIG_FIELDS.map((field) => [field, config[field]]),
)
}
async function settleRemoteManagement() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
await nextTick()
}
}
describe('RemoteManagement config save', () => {
it('saves the current network config without dropping boolean fields', async () => {
const config = makeFlagConfig()
const expectedFlags = snapshotBooleanConfigFields(config)
const api = {
delete_network: vi.fn(),
generate_config: vi.fn(),
get_network_config: vi.fn(async () => cloneConfig(config)),
get_network_info: vi.fn(),
get_network_metas: vi.fn(async (instanceIds: string[]) => ({
metas: Object.fromEntries(instanceIds.map((id) => [id, {
config_permission: 0xffffffff,
inst_id: INSTANCE_UUID,
instance_name: 'mesh-save',
network_name: 'mesh-save',
source: 2,
}])),
})),
list_network_instance_ids: vi.fn(async () => ({
disabled_inst_ids: [INSTANCE_UUID],
running_inst_ids: [],
})),
parse_config: vi.fn(),
run_network: vi.fn(),
save_config: vi.fn(async () => undefined),
update_network_instance_state: vi.fn(),
validate_config: vi.fn(),
}
const wrapper = mount(RemoteManagement, {
props: {
api,
instanceId: INSTANCE_ID,
},
global: {
stubs: {
Config: true,
ConfigEditDialog: true,
Status: true,
},
},
})
try {
await settleRemoteManagement()
const saveButton = wrapper.find('button[data-label="web.device_management.save_config"]')
expect(saveButton.exists()).toBe(true)
expect(saveButton.attributes('disabled')).toBeUndefined()
await saveButton.trigger('click')
await flushPromises()
expect(api.save_config).toHaveBeenCalledOnce()
const savedConfig = api.save_config.mock.calls[0][0] as NetworkConfig
for (const field of BOOLEAN_CONFIG_FIELDS) {
expect(savedConfig[field], `${field} should be saved`).toBe(expectedFlags[field])
}
} finally {
wrapper.unmount()
}
})
})
-9
View File
@@ -1,9 +0,0 @@
import { vi } from 'vitest'
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -1,12 +0,0 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import ViteYaml from '@modyfi/vite-plugin-yaml'
export default defineConfig({
plugins: [vue(), ViteYaml()],
test: {
environment: 'happy-dom',
include: ['tests/**/*.spec.ts'],
setupFiles: ['./tests/setup.ts'],
},
})
+3 -3
View File
@@ -4,8 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "pnpm --dir ../frontend-lib build && vite",
"build": "pnpm --dir ../frontend-lib build && vue-tsc -b && vite build",
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
@@ -32,4 +32,4 @@
"vite-plugin-singlefile": "^2.0.3",
"vue-tsc": "^2.1.10"
}
}
}
+1 -15
View File
@@ -52,8 +52,6 @@ toml = "0.8.12"
chrono = { version = "0.4.37", features = ["serde"] }
guarden = "0.2"
hotpath = { version = "0.18", default-features = false, optional = true }
quanta = "0.12"
delegate = "0.13.5"
@@ -85,7 +83,6 @@ 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 = [
@@ -132,7 +129,7 @@ once_cell = "1.18.0"
# for rpc
prost = "0.14.3"
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive", "serde"] }
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive"] }
prost-wkt-types = "0.7.1"
pbjson = "0.9.0"
@@ -362,7 +359,6 @@ default = [
"faketcp",
"magic-dns",
"zstd",
"udp-gso",
]
full = [
"websocket",
@@ -380,7 +376,6 @@ 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"]
@@ -406,15 +401,6 @@ jemalloc-prof = [
"jemalloc-sys/stats",
]
tracing = ["tokio/tracing", "dep:console-subscriber"]
hotpath = [
"dep:hotpath",
"hotpath/hotpath",
"hotpath/tokio",
"hotpath/parking_lot",
"hotpath/flume",
]
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
magic-dns = ["dep:hickory-client", "dep:hickory-server"]
faketcp = ["dep:flume"]
zstd = ["dep:zstd"]
-232
View File
@@ -1,232 +0,0 @@
//! 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
}
+5 -7
View File
@@ -3,11 +3,9 @@ use std::{
net::{IpAddr, SocketAddr},
str::FromStr as _,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use hotpath::instant::Instant;
use crate::common::{config::ConfigLoader, global_ctx::ArcGlobalCtx, token_bucket::TokenBucket};
use crate::proto::acl::*;
use anyhow::Context as _;
@@ -109,10 +107,10 @@ impl AclCacheKey {
// Cache entry with timestamp for LRU cleanup
#[derive(Debug, Clone)]
pub(crate) struct AclCacheEntry {
pub struct AclCacheEntry {
pub action: Action,
pub matched_rule: RuleId,
pub last_access: Instant,
pub last_access: std::time::Instant,
// New fields to track rule characteristics for proper cache behavior
pub conn_track_key: Option<String>,
pub rate_limit_keys: Vec<RateLimitKey>,
@@ -412,7 +410,7 @@ impl AclProcessor {
}
// Remove oldest entries (LRU cleanup)
let mut entries: Vec<(AclCacheKey, Instant)> = cache
let mut entries: Vec<(AclCacheKey, std::time::Instant)> = cache
.iter()
.map(|entry| (entry.key().clone(), entry.value().last_access))
.collect();
@@ -433,7 +431,7 @@ impl AclProcessor {
);
}
pub(crate) fn process_packet_with_cache_entry(
pub fn process_packet_with_cache_entry(
&self,
packet_info: &PacketInfo,
cache_entry: &AclCacheEntry,
-1
View File
@@ -129,7 +129,6 @@ 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() {
+20 -93
View File
@@ -10,7 +10,6 @@ use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, Report
use base64::{Engine as _, prelude::BASE64_STANDARD};
use clap::ValueEnum;
use clap::builder::PossibleValue;
use prost_reflect::{DynamicMessage, ReflectMessage, SerializeOptions};
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, VariantArray};
use tokio::io::AsyncReadExt as _;
@@ -79,54 +78,6 @@ pub fn gen_default_flags() -> Flags {
}
}
fn flags_to_dynamic_message(flags: &Flags) -> DynamicMessage {
let mut message = DynamicMessage::new(flags.descriptor());
message
.transcode_from(flags)
.expect("FlagsInConfig should transcode to DynamicMessage");
message
}
fn flags_to_full_json_map(flags: &DynamicMessage) -> serde_json::Map<String, serde_json::Value> {
let options = SerializeOptions::new()
.use_proto_field_name(true)
.skip_default_fields(false);
match flags
.serialize_with_options(serde_json::value::Serializer, &options)
.expect("FlagsInConfig should serialize to JSON")
{
serde_json::Value::Object(map) => map,
_ => unreachable!("FlagsInConfig should serialize to a JSON object"),
}
}
fn flags_diff_from_default(flags: &Flags) -> serde_json::Map<String, serde_json::Value> {
let default_flags = gen_default_flags();
let default_message = flags_to_dynamic_message(&default_flags);
let current_message = flags_to_dynamic_message(flags);
let default_map = flags_to_full_json_map(&default_message);
let current_map = flags_to_full_json_map(&current_message);
current_message
.descriptor()
.fields()
.filter_map(|field| {
let key = field.name();
let value_changed = default_map.get(key) != current_map.get(key);
let presence_changed =
default_message.has_field(&field) != current_message.has_field(&field);
if value_changed || presence_changed {
current_map
.get(key)
.map(|value| (key.to_string(), value.clone()))
} else {
None
}
})
.collect()
}
fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool {
TunnelScheme::try_from(url)
.ok()
@@ -1142,9 +1093,28 @@ impl ConfigLoader for TomlConfigLoader {
}
fn dump(&self) -> String {
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
let default_flags_hashmap =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&default_flags_json)
.unwrap();
let cur_flags_json = serde_json::to_string(&self.get_flags()).unwrap();
let cur_flags_hashmap =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&cur_flags_json)
.unwrap();
let mut flag_map: serde_json::Map<String, serde_json::Value> = Default::default();
for (key, value) in default_flags_hashmap {
if let Some(v) = cur_flags_hashmap.get(&key)
&& *v != value
{
flag_map.insert(key, v.clone());
}
}
let mut config = self.config.lock().unwrap().clone();
Self::normalize_config_source(&mut config);
config.flags = Some(flags_diff_from_default(&self.get_flags()));
config.flags = Some(flag_map);
if config.stun_servers == Some(StunInfoCollector::get_default_servers()) {
config.stun_servers = None;
}
@@ -1490,49 +1460,6 @@ socket_mark = 66
assert_eq!(cfg.get_flags().socket_mark, None);
}
#[test]
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
let cfg = TomlConfigLoader::default();
let mut flags = gen_default_flags();
flags.dev_name = "et_test".to_string();
flags.enable_quic_proxy = true;
flags.disable_tcp_hole_punching = true;
flags.disable_sym_hole_punching = true;
flags.multi_thread = false;
flags.bind_device = false;
flags.enable_ipv6 = false;
flags.relay_network_whitelist = "".to_string();
flags.mtu = 0;
flags.socket_mark = Some(0);
cfg.set_flags(flags);
let dumped = cfg.dump();
assert!(dumped.contains("dev_name = \"et_test\""));
assert!(dumped.contains("enable_quic_proxy = true"));
assert!(dumped.contains("disable_tcp_hole_punching = true"));
assert!(dumped.contains("disable_sym_hole_punching = true"));
assert!(dumped.contains("multi_thread = false"));
assert!(dumped.contains("bind_device = false"));
assert!(dumped.contains("enable_ipv6 = false"));
assert!(dumped.contains("relay_network_whitelist = \"\""));
assert!(dumped.contains("mtu = 0"));
assert!(dumped.contains("socket_mark = 0"));
let reloaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
let reloaded_flags = reloaded.get_flags();
assert_eq!(reloaded_flags.dev_name, "et_test");
assert!(reloaded_flags.enable_quic_proxy);
assert!(reloaded_flags.disable_tcp_hole_punching);
assert!(reloaded_flags.disable_sym_hole_punching);
assert!(!reloaded_flags.multi_thread);
assert!(!reloaded_flags.bind_device);
assert!(!reloaded_flags.enable_ipv6);
assert_eq!(reloaded_flags.relay_network_whitelist, "");
assert_eq!(reloaded_flags.mtu, 0);
assert_eq!(reloaded_flags.socket_mark, Some(0));
}
#[test]
fn test_stun_servers_config() {
let config = TomlConfigLoader::default();
+1 -2
View File
@@ -1,10 +1,9 @@
use dashmap::DashMap;
use hotpath::instant::Instant;
use serde::{Deserialize, Serialize};
use std::cell::UnsafeCell;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::time::interval;
use tokio_util::task::AbortOnDropHandle;
+2 -3
View File
@@ -2,13 +2,12 @@ use std::collections::BTreeSet;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::proto::common::{NatType, StunInfo};
use anyhow::Context;
use chrono::Local;
use crossbeam::atomic::AtomicCell;
use hotpath::instant::Instant;
use rand::seq::IteratorRandom;
use socket2::{SockAddr, SockRef};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -1313,7 +1312,7 @@ impl StunInfoCollectorTrait for MockStunInfoCollector {
StunInfo {
udp_nat_type: self.udp_nat_type as i32,
tcp_nat_type: NatType::Unknown as i32,
last_update_time: Local::now().timestamp(),
last_update_time: std::time::Instant::now().elapsed().as_secs() as i64,
min_port: 100,
max_port: 200,
public_ip: vec!["127.0.0.1".to_string(), "::1".to_string()],
+1 -3
View File
@@ -8,11 +8,9 @@ use std::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
time::{Duration, Instant},
};
use hotpath::instant::Instant;
use crate::{
common::{
PeerId, dns::socket_addrs, error::Error, global_ctx::ArcGlobalCtx,
+1 -2
View File
@@ -2,11 +2,10 @@ use std::{
collections::BTreeSet,
future::Future,
sync::{Arc, Weak},
time::Duration,
time::{Duration, Instant},
};
use dashmap::DashSet;
use hotpath::instant::Instant;
use tokio::{sync::mpsc, task::JoinSet, time::timeout};
use crate::{
-2
View File
@@ -70,8 +70,6 @@ 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 -2
View File
@@ -1,11 +1,10 @@
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::Arc,
time::Duration,
time::{Duration, Instant},
};
use anyhow::{Context, Error};
use hotpath::instant::Instant;
use rand::Rng as _;
use tokio::task::JoinSet;
@@ -1,11 +1,10 @@
use std::{
net::{IpAddr, SocketAddr, SocketAddrV4},
sync::Arc,
time::Duration,
time::{Duration, Instant},
};
use anyhow::Context;
use hotpath::instant::Instant;
use tokio::sync::Mutex;
use tokio_util::task::AbortOnDropHandle;
@@ -7,7 +7,6 @@ use std::{
use crossbeam::atomic::AtomicCell;
use dashmap::{DashMap, DashSet};
use guarden::defer;
use hotpath::instant::Instant;
use rand::seq::SliceRandom as _;
use tokio::{net::UdpSocket, sync::Mutex, task::JoinSet};
use tracing::{Instrument, Level, instrument};
@@ -357,9 +356,9 @@ pub(crate) struct UdpHolePunchListener {
_port_mapping_lease: Option<upnp::UdpPortMappingLease>,
conn_counter: Arc<Box<dyn TunnelConnCounter>>,
listen_time: Instant,
last_select_time: AtomicCell<Instant>,
last_active_time: Arc<AtomicCell<Instant>>,
listen_time: std::time::Instant,
last_select_time: AtomicCell<std::time::Instant>,
last_active_time: Arc<AtomicCell<std::time::Instant>>,
}
impl UdpHolePunchListener {
@@ -422,14 +421,14 @@ impl UdpHolePunchListener {
running_clone.store(false);
});
let last_active_time = Arc::new(AtomicCell::new(Instant::now()));
let last_active_time = Arc::new(AtomicCell::new(std::time::Instant::now()));
let conn_counter_clone = conn_counter.clone();
let last_active_time_clone = last_active_time.clone();
tasks.spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if conn_counter_clone.get().unwrap_or(0) != 0 {
last_active_time_clone.store(Instant::now());
last_active_time_clone.store(std::time::Instant::now());
}
}
});
@@ -445,14 +444,14 @@ impl UdpHolePunchListener {
_port_mapping_lease: port_mapping_lease,
conn_counter,
listen_time: Instant::now(),
last_select_time: AtomicCell::new(Instant::now()),
listen_time: std::time::Instant::now(),
last_select_time: AtomicCell::new(std::time::Instant::now()),
last_active_time,
})
}
pub async fn get_socket(&self) -> Arc<UdpSocket> {
self.last_select_time.store(Instant::now());
self.last_select_time.store(std::time::Instant::now());
self.socket.clone()
}
@@ -1,7 +1,9 @@
use std::{sync::Arc, time::Duration};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use anyhow::Context;
use hotpath::instant::Instant;
use tokio::net::UdpSocket;
use tokio_util::task::AbortOnDropHandle;
+1 -2
View File
@@ -1,6 +1,6 @@
use std::{
sync::{Arc, atomic::AtomicBool},
time::Duration,
time::{Duration, Instant},
};
use anyhow::{Context, Error};
@@ -8,7 +8,6 @@ use both_easy_sym::{PunchBothEasySymHoleClient, PunchBothEasySymHoleServer};
use common::{PunchHoleServerCommon, UdpNatType, UdpPunchClientMethod};
use cone::{PunchConeHoleClient, PunchConeHoleServer};
use dashmap::DashMap;
use hotpath::instant::Instant;
use once_cell::sync::Lazy;
use sym_to_cone::{PunchSymToConeHoleClient, PunchSymToConeHoleServer};
use tokio::{sync::Mutex, task::JoinHandle};
@@ -5,12 +5,11 @@ use std::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
time::{Duration, Instant},
};
use anyhow::Context;
use guarden::defer;
use hotpath::instant::Instant;
use rand::{Rng, seq::SliceRandom};
use tokio::{net::UdpSocket, sync::RwLock};
use tokio_util::task::AbortOnDropHandle;
-16
View File
@@ -1,11 +1,5 @@
use easytier::core;
#[cfg(all(
feature = "hotpath-alloc",
any(feature = "jemalloc", feature = "mimalloc")
))]
compile_error!("feature `hotpath-alloc` cannot be enabled together with `jemalloc` or `mimalloc`");
#[cfg(all(feature = "mimalloc", not(feature = "jemalloc")))]
use mimalloc::MiMalloc;
@@ -30,16 +24,6 @@ pub static malloc_conf: &[u8] = b"retain:false\0";
rust_i18n::i18n!("locales", fallback = "en");
#[tokio::main(flavor = "current_thread")]
#[cfg_attr(
all(
feature = "hotpath",
not(all(
feature = "hotpath-alloc",
any(feature = "jemalloc", feature = "mimalloc")
))
),
hotpath::main
)]
async fn main() -> std::process::ExitCode {
core::main().await
}
+2 -3
View File
@@ -7,7 +7,6 @@ use std::{
};
use anyhow::Context;
use hotpath::instant::Instant;
use pnet::packet::{
Packet,
icmp::{self, IcmpCode, IcmpTypes, MutableIcmpPacket, echo_reply::MutableEchoReplyPacket},
@@ -46,7 +45,7 @@ struct IcmpNatEntry {
src_peer_id: PeerId,
my_peer_id: PeerId,
src_ip: IpAddr,
start_time: Instant,
start_time: std::time::Instant,
mapped_dst_ip: std::net::Ipv4Addr,
}
@@ -61,7 +60,7 @@ impl IcmpNatEntry {
src_peer_id,
my_peer_id,
src_ip,
start_time: Instant::now(),
start_time: std::time::Instant::now(),
mapped_dst_ip,
})
}
+1 -2
View File
@@ -1,10 +1,9 @@
use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::Packet;
use pnet::packet::ip::IpNextHeaderProtocol;
use pnet::packet::ipv4::{self, Ipv4Flags, Ipv4Packet, MutableIpv4Packet};
use std::net::Ipv4Addr;
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::common::error::Error;
-1
View File
@@ -32,7 +32,6 @@ 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 -5
View File
@@ -1018,7 +1018,6 @@ impl TcpProxyRpc for QuicProxyDstRpcService {
mod tests {
use super::*;
use bytes::Buf;
use hotpath::instant::Instant;
/// Helper function: Create a pair of interconnected QuicSockets.
/// Data sent by socket_a will enter socket_b's rx, and vice versa.
@@ -1198,7 +1197,7 @@ mod tests {
// Accept unidirectional stream
let mut recv = connection.accept_uni().await.unwrap();
let start = Instant::now();
let start = std::time::Instant::now();
let mut received = 0;
// Loop read until the stream ends
@@ -1235,7 +1234,7 @@ mod tests {
let bytes_data = Bytes::from(data_chunk); // Use Bytes to avoid repeated allocation
println!("Client: Start sending {} MB...", TOTAL_SIZE / 1024 / 1024);
let start_send = Instant::now();
let start_send = std::time::Instant::now();
let chunks = TOTAL_SIZE / CHUNK_SIZE;
for _ in 0..chunks {
@@ -1277,7 +1276,7 @@ mod tests {
println!("Server: Accepted connection");
let mut stream_handles = Vec::new();
let start = Instant::now();
let start = std::time::Instant::now();
// Accept an expected number of streams
for i in 0..STREAM_COUNT {
@@ -1347,7 +1346,7 @@ mod tests {
STREAM_COUNT
);
let start_send = Instant::now();
let start_send = std::time::Instant::now();
let mut client_tasks = Vec::new();
// Start sending tasks concurrently
+14 -8
View File
@@ -5,11 +5,10 @@ use std::{
Arc, Weak,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::Duration,
time::{Duration, Instant},
};
use crossbeam::atomic::AtomicCell;
use hotpath::instant::Instant;
#[cfg(feature = "kcp")]
use kcp_sys::{endpoint::KcpEndpoint, stream::KcpStream};
use tokio_util::sync::{CancellationToken, DropGuard};
@@ -29,7 +28,7 @@ use crate::{
ip_reassembler::IpReassembler,
tokio_smoltcp::{BufferSize, Net, NetConfig, channel_device},
},
tunnel::packet_def::{PacketType, ZCPacket},
tunnel::packet_def::{PacketType, ZCPacket, ZCPacketType},
};
use anyhow::Context;
use dashmap::DashMap;
@@ -364,7 +363,10 @@ impl Socks5ServerNet {
let mut smoltcp_stack_receiver = packet_recv.lock().await;
while let Some(packet) = smoltcp_stack_receiver.recv().await {
tracing::trace!(?packet, "receive from peer send to smoltcp packet");
if let Err(e) = stack_sink.send(Ok(packet.payload().to_vec())).await {
if let Err(e) = stack_sink
.send(Ok(bytes::BytesMut::from(packet.payload())))
.await
{
tracing::error!("send to smoltcp stack failed: {:?}", e);
}
}
@@ -378,13 +380,16 @@ impl Socks5ServerNet {
"receive from smoltcp stack and send to peer mgr packet, len = {}",
data.len()
);
let Some(ipv4) = Ipv4Packet::new(&data) else {
tracing::error!(?data, "smoltcp stack stream get non ipv4 packet");
let packet = ZCPacket::new_from_buf(data, ZCPacketType::NIC);
let Some(ipv4) = Ipv4Packet::new(packet.payload()) else {
tracing::error!(
payload_len = packet.payload_len(),
"smoltcp stack stream get non ipv4 packet"
);
continue;
};
let dst = ipv4.get_destination();
let packet = ZCPacket::new_with_payload(&data);
let Some(peer_manager) = peer_manager.upgrade() else {
tracing::warn!("peer manager is gone, smoltcp sender exited");
return;
@@ -413,7 +418,8 @@ impl Socks5ServerNet {
tcp_tx_size: 1024 * 128,
..Default::default()
}),
),
)
.with_packet_tx_headroom(ZCPacketType::NIC.get_packet_offsets().payload_offset),
);
let forward_tasks = Arc::new(std::sync::Mutex::new(forward_tasks));
+1 -2
View File
@@ -22,12 +22,11 @@ use std::{
atomic::{AtomicUsize, Ordering},
},
task::{Context, Poll},
time::Duration,
time::{Duration, Instant},
};
use anyhow::Context as _;
use dashmap::mapref::entry::Entry;
use hotpath::instant::Instant;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{common::error::Error, gateway::fast_socks5::server::AsyncTcpConnector};
+15 -7
View File
@@ -3,7 +3,6 @@ use cidr::Ipv4Inet;
use core::panic;
use crossbeam::atomic::AtomicCell;
use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::MutablePacket;
use pnet::packet::Packet;
use pnet::packet::ip::IpNextHeaderProtocols;
@@ -13,7 +12,7 @@ use socket2::{SockRef, TcpKeepalive};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::atomic::{AtomicBool, AtomicU16};
use std::sync::{Arc, Weak};
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, copy_bidirectional};
use tokio::net::{TcpListener, TcpSocket, TcpStream};
use tokio::sync::{Mutex, mpsc};
@@ -40,6 +39,8 @@ use super::CidrSet;
#[cfg(feature = "smoltcp")]
use super::tokio_smoltcp::{self, Net, NetConfig, channel_device};
#[cfg(feature = "smoltcp")]
use crate::tunnel::packet_def::ZCPacketType;
#[async_trait::async_trait]
pub(crate) trait NatDstConnector: Send + Sync + Clone + 'static {
@@ -562,7 +563,10 @@ impl<C: NatDstConnector> TcpProxy<C> {
self.tasks.lock().unwrap().spawn(async move {
while let Some(packet) = smoltcp_stack_receiver.recv().await {
tracing::trace!(?packet, "receive from peer send to smoltcp packet");
if let Err(e) = stack_sink.send(Ok(packet.payload().to_vec())).await {
if let Err(e) = stack_sink
.send(Ok(bytes::BytesMut::from(packet.payload())))
.await
{
tracing::error!("send to smoltcp stack failed: {:?}", e);
}
}
@@ -576,13 +580,16 @@ impl<C: NatDstConnector> TcpProxy<C> {
?data,
"receive from smoltcp stack and send to peer mgr packet"
);
let Some(ipv4) = Ipv4Packet::new(&data) else {
tracing::error!(?data, "smoltcp stack stream get non ipv4 packet");
let packet = ZCPacket::new_from_buf(data, ZCPacketType::NIC);
let Some(ipv4) = Ipv4Packet::new(packet.payload()) else {
tracing::error!(
payload_len = packet.payload_len(),
"smoltcp stack stream get non ipv4 packet"
);
continue;
};
let dst = ipv4.get_destination();
let packet = ZCPacket::new_with_payload(&data);
let Some(peer_mgr) = peer_mgr.upgrade() else {
tracing::warn!("peer manager is gone, smoltcp sender exited");
return;
@@ -611,7 +618,8 @@ impl<C: NatDstConnector> TcpProxy<C> {
tcp_tx_size: 1024 * 16,
..Default::default()
}),
),
)
.with_packet_tx_headroom(ZCPacketType::NIC.get_packet_offsets().payload_offset),
);
net.set_any_ip(true);
self.smoltcp_net.lock().await.replace(net);
@@ -1,3 +1,4 @@
use bytes::BytesMut;
use futures::{Sink, Stream};
use smoltcp::phy::DeviceCapabilities;
use std::{
@@ -12,15 +13,15 @@ use super::device::AsyncDevice;
/// A device that send and receive packets using a channel.
pub struct ChannelDevice {
recv: Receiver<io::Result<Vec<u8>>>,
send: PollSender<Vec<u8>>,
recv: Receiver<io::Result<BytesMut>>,
send: PollSender<BytesMut>,
caps: DeviceCapabilities,
}
pub type ChannelDeviceNewRet = (
ChannelDevice,
Sender<io::Result<Vec<u8>>>,
Receiver<Vec<u8>>,
Sender<io::Result<BytesMut>>,
Receiver<BytesMut>,
);
impl ChannelDevice {
@@ -43,25 +44,25 @@ impl ChannelDevice {
}
impl Stream for ChannelDevice {
type Item = io::Result<Vec<u8>>;
type Item = io::Result<BytesMut>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.recv.poll_recv(cx)
}
}
fn map_err(e: PollSendError<Vec<u8>>) -> io::Error {
fn map_err(e: PollSendError<BytesMut>) -> io::Error {
io::Error::other(e)
}
impl Sink<Vec<u8>> for ChannelDevice {
impl Sink<BytesMut> for ChannelDevice {
type Error = io::Error;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.send.poll_reserve(cx).map_err(map_err)
}
fn start_send(mut self: Pin<&mut Self>, item: Vec<u8>) -> Result<(), Self::Error> {
fn start_send(mut self: Pin<&mut Self>, item: BytesMut) -> Result<(), Self::Error> {
self.send.send_item(item).map_err(map_err)
}
+35 -8
View File
@@ -1,3 +1,4 @@
use bytes::BytesMut;
use futures::{Sink, Stream};
pub use smoltcp::phy::DeviceCapabilities;
use smoltcp::{
@@ -10,7 +11,7 @@ use std::{collections::VecDeque, io};
pub const DEFAULT_MAX_BURST_SIZE: usize = 100;
/// A packet used in `AsyncDevice`.
pub type Packet = Vec<u8>;
pub type Packet = BytesMut;
/// A device that send and receive packets asynchronously.
pub trait AsyncDevice:
@@ -33,6 +34,7 @@ where
pub struct BufferDevice {
caps: DeviceCapabilities,
max_burst_size: usize,
tx_headroom: usize,
recv_queue: VecDeque<Packet>,
send_queue: VecDeque<Packet>,
}
@@ -41,13 +43,11 @@ pub struct BufferDevice {
pub struct BufferRxToken(Packet);
impl RxToken for BufferRxToken {
fn consume<R, F>(mut self, f: F) -> R
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&[u8]) -> R,
{
let p = &mut self.0;
f(p)
f(&self.0[..])
}
}
@@ -59,8 +59,10 @@ impl<'d> TxToken for BufferTxToken<'d> {
where
F: FnOnce(&mut [u8]) -> R,
{
let mut buffer = vec![0u8; len];
let result = f(&mut buffer);
let tx_headroom = self.0.tx_headroom;
let mut buffer = BytesMut::with_capacity(tx_headroom + len);
buffer.resize(tx_headroom + len, 0);
let result = f(&mut buffer[tx_headroom..]);
self.0.send_queue.push_back(buffer);
@@ -98,11 +100,12 @@ impl Device for BufferDevice {
}
impl BufferDevice {
pub(crate) fn new(caps: DeviceCapabilities) -> BufferDevice {
pub(crate) fn new(caps: DeviceCapabilities, tx_headroom: usize) -> BufferDevice {
let max_burst_size = caps.max_burst_size.unwrap_or(DEFAULT_MAX_BURST_SIZE);
BufferDevice {
caps,
max_burst_size,
tx_headroom,
recv_queue: VecDeque::with_capacity(max_burst_size),
send_queue: VecDeque::with_capacity(max_burst_size),
}
@@ -123,3 +126,27 @@ impl BufferDevice {
self.recv_queue.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buffer_device_reserves_tx_headroom() {
let mut caps = DeviceCapabilities::default();
caps.max_burst_size = Some(1);
let mut device = BufferDevice::new(caps, 16);
let token = device.transmit(Instant::now()).unwrap();
token.consume(4, |buf| {
assert_eq!(buf.len(), 4);
buf.copy_from_slice(&[1, 2, 3, 4]);
});
let mut queue = device.take_send_queue();
let packet = queue.pop_front().unwrap();
assert_eq!(packet.len(), 20);
assert_eq!(&packet[..16], &[0; 16]);
assert_eq!(&packet[16..], &[1, 2, 3, 4]);
}
}
+9 -1
View File
@@ -51,6 +51,7 @@ pub struct NetConfig {
pub ip_addr: IpCidr,
pub gateway: Vec<IpAddress>,
pub buffer_size: BufferSize,
pub(crate) packet_tx_headroom: usize,
}
impl NetConfig {
@@ -65,8 +66,14 @@ impl NetConfig {
ip_addr,
gateway,
buffer_size: buffer_size.unwrap_or_default(),
packet_tx_headroom: 0,
}
}
pub fn with_packet_tx_headroom(mut self, packet_tx_headroom: usize) -> Self {
self.packet_tx_headroom = packet_tx_headroom;
self
}
}
/// `Net` is the main interface to the network stack.
@@ -97,7 +104,8 @@ impl Net {
}
fn new2<D: device::AsyncDevice + 'static>(device: D, config: NetConfig) -> Net {
let mut buffer_device = BufferDevice::new(device.capabilities().clone());
let mut buffer_device =
BufferDevice::new(device.capabilities().clone(), config.packet_tx_headroom);
let mut iface = Interface::new(config.interface_config, &mut buffer_device, Instant::now());
let ip_addr = config.ip_addr;
iface.update_ip_addrs(|ip_addrs| {
+5 -6
View File
@@ -8,7 +8,6 @@ use bytes::{BufMut, BytesMut};
use cidr::Ipv4Inet;
use crossbeam::atomic::AtomicCell;
use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::{
Packet,
ip::IpNextHeaderProtocols,
@@ -61,8 +60,8 @@ struct UdpNatEntry {
socket: Option<UdpSocket>,
forward_task: Mutex<Option<JoinHandle<()>>>,
stopped: AtomicBool,
start_time: Instant,
last_active_time: AtomicCell<Instant>,
start_time: std::time::Instant,
last_active_time: AtomicCell<std::time::Instant>,
denied: bool,
}
@@ -86,8 +85,8 @@ impl UdpNatEntry {
socket,
forward_task: Mutex::new(None),
stopped: AtomicBool::new(false),
start_time: Instant::now(),
last_active_time: AtomicCell::new(Instant::now()),
start_time: std::time::Instant::now(),
last_active_time: AtomicCell::new(std::time::Instant::now()),
denied,
})
}
@@ -256,7 +255,7 @@ impl UdpNatEntry {
}
fn mark_active(&self) {
self.last_active_time.store(Instant::now());
self.last_active_time.store(std::time::Instant::now());
}
fn is_active(&self) -> bool {
-40
View File
@@ -1,40 +0,0 @@
//! No-op stand-in for the `hotpath` macros used by this crate, selected when
//! the `hotpath` feature is disabled.
//!
//! Keeping `hotpath` as an optional dependency means default builds do not pull
//! the profiler (or any of its transitive dependencies) into the dependency
//! graph. These macros expand to their input unchanged, mirroring `hotpath`'s
//! own disabled mode so call sites compile identically with or without the
//! feature.
//!
//! The macros are `#[macro_export]`-ed so that `lib.rs`' `extern crate self as
//! hotpath` alias exposes them through the same `hotpath::...` paths used when
//! the feature is enabled.
/// No-op mirroring `hotpath::channel!`: returns the channel expression
/// unchanged (dropping any optional trailing `label`/`log`/`capacity` args).
#[doc(hidden)]
#[macro_export]
macro_rules! channel {
($expr:expr $(, $($rest:tt)*)?) => {
$expr
};
}
/// No-op mirroring `hotpath::mutex!`: returns the expression unchanged.
#[doc(hidden)]
#[macro_export]
macro_rules! mutex {
($expr:expr $(, $($rest:tt)*)?) => {
$expr
};
}
/// No-op mirroring `hotpath::rw_lock!`: returns the expression unchanged.
#[doc(hidden)]
#[macro_export]
macro_rules! rw_lock {
($expr:expr $(, $($rest:tt)*)?) => {
$expr
};
}
+1 -1
View File
@@ -1,9 +1,9 @@
use std::collections::BTreeSet;
use std::sync::{Arc, Weak};
use std::time::Instant;
use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent};
use crate::peers::peer_manager::PeerManager;
use hotpath::instant::Instant;
use tokio_util::task::AbortOnDropHandle;
/// ProxyCidrsMonitor monitors changes in proxy CIDRs from peer routes
+3 -4
View File
@@ -24,7 +24,7 @@ use crate::{
};
use byteorder::WriteBytesExt as _;
use bytes::{Buf, BufMut, BytesMut};
use bytes::{BufMut, BytesMut};
use cidr::{Ipv4Inet, Ipv6Inet};
use futures::{SinkExt, Stream, StreamExt, lock::BiLock, ready};
use pin_project_lite::pin_project;
@@ -180,13 +180,12 @@ impl ZCPacketToBytes for TunZCPacketToBytes {
assert!(payload_offset >= 4);
let ret = if self.has_packet_info {
inner.advance(payload_offset - 4);
let mut inner = inner.split_off(payload_offset - 4);
let proto = infer_proto(&inner[4..]);
self.fill_packet_info(&mut inner[0..4], proto)?;
inner
} else {
inner.advance(payload_offset);
inner
inner.split_off(payload_offset)
};
tracing::debug!(?ret, ?payload_offset, "convert zc packet to tun packet");
-26
View File
@@ -1210,32 +1210,6 @@ mod tests {
Ok(())
}
#[test]
fn network_config_dump_preserves_web_flags() -> Result<(), anyhow::Error> {
let network_config = super::NetworkConfig {
instance_id: Some(uuid::Uuid::new_v4().to_string()),
dhcp: Some(true),
network_name: Some("demo".to_string()),
network_secret: Some("secret".to_string()),
networking_method: Some(crate::proto::api::manage::NetworkingMethod::Manual as i32),
peer_urls: vec!["tcp://1.2.3.4:11010".to_string()],
listener_urls: vec!["tcp://0.0.0.0:11010".to_string()],
dev_name: Some("et_test".to_string()),
enable_quic_proxy: Some(true),
disable_tcp_hole_punching: Some(true),
disable_sym_hole_punching: Some(true),
..Default::default()
};
let dumped = network_config.gen_config()?.dump();
assert!(dumped.contains("dev_name = \"et_test\""));
assert!(dumped.contains("enable_quic_proxy = true"));
assert!(dumped.contains("disable_tcp_hole_punching = true"));
assert!(dumped.contains("disable_sym_hole_punching = true"));
Ok(())
}
#[test]
fn test_network_config_conversion_random() -> Result<(), anyhow::Error> {
let mut rng = rand::thread_rng();
-24
View File
@@ -5,30 +5,6 @@ use std::io;
use clap::Command;
use clap_complete::{Generator, Shell};
// When the `hotpath` feature is off, alias the current crate as `hotpath` so
// call sites keep using `hotpath::...` paths, and provide a local no-op shim
// for the profiling macros. This keeps `hotpath` an optional dependency: the
// profiler is absent from the dependency graph entirely in default builds.
#[cfg(not(feature = "hotpath"))]
extern crate self as hotpath;
#[cfg(not(feature = "hotpath"))]
mod hotpath_off;
// When the `hotpath` feature is off, expose a local `instant` module backed by
// `quanta::Instant` so call sites can uniformly write `use hotpath::instant::Instant;`
// regardless of whether the feature is enabled. With the feature on, the real
// `hotpath` crate provides the same path (also `quanta::Instant` on Linux), so
// the two modes resolve to the identical type.
#[cfg(not(feature = "hotpath"))]
pub mod instant {
pub type Instant = quanta::Instant;
}
// Re-export `Instant` at the crate root so public APIs that expose it
// (e.g. `Route::get_peer_info_last_update_time`) reference a deliberate
// public type rather than leaking an inaccessible one.
pub use hotpath::instant::Instant;
mod arch;
mod gateway;
pub mod instance;
+2 -3
View File
@@ -1,5 +1,6 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::atomic::Ordering;
use std::time::Instant;
use std::{
net::IpAddr,
sync::{Arc, atomic::AtomicBool},
@@ -7,7 +8,6 @@ use std::{
use arc_swap::ArcSwap;
use dashmap::DashMap;
use hotpath::instant::Instant;
use pnet::packet::ipv6::Ipv6Packet;
use pnet::packet::{
Packet as _, ip::IpNextHeaderProtocols, ipv4::Ipv4Packet, tcp::TcpPacket, udp::UdpPacket,
@@ -402,10 +402,9 @@ mod tests {
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::Arc,
time::Instant,
};
use hotpath::instant::Instant;
use crate::{
common::acl_processor::PacketInfo,
proto::acl::{Acl, ChainType, Protocol},
+5 -11
View File
@@ -59,21 +59,15 @@ type BoxNicPacketFilter = Box<dyn NicPacketFilter + Send + Sync>;
pub type PacketRecvChan = tokio::sync::mpsc::Sender<ZCPacket>;
pub type PacketRecvChanReceiver = tokio::sync::mpsc::Receiver<ZCPacket>;
pub fn create_packet_recv_chan() -> (PacketRecvChan, PacketRecvChanReceiver) {
hotpath::channel!(tokio::sync::mpsc::channel(128))
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> {
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")),
}
packet_recv_chan_receiver
.recv()
.await
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed"))
}
pub const PUBLIC_SERVER_HOSTNAME_PREFIX: &str = "PublicServer_";
-7
View File
@@ -207,7 +207,6 @@ impl Peer {
.map(|conn| conn.clone())
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "Peer"))]
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
let Some(conn) = self.select_conn().await else {
return Err(Error::PeerNoConnectionError(self.peer_node_id));
@@ -268,12 +267,6 @@ 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()
}
+8 -31
View File
@@ -10,15 +10,6 @@ use std::{
},
};
#[cfg(feature = "hotpath")]
use hotpath::wrap::std::sync::Mutex as StdMutex;
#[cfg(feature = "hotpath")]
use hotpath::wrap::tokio::sync::Mutex;
#[cfg(not(feature = "hotpath"))]
use std::sync::Mutex as StdMutex;
#[cfg(not(feature = "hotpath"))]
use tokio::sync::Mutex;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use guarden::guard;
@@ -26,7 +17,7 @@ use hmac::Mac;
use prost::Message;
use tokio::{
sync::broadcast,
sync::{Mutex, broadcast},
task::JoinSet,
time::{Duration, timeout},
};
@@ -107,7 +98,7 @@ struct PeerSessionTunnelFilter {
enabled: bool,
my_peer_id: Arc<AtomicCell<PeerId>>,
peer_id: Arc<AtomicCell<Option<PeerId>>>,
session: Arc<StdMutex<Option<Arc<PeerSession>>>>,
session: Arc<std::sync::Mutex<Option<Arc<PeerSession>>>>,
}
impl PeerSessionTunnelFilter {
@@ -116,7 +107,7 @@ impl PeerSessionTunnelFilter {
enabled,
my_peer_id: Arc::new(AtomicCell::new(PeerId::default())),
peer_id: Arc::new(AtomicCell::new(None)),
session: Arc::new(hotpath::mutex!(std::sync::Mutex::new(None))),
session: Arc::new(std::sync::Mutex::new(None)),
}
}
@@ -125,7 +116,7 @@ impl PeerSessionTunnelFilter {
enabled,
my_peer_id: Arc::new(AtomicCell::new(my_peer_id)),
peer_id: Arc::new(AtomicCell::new(None)),
session: Arc::new(hotpath::mutex!(std::sync::Mutex::new(None))),
session: Arc::new(std::sync::Mutex::new(None)),
}
}
@@ -370,15 +361,7 @@ 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 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 mut mpsc_tunnel = MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)));
let (recv, sink) = (mpsc_tunnel.get_stream(), mpsc_tunnel.get_sink());
@@ -396,12 +379,11 @@ impl PeerConn {
session_filter,
noise_handshake_result: None,
tunnel: Arc::new(hotpath::mutex!(tokio::sync::Mutex::new(Box::new(
tunnel: Arc::new(Mutex::new(Box::new(
guard!([mut mpsc_tunnel] mpsc_tunnel.close()),
)
as Box<dyn Any + Send + 'static>))),
))),
sink,
recv: hotpath::mutex!(tokio::sync::Mutex::new(Some(recv))),
recv: Mutex::new(Some(recv)),
tunnel_info,
tasks: JoinSet::new(),
@@ -451,10 +433,6 @@ 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;
}
@@ -1482,7 +1460,6 @@ impl PeerConn {
});
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerConn"))]
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
Ok(self.sink.send(msg).await?)
}
+1 -2
View File
@@ -6,7 +6,6 @@ use std::{
time::Duration,
};
use hotpath::instant::Instant;
use rand::{Rng, thread_rng};
use tokio::{
sync::broadcast,
@@ -178,7 +177,7 @@ impl PeerConnPinger {
sink.send(req).await?;
control_metrics.record_tx(req_len);
let now = Instant::now();
let now = std::time::Instant::now();
// wait until we get a pong packet in ctrl_resp_receiver
let resp = timeout(Duration::from_secs(2), async {
loop {
+25 -59
View File
@@ -2,21 +2,19 @@ use anyhow::Context;
use async_trait::async_trait;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use dashmap::DashMap;
use hotpath::instant::Instant;
use std::collections::BTreeSet;
use std::{
fmt::Debug,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::{Arc, Weak, atomic::AtomicBool},
time::{Duration, SystemTime},
time::{Duration, Instant, SystemTime},
};
#[cfg(feature = "hotpath")]
use hotpath::wrap::tokio::sync::{Mutex, RwLock};
#[cfg(not(feature = "hotpath"))]
use tokio::sync::{Mutex, RwLock};
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
sync::{
Mutex, RwLock,
mpsc::{self, UnboundedReceiver, UnboundedSender},
},
task::JoinSet,
};
@@ -279,8 +277,8 @@ impl PeerManager {
let rpc_tspt = Arc::new(RpcTransport {
my_peer_id,
peers: Arc::downgrade(&peers),
foreign_peers: hotpath::mutex!(tokio::sync::Mutex::new(None)),
packet_recv: hotpath::mutex!(tokio::sync::Mutex::new(peer_rpc_tspt_recv)),
foreign_peers: Mutex::new(None),
packet_recv: Mutex::new(peer_rpc_tspt_recv),
peer_rpc_tspt_sender,
encryptor: encryptor.clone(),
is_secure_mode_enabled,
@@ -412,21 +410,17 @@ impl PeerManager {
global_ctx,
nic_channel,
tasks: hotpath::mutex!(tokio::sync::Mutex::new(JoinSet::new())),
tasks: Mutex::new(JoinSet::new()),
packet_recv: Arc::new(hotpath::mutex!(tokio::sync::Mutex::new(Some(packet_recv)))),
packet_recv: Arc::new(Mutex::new(Some(packet_recv))),
peers,
peer_rpc_mgr,
peer_rpc_tspt: rpc_tspt,
peer_packet_process_pipeline: Arc::new(hotpath::rw_lock!(tokio::sync::RwLock::new(
Vec::new()
))),
nic_packet_process_pipeline: Arc::new(hotpath::rw_lock!(tokio::sync::RwLock::new(
Vec::new()
))),
peer_packet_process_pipeline: Arc::new(RwLock::new(Vec::new())),
nic_packet_process_pipeline: Arc::new(RwLock::new(Vec::new())),
route_algo_inst,
@@ -437,7 +431,7 @@ impl PeerManager {
encryptor,
data_compress_algo,
exit_nodes: hotpath::rw_lock!(tokio::sync::RwLock::new(exit_nodes)),
exit_nodes: RwLock::new(exit_nodes),
reserved_my_peer_id_map: DashMap::new(),
recent_have_traffic: Arc::new(DashMap::new()),
@@ -1015,17 +1009,7 @@ impl PeerManager {
self.tasks.lock().await.spawn(async move {
tracing::trace!("start_peer_recv");
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,
};
while let Ok(ret) = recv_packet_from_chan(&mut recv).await {
let disable_relay_data = global_ctx.flags_arc().disable_relay_data;
let Err(mut ret) = Self::try_handle_foreign_network_packet(
ret,
@@ -1163,11 +1147,9 @@ impl PeerManager {
self_rx_bytes.add(buf_len as u64);
self_rx_packets.inc();
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;
}
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 {};
@@ -1456,7 +1438,6 @@ impl PeerManager {
self.get_route().get_foreign_network_summary().await
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
async fn run_nic_packet_process_pipeline(&self, data: &mut ZCPacket) -> bool {
// Enforce ACL for outbound (NIC-originated) packets. If ACL denies, stop processing.
if !self.global_ctx.get_acl_filter().process_packet_with_acl(
@@ -1542,7 +1523,6 @@ impl PeerManager {
result
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
async fn send_msg_internal(
peers: &Arc<PeerMap>,
foreign_network_client: &Arc<ForeignNetworkClient>,
@@ -1568,8 +1548,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 let Some(peer) = peers.get_peer_by_id(dst_peer_id) {
peer.send_msg(msg).await
} else if peers.has_peer(dst_peer_id) {
peers.send_msg_directly(msg, dst_peer_id).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 {
@@ -1594,9 +1574,7 @@ impl PeerManager {
if send_result.is_ok()
&& let Some(metrics) = direct_tx_metrics
{
if !metrics.record_tx_fast(dst_peer_id, packet_type, msg_len) {
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
}
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
}
send_result
@@ -1643,7 +1621,6 @@ 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![];
@@ -1712,7 +1689,6 @@ 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>,
@@ -1730,7 +1706,6 @@ impl PeerManager {
Ok(())
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
pub async fn send_msg_by_ip(
&self,
mut msg: ZCPacket,
@@ -1994,18 +1969,6 @@ 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()
}
@@ -2234,9 +2197,12 @@ impl PeerManager {
#[cfg(test)]
mod tests {
use base64::Engine;
use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
use hotpath::instant::Instant;
use std::{
collections::HashMap,
fmt::Debug,
sync::Arc,
time::{Duration, Instant},
};
use crate::{
common::{
-1
View File
@@ -38,7 +38,6 @@ 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 {
+8 -11
View File
@@ -6,14 +6,13 @@ use std::{
Arc, Weak,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{Duration, SystemTime},
time::{Duration, Instant, SystemTime},
};
use arc_swap::ArcSwap;
use cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr, Ipv6Inet};
use crossbeam::atomic::AtomicCell;
use dashmap::DashMap;
use hotpath::instant::Instant;
use ordered_hash_map::OrderedHashMap;
use parking_lot::{RwLock, lock_api::RwLockUpgradableReadGuard};
use petgraph::{
@@ -1394,7 +1393,6 @@ impl RouteTable {
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "RouteTable"))]
fn get_next_hop(&self, dst_peer_id: PeerId) -> Option<NextHopInfo> {
if self.suppressed_peer_ids.contains_key(&dst_peer_id) {
return None;
@@ -1402,7 +1400,6 @@ impl RouteTable {
self.get_topology_next_hop(dst_peer_id)
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "RouteTable"))]
fn get_topology_next_hop(&self, dst_peer_id: PeerId) -> Option<NextHopInfo> {
let cur_version = self.next_hop_map_version.get();
self.next_hop_map.get(&dst_peer_id).and_then(|x| {
@@ -2171,9 +2168,9 @@ struct PeerRouteServiceImpl {
interface_peers_generation: AtomicU64,
applied_interface_peers_generation: AtomicU64,
last_update_my_foreign_network: AtomicCell<Option<Instant>>,
last_update_my_foreign_network: AtomicCell<Option<std::time::Instant>>,
peer_info_last_update: AtomicCell<Instant>,
peer_info_last_update: AtomicCell<std::time::Instant>,
}
impl Debug for PeerRouteServiceImpl {
@@ -2240,7 +2237,7 @@ impl PeerRouteServiceImpl {
last_update_my_foreign_network: AtomicCell::new(None),
peer_info_last_update: AtomicCell::new(Instant::now()),
peer_info_last_update: AtomicCell::new(std::time::Instant::now()),
}
}
@@ -2436,7 +2433,7 @@ impl PeerRouteServiceImpl {
}
self.last_update_my_foreign_network
.store(Some(Instant::now()));
.store(Some(std::time::Instant::now()));
let foreign_networks = self
.interface
@@ -3157,12 +3154,12 @@ impl PeerRouteServiceImpl {
"update_peer_info_last_update, my_peer_id: {:?}, prev: {:?}, new: {:?}",
self.my_peer_id,
self.peer_info_last_update.load(),
Instant::now()
std::time::Instant::now()
);
self.peer_info_last_update.store(Instant::now());
self.peer_info_last_update.store(std::time::Instant::now());
}
fn get_peer_info_last_update(&self) -> Instant {
fn get_peer_info_last_update(&self) -> std::time::Instant {
self.peer_info_last_update.load()
}
-2
View File
@@ -337,7 +337,6 @@ impl PeerSession {
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerSession"))]
pub fn encrypt_payload(
&self,
sender_peer_id: PeerId,
@@ -351,7 +350,6 @@ impl PeerSession {
.encrypt_payload(Self::dir_for_sender(sender_peer_id, receiver_peer_id), pkt)
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerSession"))]
pub fn decrypt_payload(
&self,
sender_peer_id: PeerId,
+1 -2
View File
@@ -1,7 +1,6 @@
use std::sync::Arc;
use std::{sync::Arc, time::Instant};
use dashmap::DashMap;
use hotpath::instant::Instant;
use prost::Message;
use snow::params::NoiseParams;
use tokio::sync::{Mutex, OwnedMutexGuard, oneshot};
+2 -3
View File
@@ -1,7 +1,6 @@
use cidr::Ipv6Inet;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use dashmap::DashMap;
use hotpath::instant::Instant;
use std::{
collections::BTreeSet,
net::{Ipv4Addr, Ipv6Addr},
@@ -158,7 +157,7 @@ pub trait Route {
async fn get_peer_info(&self, peer_id: PeerId) -> Option<RoutePeerInfo>;
async fn get_peer_info_last_update_time(&self) -> Instant;
async fn get_peer_info_last_update_time(&self) -> std::time::Instant;
fn get_peer_groups(&self, peer_id: PeerId) -> Arc<Vec<String>>;
@@ -227,7 +226,7 @@ impl Route for MockRoute {
panic!("mock route")
}
async fn get_peer_info_last_update_time(&self) -> Instant {
async fn get_peer_info_last_update_time(&self) -> std::time::Instant {
panic!("mock route")
}
-8
View File
@@ -701,10 +701,6 @@ impl SecureDatagramSession {
false
}
#[cfg_attr(
feature = "hotpath",
hotpath::measure(impl_type = "SecureDatagramSession")
)]
pub fn encrypt_payload(
&self,
dir: SecureDatagramDirection,
@@ -723,10 +719,6 @@ impl SecureDatagramSession {
Ok(())
}
#[cfg_attr(
feature = "hotpath",
hotpath::measure(impl_type = "SecureDatagramSession")
)]
pub fn decrypt_payload(
&self,
dir: SecureDatagramDirection,
+21 -91
View File
@@ -1,4 +1,4 @@
use std::{future::Future, sync::atomic::{AtomicU64, Ordering}, sync::Arc};
use std::{future::Future, sync::Arc};
use dashmap::DashMap;
use futures::future::BoxFuture;
@@ -18,54 +18,16 @@ 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) {
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);
}
self.bytes.add(bytes);
self.packets.inc();
}
}
@@ -98,14 +60,14 @@ impl AggregateTrafficMetrics {
let label_set =
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
Self {
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),
),
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),
},
}
}
@@ -160,10 +122,10 @@ impl LogicalTrafficMetrics {
let label_set =
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
Self {
total: TrafficCounters::new(
stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
stats_mgr.get_counter(total_packets_metric, label_set),
),
total: TrafficCounters {
bytes: stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
packets: stats_mgr.get_counter(total_packets_metric, label_set),
},
stats_mgr,
network_name,
instance_bytes_metric,
@@ -173,22 +135,6 @@ 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,
@@ -268,12 +214,14 @@ impl LogicalTrafficMetrics {
let label_set = LabelSet::new()
.with_label_type(LabelType::NetworkName(self.network_name.clone()))
.with_label_type(instance_label);
TrafficCounters::new(
self.stats_mgr
TrafficCounters {
bytes: self
.stats_mgr
.get_counter(self.instance_bytes_metric, label_set.clone()),
self.stats_mgr
packets: self
.stats_mgr
.get_counter(self.instance_packets_metric, label_set),
)
}
}
}
@@ -356,15 +304,6 @@ 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;
@@ -375,15 +314,6 @@ 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;
+8 -9
View File
@@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex};
use bytes::Bytes;
use dashmap::DashMap;
use guarden::defer;
use hotpath::instant::Instant;
use prost::Message;
use tokio::sync::mpsc;
use tokio::task::JoinSet;
@@ -53,7 +52,7 @@ struct InflightRequestKey {
struct InflightRequest {
sender: RpcPacketSender,
merger: PacketMerger,
start_time: Instant,
start_time: std::time::Instant,
}
impl std::fmt::Debug for InflightRequest {
@@ -66,14 +65,14 @@ impl std::fmt::Debug for InflightRequest {
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PeerInfo {
pub struct PeerInfo {
pub peer_id: PeerId,
pub compression_info: RpcCompressionInfo,
pub last_active: Option<Instant>,
pub last_active: Option<std::time::Instant>,
}
type InflightRequestTable = Arc<DashMap<InflightRequestKey, InflightRequest>>;
pub(crate) type PeerInfoTable = Arc<DashMap<PeerId, PeerInfo>>;
pub type PeerInfoTable = Arc<DashMap<PeerId, PeerInfo>>;
pub struct Client {
mpsc: Mutex<MpscTunnel<Box<dyn Tunnel>>>,
@@ -124,7 +123,7 @@ impl Client {
tasks.spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let now = Instant::now();
let now = std::time::Instant::now();
peer_infos.retain(|_, v| {
if let Some(last_active) = v.last_active {
return now.duration_since(last_active)
@@ -231,7 +230,7 @@ impl Client {
method: <Self::Descriptor as ServiceDescriptor>::Method,
input: bytes::Bytes,
) -> Result<bytes::Bytes> {
let start_time = Instant::now();
let start_time = std::time::Instant::now();
let transaction_id = CUR_TID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = mpsc::unbounded_channel();
let key = InflightRequestKey {
@@ -315,7 +314,7 @@ impl Client {
PeerInfo {
peer_id: self.to_peer_id,
compression_info,
last_active: Some(Instant::now()),
last_active: Some(std::time::Instant::now()),
},
);
@@ -386,7 +385,7 @@ impl Client {
self.inflight_requests.len()
}
pub(crate) fn peer_info_table(&self) -> PeerInfoTable {
pub fn peer_info_table(&self) -> PeerInfoTable {
self.peer_info.clone()
}
}
+5 -7
View File
@@ -1,7 +1,5 @@
use prost::{Message as _, length_delimiter_len};
use hotpath::instant::Instant;
use crate::{
common::{PeerId, compressor::DefaultCompressor},
proto::{
@@ -44,10 +42,10 @@ pub async fn decompress_packet(
Ok(decompressed)
}
pub(crate) struct PacketMerger {
pub struct PacketMerger {
first_piece: Option<RpcPacket>,
pieces: Vec<RpcPacket>,
last_updated: Instant,
last_updated: std::time::Instant,
}
impl Default for PacketMerger {
@@ -61,7 +59,7 @@ impl PacketMerger {
Self {
first_piece: None,
pieces: Vec::new(),
last_updated: Instant::now(),
last_updated: std::time::Instant::now(),
}
}
@@ -134,12 +132,12 @@ impl PacketMerger {
.resize(total_pieces as usize, Default::default());
self.pieces[piece_idx as usize] = rpc_packet;
self.last_updated = Instant::now();
self.last_updated = std::time::Instant::now();
Ok(self.try_merge_pieces())
}
pub(crate) fn last_updated(&self) -> Instant {
pub fn last_updated(&self) -> std::time::Instant {
self.last_updated
}
}
+1 -2
View File
@@ -5,7 +5,6 @@ use std::{
use bytes::Bytes;
use dashmap::DashMap;
use hotpath::instant::Instant;
use prost::Message;
use tokio::{task::JoinSet, time::timeout};
use tokio_stream::StreamExt;
@@ -234,7 +233,7 @@ impl Server {
}
let mut resp_msg = RpcResponse::default();
let now = Instant::now();
let now = std::time::Instant::now();
let compression_info = packet.compression_info;
let resp_bytes = Self::handle_rpc_request(packet, reg, tunnel_info).await;
+1 -1
View File
@@ -159,7 +159,7 @@ impl Socket {
ack: Option<u32>,
state: State,
) -> (Socket, flume::Sender<Bytes>) {
let (incoming_tx, incoming_rx) = hotpath::channel!(flume::bounded(MPMC_BUFFER_LEN));
let (incoming_tx, incoming_rx) = flume::bounded(MPMC_BUFFER_LEN);
(
Socket {
+15 -161
View File
@@ -1,13 +1,6 @@
// this mod wrap tunnel to a mpsc tunnel, based on crossbeam_channel
use std::{
cell::UnsafeCell,
pin::Pin,
sync::Arc,
sync::atomic::{AtomicBool, AtomicU32, Ordering},
task::Poll,
time::Duration,
};
use std::{pin::Pin, time::Duration};
use anyhow::Context;
use tokio::time::timeout;
@@ -18,151 +11,39 @@ 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 {
channel_tx: Option<Sender<ZCPacket>>,
direct_sink: Option<Arc<SpinSink>>,
direct_batch_flush: bool,
}
pub struct MpscTunnelSender(Sender<ZCPacket>);
impl MpscTunnelSender {
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnelSender"))]
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
if let Some(sink) = &self.direct_sink {
// 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
self.0.send(item).await.with_context(|| "send error")?;
Ok(())
}
pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> {
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
tx.try_send(item).map_err(|e| match e {
self.0.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: Option<AbortOnDropHandle<()>>,
task: AbortOnDropHandle<()>,
}
impl<T: Tunnel> MpscTunnel<T> {
pub fn new(tunnel: T, send_timeout: Option<Duration>) -> Self {
let (tx, mut rx) = hotpath::channel!(channel(1024));
let (tx, mut rx) = channel(32);
let (stream, mut sink) = tunnel.split();
let task = tokio::spawn(async move {
@@ -179,32 +60,12 @@ impl<T: Tunnel> MpscTunnel<T> {
Self {
tx: Some(tx),
direct_sink: None,
direct_batch_flush: false,
tunnel,
stream: Some(stream),
task: Some(AbortOnDropHandle::new(task)),
task: 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,
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
async fn forward_one_round(
rx: &mut Receiver<ZCPacket>,
sink: &mut Pin<Box<dyn ZCPacketSink>>,
@@ -218,7 +79,6 @@ impl<T: Tunnel> MpscTunnel<T> {
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
async fn forward_one_round_no_timeout(
rx: &mut Receiver<ZCPacket>,
sink: &mut Pin<Box<dyn ZCPacketSink>>,
@@ -236,7 +96,6 @@ impl<T: Tunnel> MpscTunnel<T> {
sink.flush().await
}
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
async fn forward_one_round_with_timeout(
rx: &mut Receiver<ZCPacket>,
sink: &mut Pin<Box<dyn ZCPacketSink>>,
@@ -265,19 +124,12 @@ impl<T: Tunnel> MpscTunnel<T> {
}
pub fn get_sink(&self) -> MpscTunnelSender {
MpscTunnelSender {
channel_tx: self.tx.as_ref().cloned(),
direct_sink: self.direct_sink.clone(),
direct_batch_flush: self.direct_batch_flush,
}
MpscTunnelSender(self.tx.as_ref().unwrap().clone())
}
pub fn close(&mut self) {
self.tx.take();
self.direct_sink.take();
if let Some(task) = self.task.take() {
task.abort();
}
self.task.abort();
}
pub fn tunnel_info(&self) -> Option<TunnelInfo> {
@@ -337,7 +189,8 @@ mod tests {
for i in 0..1000000 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let a = sink1
.send_async(ZCPacket::new_with_payload("hello".as_bytes())).await;
.send(ZCPacket::new_with_payload("hello".as_bytes()))
.await;
if a.is_err() {
tracing::info!(?a, "t2 exit with err");
break;
@@ -356,7 +209,8 @@ mod tests {
for i in 0..1000000 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let a = sink2
.send_async(ZCPacket::new_with_payload("hello2".as_bytes())).await;
.send(ZCPacket::new_with_payload("hello2".as_bytes()))
.await;
if a.is_err() {
tracing::info!(?a, "t3 exit with err");
break;
+89 -81
View File
@@ -1,10 +1,9 @@
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;
@@ -484,16 +483,8 @@ 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);
// 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);
}
unsafe { ret.inner.set_len(total_len) };
ret.mut_payload()[..payload.len()].copy_from_slice(payload);
ret
}
@@ -501,12 +492,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;
ret.inner.resize(total_len, 0);
unsafe { ret.inner.set_len(total_len) };
ret
}
pub fn new_for_foreign_network(
network_name: &str,
network_name: &String,
dst_peer_id: u32,
foreign_zc_packet: &ZCPacket,
) -> Self {
@@ -515,71 +506,26 @@ impl ZCPacket {
foreign_network_hdr.get_header_len() + foreign_zc_packet.tunnel_payload().len();
let mut ret = Self::new_nic_packet();
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 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 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;
let foreign_payload = foreign_zc_packet.tunnel_payload();
ret.mut_payload()[name_offset..name_offset + name_len]
.copy_from_slice(network_name.as_bytes());
// 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),
};
ret.mut_payload()[foreign_network_hdr.get_header_len()..]
.copy_from_slice(foreign_zc_packet.tunnel_payload());
// 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);
}
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);
ret
}
@@ -639,8 +585,7 @@ impl ZCPacket {
}
pub fn payload_bytes(mut self) -> BytesMut {
self.inner.advance(self.payload_offset());
self.inner
self.inner.split_off(self.payload_offset())
}
pub fn peer_manager_header(&self) -> Option<&PeerManagerHeader> {
@@ -705,12 +650,11 @@ impl ZCPacket {
}
pub fn tunnel_payload_bytes(mut self) -> BytesMut {
self.inner.advance(
self.inner.split_off(
self.packet_type
.get_packet_offsets()
.peer_manager_header_offset,
);
self.inner
)
}
pub fn convert_type(mut self, target_packet_type: ZCPacketType) -> Self {
@@ -751,13 +695,12 @@ impl ZCPacket {
.get_packet_offsets()
.peer_manager_header_offset;
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
buf.resize(new_pm_offset, 0);
unsafe { buf.set_len(new_pm_offset) };
buf.extend_from_slice(tunnel_payload);
return Self::new_from_buf(buf, target_packet_type);
}
self.inner.advance(new_offset);
Self::new_from_buf(self.inner, target_packet_type)
Self::new_from_buf(self.inner.split_off(new_offset), target_packet_type)
}
pub fn into_bytes(self) -> Bytes {
@@ -821,6 +764,7 @@ impl ZCPacket {
#[cfg(test)]
mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn test_zc_packet() {
@@ -866,4 +810,68 @@ mod tests {
assert!(packet.mut_wg_tunnel_header().is_none());
}
fn bench_smoltcp_zcpacket_construct(payload_len: usize, iterations: usize) {
let nic_offset = ZCPacketType::NIC.get_packet_offsets().payload_offset;
// Correctness check (outside the timed section): both construction paths
// must yield equivalent payloads for the perf comparison to be meaningful.
{
let data = vec![7u8; payload_len];
let p_copy = ZCPacket::new_with_payload(&data);
let mut buf = BytesMut::with_capacity(nic_offset + payload_len);
buf.resize(nic_offset + payload_len, 0);
buf[nic_offset..].fill(7);
let p_zero = ZCPacket::new_from_buf(buf, ZCPacketType::NIC);
assert_eq!(p_copy.payload(), p_zero.payload());
}
// copy path: smoltcp emits a bare payload buf; socks5/tcp_proxy copy it
// via ZCPacket::new_with_payload (pre-f5ce0848 behavior).
let now = Instant::now();
let mut checksum = 0usize;
for _ in 0..iterations {
let data = vec![7u8; payload_len];
let p = ZCPacket::new_with_payload(black_box(&data));
// black_box forces the side-effect-free construction to be emitted;
// payload_len is stable per run so it cannot skew the numbers.
checksum = checksum.wrapping_add(black_box(&p).payload_len());
}
let copy_elapsed = now.elapsed().as_secs_f64();
// zerocopy path: device reserves NIC headroom in the buf; socks5/tcp_proxy
// wrap it zero-copy via ZCPacket::new_from_buf (f5ce0848 behavior).
let now = Instant::now();
let mut checksum2 = 0usize;
for _ in 0..iterations {
let mut buf = BytesMut::with_capacity(nic_offset + payload_len);
buf.resize(nic_offset + payload_len, 0);
buf[nic_offset..].fill(7);
let p = ZCPacket::new_from_buf(black_box(buf), ZCPacketType::NIC);
checksum2 = checksum2.wrapping_add(black_box(&p).payload_len());
}
let zerocopy_elapsed = now.elapsed().as_secs_f64();
println!(
"smoltcp_zcpacket payload_len={} iterations={} copy_pps={:.0} copy_bytes_per_sec={:.0} zerocopy_pps={:.0} zerocopy_bytes_per_sec={:.0} speedup={:.2}x checksums={}/{}",
payload_len,
iterations,
iterations as f64 / copy_elapsed,
(payload_len * iterations) as f64 / copy_elapsed,
iterations as f64 / zerocopy_elapsed,
(payload_len * iterations) as f64 / zerocopy_elapsed,
copy_elapsed / zerocopy_elapsed,
checksum,
checksum2
);
}
#[test]
#[ignore = "benchmark helper; run with --ignored --nocapture"]
fn smoltcp_zcpacket_construct_bench() {
bench_smoltcp_zcpacket_construct(1280, 1_000_000);
bench_smoltcp_zcpacket_construct(4096, 500_000);
}
}
+1 -3
View File
@@ -130,7 +130,6 @@ impl RingSink {
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
impl Sink<SinkItem> for RingSink {
type Error = SinkError;
@@ -197,8 +196,7 @@ pub struct RingTunnelListener {
impl RingTunnelListener {
pub fn new(key: url::Url) -> Self {
let (conn_sender, conn_receiver) =
hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
let (conn_sender, conn_receiver) = tokio::sync::mpsc::unbounded_channel();
RingTunnelListener {
listener_addr: key,
conn_sender,
+6 -144
View File
@@ -266,151 +266,14 @@ fn get_zcpacket_from_buf(buf: BytesMut, allow_stun: bool) -> Result<ZCPacket, Tu
Ok(zc_packet)
}
#[cfg(feature = "udp-gso")]
const UDP_BATCH_SIZE: usize = 8;
#[cfg(feature = "udp-gso")]
#[instrument]
async fn forward_from_ring_to_udp(
mut ring_recv: RingStream,
socket: &Arc<UdpSocket>,
addr: &SocketAddr,
conn_id: u32,
) -> Option<TunnelError> {
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)");
tracing::debug!("udp forward from ring to udp");
loop {
let buf = ring_recv.next().await?;
let packet = match buf {
@@ -428,6 +291,7 @@ async fn forward_from_ring_to_udp_fallback(
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()));
@@ -708,9 +572,8 @@ pub struct UdpTunnelListener {
impl UdpTunnelListener {
pub fn new(addr: url::Url) -> Self {
let (close_event_send, close_event_recv) =
hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
let (conn_send, conn_recv) = hotpath::channel!(tokio::sync::mpsc::channel(100));
let (close_event_send, close_event_recv) = tokio::sync::mpsc::unbounded_channel();
let (conn_send, conn_recv) = tokio::sync::mpsc::channel(100);
Self {
addr: addr.clone(),
socket: None,
@@ -921,8 +784,7 @@ impl UdpTunnelConnector {
"udp build tunnel for connector"
);
let (close_event_sender, mut close_event_recv) =
hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
let (close_event_sender, mut close_event_recv) = tokio::sync::mpsc::unbounded_channel();
let ring_recv = RingStream::new(ring_for_send_udp.clone());
let ring_sender = RingSink::new(ring_for_recv_udp.clone());
+4 -6
View File
@@ -6,8 +6,6 @@ use std::{
time::Duration,
};
use hotpath::instant::Instant;
use super::{
FromUrl, IpVersion, Tunnel, TunnelError, TunnelInfo, TunnelListener, TunnelUrl, ZCPacketSink,
ZCPacketStream,
@@ -348,7 +346,7 @@ struct WgPeer {
data: Option<WgPeerData>,
tasks: JoinSet<()>,
access_time: AtomicCell<Instant>,
access_time: AtomicCell<std::time::Instant>,
}
impl WgPeer {
@@ -371,7 +369,7 @@ impl WgPeer {
data: None,
tasks: JoinSet::new(),
access_time: AtomicCell::new(Instant::now()),
access_time: AtomicCell::new(std::time::Instant::now()),
}
}
@@ -387,7 +385,7 @@ impl WgPeer {
}
async fn handle_packet_from_peer(&self, packet: &[u8]) {
self.access_time.store(Instant::now());
self.access_time.store(std::time::Instant::now());
tracing::trace!("Received {} bytes from peer", packet.len());
let data = self.data.as_ref().unwrap();
// TODO: improve this
@@ -470,7 +468,7 @@ pub struct WgTunnelListener {
impl WgTunnelListener {
pub fn new(addr: url::Url, config: WgConfig) -> Self {
let (conn_send, conn_recv) = hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
let (conn_send, conn_recv) = tokio::sync::mpsc::unbounded_channel();
WgTunnelListener {
addr,
config,
-1
View File
@@ -3,7 +3,6 @@
"private": true,
"pnpm": {
"overrides": {
"happy-dom": "16.8.1",
"minimatch": "10.2.4"
}
}
-197
View File
@@ -1,197 +0,0 @@
# 计划 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`。保持外部 behavior:counter update 后 last update 被刷新,过期清理逻辑仍能工作。
**验证**:`cargo clippy --all-targets --features full --all -- -D warnings` → exit 0, no warnings。
### 步骤 3:替换 `Throughput` 中的 `UnsafeCell` counters
在 `easytier/src/tunnel/stats.rs` 中将 `tx_bytes`、`rx_bytes`、`tx_packets`、`rx_packets` 改成 `AtomicU64`。`record_tx_bytes` 和 `record_rx_bytes` 使用 `fetch_add(..., Ordering::Relaxed)`;getter 使用 `load(Ordering::Relaxed)`。
更新 `Clone` 实现为加载旧值后创建新的 atomic counters。移除 `unsafe impl Send for Throughput` 和 `unsafe impl Sync for Throughput`。
**验证**:`cargo test --package easytier tunnel::stats --features full -- --nocapture` → exit 0;如果没有匹配测试,继续步骤 4 新增测试后重跑。
### 步骤 4:新增并发回归测试
为 `stats_manager` 添加一个多线程并发 increment 测试,建议放在 `easytier/src/common/stats_manager.rs` 的 `#[cfg(test)]` 模块中:创建一个 counter handle,启动多个 OS threads 或 `tokio::task::JoinSet`,每个 task 多次 `inc()`,最后断言总数等于预期。
为 `Throughput` 添加类似测试,创建 `Arc<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
@@ -1,160 +0,0 @@
# 计划 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 test;bounded queue behavior 应在 unit-level 可验证。
## 完成标准
- [ ] `peer_manager.rs` 不再为 peer RPC transport 使用 `mpsc::unbounded_channel()`。
- [ ] `foreign_network_manager.rs` 不再为 foreign-network RPC transport 使用 `mpsc::unbounded_channel()`。
- [ ] 相关 packet enqueue path 不再调用 `.unwrap()`。
- [ ] 满队列和 receiver closed 有明确非 panic 行为。
- [ ] 新增或更新测试覆盖 queue overload behavior。
- [ ] `cargo fmt --all -- --check` exits 0。
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
- [ ] `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` exits 0。
- [ ] 没有修改范围外文件。
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
## STOP 条件
- 001 尚未完成,而本计划需要新增 metrics/drop counters;此时先执行 001 或报告阻塞。
- `PeerRpcManager` 或 transport trait 要求 unbounded receiver 类型且无法在范围内替换。
- 正确实现需要改变 RPC protocol semantics 或 routing trust logic。
- bounded queue 导致现有 integration tests 稳定失败,且不能通过容量或 policy 微调解决。
## 维护说明
- reviewer 应重点审查 drop policy 是否适合 control-plane:丢弃低优先级 sync packet 可以接受,但不能默默破坏必须可靠的 request/response path。
- 后续如果出现 reconnect storm 或 route sync loss,应结合 drop metrics 调整 capacity。
- 本计划不实现优先级队列;如果未来需要区分 `RpcReq`、`RpcResp`、`TaRpc` 优先级,应另写计划。
-176
View File
@@ -1,176 +0,0 @@
# 计划 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 tests:newer version true,same/older version false。
- 新增 `update_conn_info` changed flag tests:changed topology true,identical topology false。
- 不要求跑完整 privileged nextest matrix;至少 archive 编译所有 tests。
## 完成标准
- [ ] stale/duplicate peer info 不再设置 `need_update_route_table = true`。
- [ ] unchanged conn info 不再设置 `need_update_route_table = true`。
- [ ] duplicate peer ID check 仍在 stale/no-op 判断前执行。
- [ ] 新增 tests 覆盖 true/false change flag。
- [ ] `cargo fmt --all -- --check` exits 0。
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
- [ ] 没有修改范围外文件。
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
## STOP 条件
- `update_peer_infos` 的返回值已被其他分支重构,当前摘录不匹配。
- 判断 no-op 需要改变 route trust、credential 或 duplicate peer semantics。
- 无法构造可靠测试,且只能通过完整三节点集成测试验证;停止并报告需要 reviewer 决定测试策略。
## 维护说明
- 后续任何 route sync state mutation 都应返回 changed flag,并只在 actual change 时触发 route rebuild。
- reviewer 应重点检查 version bookkeeping:不要为了省重算而漏掉必要 route refresh。
- 本计划不减少单次 rebuild 的成本;那由 `plans/004-reuse-ospf-route-graph.md` 处理。
-176
View File
@@ -1,176 +0,0 @@
# 计划 004:复用 OSPF route-table 构图以减少拓扑更新成本
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
>
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests`
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
## 状态
- **优先级**: P2
- **工作量**: M
- **风险**: MED
- **依赖**: plans/003-avoid-noop-ospf-route-rebuilds.md
- **类别**: perf
- **计划生成于**: commit `78146d16`, 2026-06-18
## 为什么重要
每次 OSPF 拓扑更新当前会分别为 least-hop 和 least-cost route table 调用 `build_from_synced_info`。每次调用都会从 synced info 重新构建 peer graph,并重新构建 peer/CIDR indexes。对于 peer 数和 proxy CIDR 数较大的 mesh,这把一次拓扑变化放大成两次完整构图和多次 map/trie 重建。完成后应保持 route selection 结果不变,同时复用同一份 graph/materialized synced view,减少 CPU 和分配成本。
## 当前状态
- `easytier/src/peers/peer_ospf_route.rs` — `update_route_table`、graph builder、least-hop/least-cost map generation、CIDR trie rebuild 均在此文件。
当前代码摘录:
```rust
// easytier/src/peers/peer_ospf_route.rs:1628
// build next hop map
let (graph, start_node) =
Self::build_peer_graph_from_synced_info(my_peer_id, synced_info, cost_calc);
// easytier/src/peers/peer_ospf_route.rs:1649
if matches!(policy, NextHopPolicy::LeastHop) {
self.gen_next_hop_map_with_least_hop(&graph, &start_node, version);
} else {
self.gen_next_hop_map_with_least_cost(&graph, &start_node, version);
};
// easytier/src/peers/peer_ospf_route.rs:1655
let mut new_cidr_prefix_trie = PrefixMap::new();
let mut new_cidr_v6_prefix_trie = PrefixMap::new();
```
```rust
// easytier/src/peers/peer_ospf_route.rs:2453
fn update_route_table(&self) {
// ...
self.route_table.build_from_synced_info(
self.my_peer_id,
&self.synced_route_info,
NextHopPolicy::LeastHop,
calc_locked.as_ref().unwrap(),
);
self.route_table_with_cost.build_from_synced_info(
self.my_peer_id,
&self.synced_route_info,
NextHopPolicy::LeastCost,
calc_locked.as_ref().unwrap(),
);
}
```
仓库约定:core routing behavior must be preserved。先添加 characterization tests,再重构;不要在同一计划里改 route policy。
## 需要使用的命令
| Purpose | Command | Expected on success |
|---------|---------|---------------------|
| Format | `cargo fmt --all -- --check` | exit 0 |
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; route characterization tests pass |
| Archive tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
## 临时目录约定
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
- 不要把临时产物放进被修改仓库。
## 范围
**范围内**(只能修改这些文件):
- `easytier/src/peers/peer_ospf_route.rs`
- `easytier/src/tests/*`(仅新增/调整 route-table characterization tests)
**范围外**(即使看起来相关也不要触碰):
- Route protocol schema and wire format。
- Credential/trust semantics。
- Peer center、foreign network manager、data-plane tunnels。
- Any UI or config surface。
## Git 工作流
- Branch: `advisor/004-reuse-ospf-route-graph`
- Commit message style follows existing conventional commits, for example `refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector`.
- Do NOT push or open a PR unless the operator instructed it.
## 步骤
### 步骤 1:添加 route-table characterization tests
在修改实现前,新增测试覆盖至少一个包含以下元素的小拓扑:
- 本 peer、两个 reachable peers、一个 unreachable 或 outdated peer。
- 至少一个 IPv4 proxy CIDR 和一个 IPv6 proxy CIDR。
- least-hop 和 least-cost 结果不同或至少都被断言。
测试应断言当前 `route_table` 和 `route_table_with_cost` 对 peer next-hop、peer reachability、CIDR lookup 的结果。优先使用现有测试 helper;如果内部 API 不便,添加 `#[cfg(test)]` helper,不改变生产 API。
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;新增 characterization tests 在重构前通过。
### 步骤 2:抽出一次性 graph build 输入
在 `peer_ospf_route.rs` 中把 `build_from_synced_info` 内部的 graph construction 拆成私有 helper,例如:
- `build_peer_graph_from_synced_info(...)` 已存在则复用。
- 新增 small struct 持有 `graph`、`start_node`、`version` 和后续 index rebuild 需要的 synced snapshot references。
不要改变 `gen_next_hop_map_with_least_hop` 或 `gen_next_hop_map_with_least_cost` 的算法。
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
### 步骤 3:让 `update_route_table` 对两种 policy 复用 graph
把 `update_route_table` 改为在持有 `cost_calculator` read lock 时构建一次 graph/materialized input,然后分别对 `self.route_table` 和 `self.route_table_with_cost` 应用 least-hop / least-cost generation。
如果现有 `RouteTable::build_from_synced_info` 是唯一封装点,可以新增一个 sibling method,例如 `build_from_prebuilt_graph(...)`,保持旧方法用于兼容 tests 或其他调用方。
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;characterization tests 仍通过。
### 步骤 4:避免重复构建共享 indexes
检查 `build_from_synced_info` 中 peer info map、IPv4 map、CIDR tries 的生成是否依赖 policy-specific next-hop map。如果只依赖 reachability 或 synced info,可移动到共享 helper;如果依赖每个 `RouteTable` 自己的 `next_hop_map`,不要强行共享,避免改变 semantics。
允许分阶段收益:只共享 graph build 也可完成本计划;共享 CIDR/index 只有在 characterization tests 能证明 behavior 不变时才做。
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
### 步骤 5:运行完整相关门禁
**验证**:
- `cargo fmt --all -- --check` → exit 0。
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
## 测试计划
- 新增 route-table characterization tests,先在重构前证明现有行为,再在重构后保持通过。
- 测试覆盖 least-hop、least-cost、CIDR lookup、unreachable peer exclusion。
- 如果可行,加入一个轻量 counter/helper 在 test-only path 确认 graph builder 调用次数从 2 降为 1;如果这需要侵入生产代码,则不要做。
## 完成标准
- [ ] `update_route_table` 不再对同一 synced topology 构建两次 peer graph。
- [ ] least-hop 和 least-cost route outputs 与 characterization tests 中的旧行为一致。
- [ ] 没有改变 routing protocol、credential trust 或 config behavior。
- [ ] `cargo fmt --all -- --check` exits 0。
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
- [ ] 没有修改范围外文件。
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
## STOP 条件
- `plans/003-avoid-noop-ospf-route-rebuilds.md` 未完成,且当前 route rebuild trigger 仍会对 no-op payload 重算。
- 复用 graph 需要改变 least-hop 或 least-cost algorithm。
- 现有代码让 `cost_calc` 在两次 build 之间发生有意状态变化;如果确认 `begin_update`/`end_update` 依赖两次独立 build,停止并报告。
- Characterization tests 无法稳定构造 route-table expected outputs。
## 维护说明
- reviewer 应重点审查是否在锁持有期间引入更长 critical section。
- 未来新增 route policy 时应复用本计划抽出的 prebuilt graph input,而不是再调用完整 `build_from_synced_info`。
- 本计划不拆分 `peer_ospf_route.rs` 大文件;只做局部性能重构。
-164
View File
@@ -1,164 +0,0 @@
# 计划 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
@@ -1,200 +0,0 @@
# 计划 006:send_msg_internal 发包链路 CPU 优化
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。完成后更新 `plans/README.md` 中本计划的状态行。
## 状态
- **优先级**: P1
- **工作量**: M
- **风险**: LOW
- **依赖**: hotpath profiling infra (main branch commit `be2034dd`)
- **类别**: performance
- **数据来源**: hotpath-cpu samply 423,583,601 samples,4 threads,234K pps,pkt_size=1400
## 为什么重要
`send_msg_internal` 是数据面包转发的核心路径,每包耗时 3.26µs(wall time)。在 234K pps 下占 wall time 的 ~70%。samply inclusive CPU 分解显示有多处可通过减少冗余操作来省 µs 级开销。每包省 1µs 即可将吞吐提升 ~30%。
## 数据基线
### timing(wall 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µs(conn 选择) |
| └─ `PeerConn::send_msg` | 6.9M | 2.58µs | 0.11µs(session 选择) |
### samply inclusive CPU(send_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 查询。每次 ~100ns(hash + 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`
### 步骤 2:TrafficMetricRecorder 降频记录(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 对比。
### 步骤 4:mpsc batch send(P3,预期省 ~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`
### 步骤 5:ZCPacket 池化(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`
## 预期总收益
| 步骤 | 每包省 | 累计 |
|---|---|---|
| 步骤 1(dashmap 合并) | ~0.15µs | 3.26→3.11µs |
| 步骤 2(metrics 降频) | ~0.25µs | 3.11→2.86µs |
| 步骤 3(缓存时间戳) | ~0.13µs | 2.86→2.73µs |
| 步骤 4(batch send) | ~0.46µs | 2.73→2.27µs |
| 步骤 5(packet 池化) | ~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
@@ -1,35 +0,0 @@
# 实施计划
由 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:有价值,但用户本次只要求正确性和性能计划。
+23 -763
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
#!/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"