mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 03:22:05 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09cd15725e | ||
|
|
90c45d2964 | ||
|
|
0e665eafc6 | ||
|
|
97526a95a1 | ||
|
|
2794b96028 | ||
|
|
42b6d326a8 | ||
|
|
b37c1539e1 | ||
|
|
8312400913 | ||
|
|
19647f296a | ||
|
|
94d056cb78 | ||
|
|
4c7c3d7672 | ||
|
|
d99efba64f | ||
|
|
6d01908593 | ||
|
|
4b654fc56e | ||
|
|
1fdd4b0abe | ||
|
|
ba4fde40ad | ||
|
|
cdec67ff53 | ||
|
|
7e0cdfc683 | ||
|
|
1d80439c7c | ||
|
|
392a970db1 | ||
|
|
368d140b5b | ||
|
|
57cc9922a4 | ||
|
|
4875393327 | ||
|
|
37f742272b | ||
|
|
d4ef9decd8 | ||
|
|
3464cb801a | ||
|
|
e18387b06b | ||
|
|
31c639f70c | ||
|
|
79035ea972 | ||
|
|
7205517160 | ||
|
|
be2034dd06 | ||
|
|
f0d00d6161 |
@@ -33,6 +33,19 @@ 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
|
||||
@@ -82,9 +95,3 @@ 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 }}
|
||||
@@ -41,8 +41,8 @@ runs:
|
||||
pnpm -r install
|
||||
if [ -n "${{ inputs.build-filter }}" ]; then
|
||||
echo "Building with filter: ${{ inputs.build-filter }}"
|
||||
pnpm -r --filter "${{ inputs.build-filter }}" build
|
||||
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
|
||||
else
|
||||
echo "No build filter provided, building all packages"
|
||||
pnpm -r build
|
||||
pnpm -r --workspace-concurrency=1 build
|
||||
fi
|
||||
|
||||
@@ -34,6 +34,7 @@ easytier-panic.log
|
||||
|
||||
# web
|
||||
node_modules
|
||||
easytier-web/frontend-lib/src/generated/
|
||||
|
||||
.vite
|
||||
|
||||
|
||||
Generated
+187
-1
@@ -257,6 +257,12 @@ 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"
|
||||
@@ -1248,6 +1254,12 @@ 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"
|
||||
@@ -2088,6 +2100,16 @@ 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"
|
||||
@@ -2111,6 +2133,17 @@ 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"
|
||||
@@ -2289,6 +2322,7 @@ dependencies = [
|
||||
"hickory-resolver",
|
||||
"hickory-server",
|
||||
"hmac",
|
||||
"hotpath",
|
||||
"http",
|
||||
"http_req",
|
||||
"humansize",
|
||||
@@ -2328,8 +2362,10 @@ dependencies = [
|
||||
"prost-reflect",
|
||||
"prost-reflect-build",
|
||||
"prost-wkt-types",
|
||||
"quanta",
|
||||
"quinn",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"quote",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
@@ -2630,6 +2666,12 @@ 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"
|
||||
@@ -3888,6 +3930,61 @@ 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"
|
||||
@@ -4495,6 +4592,17 @@ 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"
|
||||
@@ -5613,7 +5721,7 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 2.0.0",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -5845,6 +5953,15 @@ 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"
|
||||
@@ -6715,6 +6832,19 @@ 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"
|
||||
@@ -6897,9 +7027,12 @@ 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]]
|
||||
@@ -7013,6 +7146,21 @@ 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"
|
||||
@@ -7266,6 +7414,15 @@ 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"
|
||||
@@ -7747,6 +7904,12 @@ 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"
|
||||
@@ -9671,6 +9834,17 @@ 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"
|
||||
@@ -9826,6 +10000,18 @@ 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"
|
||||
|
||||
@@ -27,3 +27,9 @@ lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
# For hotpath CPU profiling: samply needs debug symbols and unstripped binaries.
|
||||
[profile.hotpath]
|
||||
inherits = "release"
|
||||
strip = false
|
||||
debug = "line-tables-only"
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
# hotpath CPU 热点分析与发包链路优化
|
||||
|
||||
## 概述
|
||||
|
||||
本文档记录了使用 hotpath + samply 对 easytier-core 发包链路进行 CPU 热点分析的全过程,包括工具链搭建、热点定位、优化实施、踩坑记录和最终 benchmark 结果。
|
||||
|
||||
## 最终 benchmark 数据
|
||||
|
||||
### 真实性能对比(不带 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 本体完整路径 |
|
||||
@@ -5,8 +5,8 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
|
||||
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "eslint . --ignore-pattern src-tauri",
|
||||
|
||||
@@ -13,12 +13,18 @@
|
||||
"./*.css": "./dist/*.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"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",
|
||||
"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",
|
||||
@@ -33,9 +39,13 @@
|
||||
},
|
||||
"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",
|
||||
@@ -43,10 +53,11 @@
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.12",
|
||||
"primevue": "^4.3.9"
|
||||
"primevue": "^4.3.9",
|
||||
"vue": "^3.5.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2482
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
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 { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AclManager from './acl/AclManager.vue'
|
||||
import UrlListInput from './UrlListInput.vue'
|
||||
@@ -134,6 +134,7 @@ function savePortForward() {
|
||||
const portForwardContainer = ref<HTMLElement | null>(null);
|
||||
const isCompact = ref(false);
|
||||
|
||||
const UINT64_MAX = (1n << 64n) - 1n
|
||||
|
||||
onMounted(() => {
|
||||
if (portForwardContainer.value) {
|
||||
@@ -161,6 +162,39 @@ 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>
|
||||
@@ -317,9 +351,9 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
|
||||
<span class="pi pi-question-circle ml-2 self-center"
|
||||
v-tooltip="t('instance_recv_bps_limit_help')"></span>
|
||||
</div>
|
||||
<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 />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,166 +1,49 @@
|
||||
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 enum NetworkingMethod {
|
||||
PublicServer = 0,
|
||||
Manual = 1,
|
||||
Standalone = 2,
|
||||
}
|
||||
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
|
||||
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
export type NetworkConfig = Omit<
|
||||
ProtoNetworkConfig,
|
||||
'instance_id' | 'instance_recv_bps_limit' | 'mtu' | 'networking_method'
|
||||
> & {
|
||||
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[]
|
||||
|
||||
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[]
|
||||
instance_recv_bps_limit: number | string | null
|
||||
networking_method: NetworkingMethod | string
|
||||
}
|
||||
|
||||
enable_magic_dns?: boolean
|
||||
enable_private_mode?: boolean
|
||||
const UINT64_MAX = (1n << 64n) - 1n
|
||||
|
||||
port_forwards: PortForwardConfig[]
|
||||
acl?: Acl
|
||||
interface NetworkingConfigFields {
|
||||
peer_urls: string[]
|
||||
public_server_url?: string
|
||||
networking_method?: NetworkingMethod | string
|
||||
}
|
||||
|
||||
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
return {
|
||||
...NetworkConfigPb.create(),
|
||||
|
||||
instance_id: uuidv4(),
|
||||
|
||||
dhcp: true,
|
||||
@@ -243,33 +126,82 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
|
||||
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
|
||||
}
|
||||
|
||||
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
const normalized: NetworkConfig = {
|
||||
...config,
|
||||
peer_urls: cleanPeerUrls(config.peer_urls),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
|
||||
function normalizeNumberForInput(v: number | string | null | undefined): number | null {
|
||||
if (v == null) return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
switch (normalized.networking_method) {
|
||||
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) {
|
||||
case NetworkingMethod.PublicServer:
|
||||
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||
config.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||
break
|
||||
case NetworkingMethod.Manual:
|
||||
break
|
||||
case NetworkingMethod.Standalone:
|
||||
default:
|
||||
normalized.peer_urls = []
|
||||
config.peer_urls = []
|
||||
break
|
||||
}
|
||||
|
||||
normalized.networking_method = NetworkingMethod.Manual
|
||||
normalized.public_server_url = ''
|
||||
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,
|
||||
)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
return normalizeNetworkConfig(config)
|
||||
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
|
||||
}
|
||||
|
||||
export interface NetworkInstance {
|
||||
@@ -397,14 +329,6 @@ 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({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
@@ -0,0 +1,12 @@
|
||||
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'],
|
||||
},
|
||||
})
|
||||
@@ -4,8 +4,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"dev": "pnpm --dir ../frontend-lib build && vite",
|
||||
"build": "pnpm --dir ../frontend-lib 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -52,6 +52,8 @@ 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"
|
||||
|
||||
@@ -83,6 +85,7 @@ atomic_refcell = "0.1.13"
|
||||
|
||||
quinn = { version = "0.11.8", optional = true, features = ["ring"] }
|
||||
quinn-proto = { version = "0.11.12", optional = true }
|
||||
quinn-udp = { version = "0.5", optional = true }
|
||||
seahash = { version = "4.1.0", optional = true }
|
||||
|
||||
rustls = { version = "0.23.0", features = [
|
||||
@@ -129,7 +132,7 @@ once_cell = "1.18.0"
|
||||
|
||||
# for rpc
|
||||
prost = "0.14.3"
|
||||
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive"] }
|
||||
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive", "serde"] }
|
||||
prost-wkt-types = "0.7.1"
|
||||
pbjson = "0.9.0"
|
||||
|
||||
@@ -359,6 +362,7 @@ default = [
|
||||
"faketcp",
|
||||
"magic-dns",
|
||||
"zstd",
|
||||
"udp-gso",
|
||||
]
|
||||
full = [
|
||||
"websocket",
|
||||
@@ -376,6 +380,7 @@ full = [
|
||||
]
|
||||
wireguard = ["dep:boringtun", "dep:ring"]
|
||||
quic = ["dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "dep:rcgen"]
|
||||
udp-gso = ["dep:quinn-udp"]
|
||||
kcp = ["dep:kcp-sys"]
|
||||
mimalloc = ["dep:mimalloc"]
|
||||
aes-gcm = ["dep:aes-gcm"]
|
||||
@@ -401,6 +406,15 @@ 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"]
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! CPU hotspot benchmark for hotpath profiling.
|
||||
//!
|
||||
//! Builds two no-tun EasyTier instances connected via an in-process ring
|
||||
//! tunnel, lets routes converge, then floods data-plane packets through
|
||||
//! `send_msg_by_ip` so that `hotpath-cpu` / samply can collect meaningful
|
||||
//! CPU samples.
|
||||
//!
|
||||
//! Build & run:
|
||||
//! cargo run --profile hotpath --features hotpath,hotpath-cpu \
|
||||
//! --example cpu_hotspot_ring
|
||||
//!
|
||||
//! Prerequisites: hotpath-samply + samply must be installed and on PATH.
|
||||
//! See bench/006-hotpath-cpu-top.md for install instructions.
|
||||
//!
|
||||
//! Then in another terminal:
|
||||
//! hotpath console
|
||||
|
||||
#[cfg(feature = "mimalloc")]
|
||||
#[global_allocator]
|
||||
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
#[cfg(feature = "jemalloc")]
|
||||
#[global_allocator]
|
||||
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::BytesMut;
|
||||
|
||||
use easytier::common::config::{ConfigLoader, PeerConfig, TomlConfigLoader};
|
||||
use easytier::instance::instance::Instance;
|
||||
use easytier::tunnel::packet_def::ZCPacket;
|
||||
use easytier::tunnel::ring::RingTunnelConnector;
|
||||
use easytier::tunnel::udp::UdpTunnelConnector;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::main)]
|
||||
async fn main() {
|
||||
let duration = std::env::var("HOTPATH_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30u64);
|
||||
|
||||
let pkt_size: usize = std::env::var("HOTPATH_PKT_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1400);
|
||||
|
||||
let tunnel_type = std::env::var("HOTPATH_TUNNEL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "ring".to_string());
|
||||
|
||||
let (inst_a_config, inst_b_config) = match tunnel_type.as_str() {
|
||||
"udp" => {
|
||||
let mut a = no_tun_config("hot-a", "10.144.144.1");
|
||||
a.set_listeners(vec!["udp://0.0.0.0:35521".parse().unwrap()]);
|
||||
(a, no_tun_config("hot-b", "10.144.144.2"))
|
||||
}
|
||||
"tcp" => {
|
||||
let mut a = no_tun_config("hot-a", "10.144.144.1");
|
||||
a.set_listeners(vec!["tcp://0.0.0.0:35522".parse().unwrap()]);
|
||||
(a, no_tun_config("hot-b", "10.144.144.2"))
|
||||
}
|
||||
_ => (
|
||||
no_tun_config("hot-a", "10.144.144.1"),
|
||||
no_tun_config("hot-b", "10.144.144.2"),
|
||||
),
|
||||
};
|
||||
|
||||
let mut inst_a = Instance::new(inst_a_config);
|
||||
let mut inst_b = Instance::new(inst_b_config);
|
||||
|
||||
inst_a.run().await.expect("inst_a run");
|
||||
inst_b.run().await.expect("inst_b run");
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
match tunnel_type.as_str() {
|
||||
"ring" => {
|
||||
let ring_url = format!("ring://{}", inst_a.id());
|
||||
inst_b
|
||||
.get_conn_manager()
|
||||
.add_connector(RingTunnelConnector::new(ring_url.parse().unwrap()));
|
||||
}
|
||||
"udp" => {
|
||||
inst_b.get_conn_manager().add_connector(
|
||||
UdpTunnelConnector::new("udp://127.0.0.1:35521".parse().unwrap()),
|
||||
);
|
||||
}
|
||||
"tcp" => {
|
||||
inst_b.get_conn_manager().add_connector(
|
||||
easytier::tunnel::tcp::TcpTunnelConnector::new(
|
||||
"tcp://127.0.0.1:35522".parse().unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let dst: IpAddr = "10.144.144.2".parse().unwrap();
|
||||
let src = "10.144.144.1";
|
||||
|
||||
let converged = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let a = inst_a.get_peer_manager().list_routes().await;
|
||||
let b = inst_b.get_peer_manager().list_routes().await;
|
||||
if a.len() >= 1 && b.len() >= 1 {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
if !converged {
|
||||
eprintln!("warning: routes did not converge within 15s");
|
||||
}
|
||||
|
||||
println!(
|
||||
"cpu_hotspot_ring: flooding {}s, pkt_size={}, tunnel={} (converged={})",
|
||||
duration, pkt_size, tunnel_type, converged
|
||||
);
|
||||
|
||||
let pm = inst_a.get_peer_manager();
|
||||
let send_pkt = make_data_packet(src, "10.144.144.2", pkt_size);
|
||||
|
||||
let batch_threshold: u32 = std::env::var("HOTPATH_BATCH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
// After convergence, enable batch flush for writev optimization
|
||||
if converged && batch_threshold > 1 {
|
||||
pm.set_peer_conn_batch_threshold(batch_threshold);
|
||||
println!("cpu_hotspot_ring: batch_threshold={}", batch_threshold);
|
||||
}
|
||||
|
||||
let pipeline_depth: usize = std::env::var("HOTPATH_PIPELINE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
println!(
|
||||
"cpu_hotspot_ring: pipeline_depth={}",
|
||||
pipeline_depth
|
||||
);
|
||||
|
||||
let sender_task = tokio::spawn(async move {
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
let mut sent: u64 = 0;
|
||||
let start = Instant::now();
|
||||
let mut in_flight = FuturesUnordered::new();
|
||||
|
||||
loop {
|
||||
while in_flight.len() < pipeline_depth {
|
||||
let pkt = send_pkt.clone();
|
||||
in_flight.push(pm.send_msg_by_ip(pkt, dst, false));
|
||||
}
|
||||
in_flight.next().await;
|
||||
sent += 1;
|
||||
if sent % 10000 == 0 {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let pps = sent as f64 / elapsed;
|
||||
let mbps = pps * pkt_size as f64 * 8.0 / 1_000_000.0;
|
||||
println!("sent {} pkts ({:.0} pps, {:.0} Mbps)", sent, pps, mbps);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(duration)).await;
|
||||
sender_task.abort();
|
||||
|
||||
println!("cpu_hotspot_ring: done");
|
||||
}
|
||||
|
||||
fn make_data_packet(src: &str, dst: &str, total_size: usize) -> ZCPacket {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
let hdr_len = 28;
|
||||
let payload_len = total_size.saturating_sub(hdr_len);
|
||||
let ip_total_len = (hdr_len + payload_len) as u16;
|
||||
|
||||
let mut buf = BytesMut::with_capacity(total_size);
|
||||
|
||||
buf.extend_from_slice(&[
|
||||
0x45,
|
||||
0x00,
|
||||
(ip_total_len >> 8) as u8,
|
||||
(ip_total_len & 0xff) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
0x40,
|
||||
0x00,
|
||||
0x40,
|
||||
0x11,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
let src: Ipv4Addr = src.parse().unwrap();
|
||||
buf.extend_from_slice(&src.octets());
|
||||
let dst: Ipv4Addr = dst.parse().unwrap();
|
||||
buf.extend_from_slice(&dst.octets());
|
||||
|
||||
let udp_len = (8 + payload_len) as u16;
|
||||
buf.extend_from_slice(&[
|
||||
0x30,
|
||||
0x39,
|
||||
0xD4,
|
||||
0x31,
|
||||
(udp_len >> 8) as u8,
|
||||
(udp_len & 0xff) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
buf.resize(total_size, 0xAA);
|
||||
|
||||
ZCPacket::new_with_payload(&buf)
|
||||
}
|
||||
|
||||
fn no_tun_config(name: &str, ipv4: &str) -> TomlConfigLoader {
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_inst_name(name.to_owned());
|
||||
config.set_ipv4(Some(ipv4.parse().unwrap()));
|
||||
let mut flags = config.get_flags();
|
||||
flags.no_tun = true;
|
||||
config.set_flags(flags);
|
||||
config
|
||||
}
|
||||
@@ -3,9 +3,11 @@ use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
str::FromStr as _,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
time::{Duration, 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 _;
|
||||
@@ -107,10 +109,10 @@ impl AclCacheKey {
|
||||
|
||||
// Cache entry with timestamp for LRU cleanup
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AclCacheEntry {
|
||||
pub(crate) struct AclCacheEntry {
|
||||
pub action: Action,
|
||||
pub matched_rule: RuleId,
|
||||
pub last_access: std::time::Instant,
|
||||
pub last_access: Instant,
|
||||
// New fields to track rule characteristics for proper cache behavior
|
||||
pub conn_track_key: Option<String>,
|
||||
pub rate_limit_keys: Vec<RateLimitKey>,
|
||||
@@ -410,7 +412,7 @@ impl AclProcessor {
|
||||
}
|
||||
|
||||
// Remove oldest entries (LRU cleanup)
|
||||
let mut entries: Vec<(AclCacheKey, std::time::Instant)> = cache
|
||||
let mut entries: Vec<(AclCacheKey, Instant)> = cache
|
||||
.iter()
|
||||
.map(|entry| (entry.key().clone(), entry.value().last_access))
|
||||
.collect();
|
||||
@@ -431,7 +433,7 @@ impl AclProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn process_packet_with_cache_entry(
|
||||
pub(crate) fn process_packet_with_cache_entry(
|
||||
&self,
|
||||
packet_info: &PacketInfo,
|
||||
cache_entry: &AclCacheEntry,
|
||||
|
||||
@@ -129,6 +129,7 @@ impl Compressor for DefaultCompressor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "DefaultCompressor"))]
|
||||
async fn decompress(&self, zc_packet: &mut ZCPacket) -> Result<(), Error> {
|
||||
let pm_header = zc_packet.peer_manager_header().unwrap();
|
||||
if !pm_header.is_compressed() {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 _;
|
||||
@@ -78,6 +79,54 @@ 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(¤t_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()
|
||||
@@ -1093,28 +1142,9 @@ 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(flag_map);
|
||||
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
||||
if config.stun_servers == Some(StunInfoCollector::get_default_servers()) {
|
||||
config.stun_servers = None;
|
||||
}
|
||||
@@ -1460,6 +1490,49 @@ 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,9 +1,10 @@
|
||||
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, Instant};
|
||||
use std::time::Duration;
|
||||
use tokio::time::interval;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ 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, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
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};
|
||||
@@ -1312,7 +1313,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: std::time::Instant::now().elapsed().as_secs() as i64,
|
||||
last_update_time: Local::now().timestamp(),
|
||||
min_port: 100,
|
||||
max_port: 200,
|
||||
public_ip: vec!["127.0.0.1".to_string(), "::1".to_string()],
|
||||
|
||||
@@ -8,9 +8,11 @@ use std::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hotpath::instant::Instant;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
PeerId, dns::socket_addrs, error::Error, global_ctx::ArcGlobalCtx,
|
||||
|
||||
@@ -2,10 +2,11 @@ use std::{
|
||||
collections::BTreeSet,
|
||||
future::Future,
|
||||
sync::{Arc, Weak},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use dashmap::DashSet;
|
||||
use hotpath::instant::Instant;
|
||||
use tokio::{sync::mpsc, task::JoinSet, time::timeout};
|
||||
|
||||
use crate::{
|
||||
|
||||
@@ -70,6 +70,8 @@ async fn set_bind_addr_for_peer_connector(
|
||||
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
|
||||
if is_ipv4 {
|
||||
let mut bind_addrs = vec![];
|
||||
// Always include loopback so localhost connections work
|
||||
bind_addrs.push(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
|
||||
for ipv4 in ips.interface_ipv4s {
|
||||
let socket_addr = SocketAddrV4::new(ipv4.into(), 0).into();
|
||||
bind_addrs.push(socket_addr);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Error};
|
||||
use hotpath::instant::Instant;
|
||||
use rand::Rng as _;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr, SocketAddrV4},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use hotpath::instant::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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};
|
||||
@@ -356,9 +357,9 @@ pub(crate) struct UdpHolePunchListener {
|
||||
_port_mapping_lease: Option<upnp::UdpPortMappingLease>,
|
||||
conn_counter: Arc<Box<dyn TunnelConnCounter>>,
|
||||
|
||||
listen_time: std::time::Instant,
|
||||
last_select_time: AtomicCell<std::time::Instant>,
|
||||
last_active_time: Arc<AtomicCell<std::time::Instant>>,
|
||||
listen_time: Instant,
|
||||
last_select_time: AtomicCell<Instant>,
|
||||
last_active_time: Arc<AtomicCell<Instant>>,
|
||||
}
|
||||
|
||||
impl UdpHolePunchListener {
|
||||
@@ -421,14 +422,14 @@ impl UdpHolePunchListener {
|
||||
running_clone.store(false);
|
||||
});
|
||||
|
||||
let last_active_time = Arc::new(AtomicCell::new(std::time::Instant::now()));
|
||||
let last_active_time = Arc::new(AtomicCell::new(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(std::time::Instant::now());
|
||||
last_active_time_clone.store(Instant::now());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -444,14 +445,14 @@ impl UdpHolePunchListener {
|
||||
_port_mapping_lease: port_mapping_lease,
|
||||
conn_counter,
|
||||
|
||||
listen_time: std::time::Instant::now(),
|
||||
last_select_time: AtomicCell::new(std::time::Instant::now()),
|
||||
listen_time: Instant::now(),
|
||||
last_select_time: AtomicCell::new(Instant::now()),
|
||||
last_active_time,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_socket(&self) -> Arc<UdpSocket> {
|
||||
self.last_select_time.store(std::time::Instant::now());
|
||||
self.last_select_time.store(Instant::now());
|
||||
self.socket.clone()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use hotpath::instant::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Error};
|
||||
@@ -8,6 +8,7 @@ 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,11 +5,12 @@ use std::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -24,6 +30,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use hotpath::instant::Instant;
|
||||
use pnet::packet::{
|
||||
Packet,
|
||||
icmp::{self, IcmpCode, IcmpTypes, MutableIcmpPacket, echo_reply::MutableEchoReplyPacket},
|
||||
@@ -45,7 +46,7 @@ struct IcmpNatEntry {
|
||||
src_peer_id: PeerId,
|
||||
my_peer_id: PeerId,
|
||||
src_ip: IpAddr,
|
||||
start_time: std::time::Instant,
|
||||
start_time: Instant,
|
||||
mapped_dst_ip: std::net::Ipv4Addr,
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ impl IcmpNatEntry {
|
||||
src_peer_id,
|
||||
my_peer_id,
|
||||
src_ip,
|
||||
start_time: std::time::Instant::now(),
|
||||
start_time: Instant::now(),
|
||||
mapped_dst_ip,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::common::error::Error;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) struct CidrSet {
|
||||
mapped_to_real: Arc<DashMap<cidr::Ipv4Cidr, cidr::Ipv4Cidr>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
|
||||
impl CidrSet {
|
||||
pub fn new(global_ctx: ArcGlobalCtx) -> Self {
|
||||
let mut ret = Self {
|
||||
|
||||
@@ -1018,6 +1018,7 @@ 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.
|
||||
@@ -1197,7 +1198,7 @@ mod tests {
|
||||
// Accept unidirectional stream
|
||||
let mut recv = connection.accept_uni().await.unwrap();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let start = Instant::now();
|
||||
let mut received = 0;
|
||||
|
||||
// Loop read until the stream ends
|
||||
@@ -1234,7 +1235,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 = std::time::Instant::now();
|
||||
let start_send = Instant::now();
|
||||
|
||||
let chunks = TOTAL_SIZE / CHUNK_SIZE;
|
||||
for _ in 0..chunks {
|
||||
@@ -1276,7 +1277,7 @@ mod tests {
|
||||
println!("Server: Accepted connection");
|
||||
|
||||
let mut stream_handles = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
let start = Instant::now();
|
||||
|
||||
// Accept an expected number of streams
|
||||
for i in 0..STREAM_COUNT {
|
||||
@@ -1346,7 +1347,7 @@ mod tests {
|
||||
STREAM_COUNT
|
||||
);
|
||||
|
||||
let start_send = std::time::Instant::now();
|
||||
let start_send = Instant::now();
|
||||
let mut client_tasks = Vec::new();
|
||||
|
||||
// Start sending tasks concurrently
|
||||
|
||||
@@ -5,10 +5,11 @@ use std::{
|
||||
Arc, Weak,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use hotpath::instant::Instant;
|
||||
#[cfg(feature = "kcp")]
|
||||
use kcp_sys::{endpoint::KcpEndpoint, stream::KcpStream};
|
||||
use tokio_util::sync::{CancellationToken, DropGuard};
|
||||
@@ -28,7 +29,7 @@ use crate::{
|
||||
ip_reassembler::IpReassembler,
|
||||
tokio_smoltcp::{BufferSize, Net, NetConfig, channel_device},
|
||||
},
|
||||
tunnel::packet_def::{PacketType, ZCPacket, ZCPacketType},
|
||||
tunnel::packet_def::{PacketType, ZCPacket},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use dashmap::DashMap;
|
||||
@@ -363,10 +364,7 @@ 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(bytes::BytesMut::from(packet.payload())))
|
||||
.await
|
||||
{
|
||||
if let Err(e) = stack_sink.send(Ok(packet.payload().to_vec())).await {
|
||||
tracing::error!("send to smoltcp stack failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
@@ -380,16 +378,13 @@ impl Socks5ServerNet {
|
||||
"receive from smoltcp stack and send to peer mgr packet, len = {}",
|
||||
data.len()
|
||||
);
|
||||
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"
|
||||
);
|
||||
let Some(ipv4) = Ipv4Packet::new(&data) else {
|
||||
tracing::error!(?data, "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;
|
||||
@@ -418,8 +413,7 @@ 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));
|
||||
|
||||
@@ -22,11 +22,12 @@ use std::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
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};
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -12,7 +13,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, Instant};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, copy_bidirectional};
|
||||
use tokio::net::{TcpListener, TcpSocket, TcpStream};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
@@ -39,8 +40,6 @@ 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 {
|
||||
@@ -563,10 +562,7 @@ 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(bytes::BytesMut::from(packet.payload())))
|
||||
.await
|
||||
{
|
||||
if let Err(e) = stack_sink.send(Ok(packet.payload().to_vec())).await {
|
||||
tracing::error!("send to smoltcp stack failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
@@ -580,16 +576,13 @@ impl<C: NatDstConnector> TcpProxy<C> {
|
||||
?data,
|
||||
"receive from smoltcp stack and send to peer mgr 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"
|
||||
);
|
||||
let Some(ipv4) = Ipv4Packet::new(&data) else {
|
||||
tracing::error!(?data, "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;
|
||||
@@ -618,8 +611,7 @@ 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,4 +1,3 @@
|
||||
use bytes::BytesMut;
|
||||
use futures::{Sink, Stream};
|
||||
use smoltcp::phy::DeviceCapabilities;
|
||||
use std::{
|
||||
@@ -13,15 +12,15 @@ use super::device::AsyncDevice;
|
||||
|
||||
/// A device that send and receive packets using a channel.
|
||||
pub struct ChannelDevice {
|
||||
recv: Receiver<io::Result<BytesMut>>,
|
||||
send: PollSender<BytesMut>,
|
||||
recv: Receiver<io::Result<Vec<u8>>>,
|
||||
send: PollSender<Vec<u8>>,
|
||||
caps: DeviceCapabilities,
|
||||
}
|
||||
|
||||
pub type ChannelDeviceNewRet = (
|
||||
ChannelDevice,
|
||||
Sender<io::Result<BytesMut>>,
|
||||
Receiver<BytesMut>,
|
||||
Sender<io::Result<Vec<u8>>>,
|
||||
Receiver<Vec<u8>>,
|
||||
);
|
||||
|
||||
impl ChannelDevice {
|
||||
@@ -44,25 +43,25 @@ impl ChannelDevice {
|
||||
}
|
||||
|
||||
impl Stream for ChannelDevice {
|
||||
type Item = io::Result<BytesMut>;
|
||||
type Item = io::Result<Vec<u8>>;
|
||||
|
||||
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<BytesMut>) -> io::Error {
|
||||
fn map_err(e: PollSendError<Vec<u8>>) -> io::Error {
|
||||
io::Error::other(e)
|
||||
}
|
||||
|
||||
impl Sink<BytesMut> for ChannelDevice {
|
||||
impl Sink<Vec<u8>> 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: BytesMut) -> Result<(), Self::Error> {
|
||||
fn start_send(mut self: Pin<&mut Self>, item: Vec<u8>) -> Result<(), Self::Error> {
|
||||
self.send.send_item(item).map_err(map_err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use bytes::BytesMut;
|
||||
use futures::{Sink, Stream};
|
||||
pub use smoltcp::phy::DeviceCapabilities;
|
||||
use smoltcp::{
|
||||
@@ -11,7 +10,7 @@ use std::{collections::VecDeque, io};
|
||||
pub const DEFAULT_MAX_BURST_SIZE: usize = 100;
|
||||
|
||||
/// A packet used in `AsyncDevice`.
|
||||
pub type Packet = BytesMut;
|
||||
pub type Packet = Vec<u8>;
|
||||
|
||||
/// A device that send and receive packets asynchronously.
|
||||
pub trait AsyncDevice:
|
||||
@@ -34,7 +33,6 @@ where
|
||||
pub struct BufferDevice {
|
||||
caps: DeviceCapabilities,
|
||||
max_burst_size: usize,
|
||||
tx_headroom: usize,
|
||||
recv_queue: VecDeque<Packet>,
|
||||
send_queue: VecDeque<Packet>,
|
||||
}
|
||||
@@ -43,11 +41,13 @@ pub struct BufferDevice {
|
||||
pub struct BufferRxToken(Packet);
|
||||
|
||||
impl RxToken for BufferRxToken {
|
||||
fn consume<R, F>(self, f: F) -> R
|
||||
fn consume<R, F>(mut self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&[u8]) -> R,
|
||||
{
|
||||
f(&self.0[..])
|
||||
let p = &mut self.0;
|
||||
|
||||
f(p)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,8 @@ impl<'d> TxToken for BufferTxToken<'d> {
|
||||
where
|
||||
F: FnOnce(&mut [u8]) -> R,
|
||||
{
|
||||
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..]);
|
||||
let mut buffer = vec![0u8; len];
|
||||
let result = f(&mut buffer);
|
||||
|
||||
self.0.send_queue.push_back(buffer);
|
||||
|
||||
@@ -100,12 +98,11 @@ impl Device for BufferDevice {
|
||||
}
|
||||
|
||||
impl BufferDevice {
|
||||
pub(crate) fn new(caps: DeviceCapabilities, tx_headroom: usize) -> BufferDevice {
|
||||
pub(crate) fn new(caps: DeviceCapabilities) -> 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),
|
||||
}
|
||||
@@ -126,27 +123,3 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ pub struct NetConfig {
|
||||
pub ip_addr: IpCidr,
|
||||
pub gateway: Vec<IpAddress>,
|
||||
pub buffer_size: BufferSize,
|
||||
pub(crate) packet_tx_headroom: usize,
|
||||
}
|
||||
|
||||
impl NetConfig {
|
||||
@@ -66,14 +65,8 @@ 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.
|
||||
@@ -104,8 +97,7 @@ impl Net {
|
||||
}
|
||||
|
||||
fn new2<D: device::AsyncDevice + 'static>(device: D, config: NetConfig) -> Net {
|
||||
let mut buffer_device =
|
||||
BufferDevice::new(device.capabilities().clone(), config.packet_tx_headroom);
|
||||
let mut buffer_device = BufferDevice::new(device.capabilities().clone());
|
||||
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| {
|
||||
|
||||
@@ -8,6 +8,7 @@ use bytes::{BufMut, BytesMut};
|
||||
use cidr::Ipv4Inet;
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use dashmap::DashMap;
|
||||
use hotpath::instant::Instant;
|
||||
use pnet::packet::{
|
||||
Packet,
|
||||
ip::IpNextHeaderProtocols,
|
||||
@@ -60,8 +61,8 @@ struct UdpNatEntry {
|
||||
socket: Option<UdpSocket>,
|
||||
forward_task: Mutex<Option<JoinHandle<()>>>,
|
||||
stopped: AtomicBool,
|
||||
start_time: std::time::Instant,
|
||||
last_active_time: AtomicCell<std::time::Instant>,
|
||||
start_time: Instant,
|
||||
last_active_time: AtomicCell<Instant>,
|
||||
denied: bool,
|
||||
}
|
||||
|
||||
@@ -85,8 +86,8 @@ impl UdpNatEntry {
|
||||
socket,
|
||||
forward_task: Mutex::new(None),
|
||||
stopped: AtomicBool::new(false),
|
||||
start_time: std::time::Instant::now(),
|
||||
last_active_time: AtomicCell::new(std::time::Instant::now()),
|
||||
start_time: Instant::now(),
|
||||
last_active_time: AtomicCell::new(Instant::now()),
|
||||
denied,
|
||||
})
|
||||
}
|
||||
@@ -255,7 +256,7 @@ impl UdpNatEntry {
|
||||
}
|
||||
|
||||
fn mark_active(&self) {
|
||||
self.last_active_time.store(std::time::Instant::now());
|
||||
self.last_active_time.store(Instant::now());
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! 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,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
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use byteorder::WriteBytesExt as _;
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use bytes::{Buf, BufMut, BytesMut};
|
||||
use cidr::{Ipv4Inet, Ipv6Inet};
|
||||
use futures::{SinkExt, Stream, StreamExt, lock::BiLock, ready};
|
||||
use pin_project_lite::pin_project;
|
||||
@@ -180,12 +180,13 @@ impl ZCPacketToBytes for TunZCPacketToBytes {
|
||||
assert!(payload_offset >= 4);
|
||||
|
||||
let ret = if self.has_packet_info {
|
||||
let mut inner = inner.split_off(payload_offset - 4);
|
||||
inner.advance(payload_offset - 4);
|
||||
let proto = infer_proto(&inner[4..]);
|
||||
self.fill_packet_info(&mut inner[0..4], proto)?;
|
||||
inner
|
||||
} else {
|
||||
inner.split_off(payload_offset)
|
||||
inner.advance(payload_offset);
|
||||
inner
|
||||
};
|
||||
|
||||
tracing::debug!(?ret, ?payload_offset, "convert zc packet to tun packet");
|
||||
|
||||
@@ -1210,6 +1210,32 @@ 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();
|
||||
|
||||
@@ -5,6 +5,30 @@ 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;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
@@ -8,6 +7,7 @@ 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,9 +402,10 @@ 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},
|
||||
|
||||
@@ -59,15 +59,21 @@ 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) {
|
||||
tokio::sync::mpsc::channel(128)
|
||||
hotpath::channel!(tokio::sync::mpsc::channel(128))
|
||||
}
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PacketRecvChan"))]
|
||||
pub async fn recv_packet_from_chan(
|
||||
packet_recv_chan_receiver: &mut PacketRecvChanReceiver,
|
||||
) -> Result<ZCPacket, anyhow::Error> {
|
||||
packet_recv_chan_receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed"))
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
match packet_recv_chan_receiver.try_recv() {
|
||||
Ok(pkt) => Ok(pkt),
|
||||
Err(TryRecvError::Empty) => packet_recv_chan_receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(anyhow::anyhow!("recv_packet_from_chan failed")),
|
||||
Err(TryRecvError::Disconnected) => Err(anyhow::anyhow!("recv_packet_from_chan failed")),
|
||||
}
|
||||
}
|
||||
|
||||
pub const PUBLIC_SERVER_HOSTNAME_PREFIX: &str = "PublicServer_";
|
||||
|
||||
@@ -207,6 +207,7 @@ 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));
|
||||
@@ -267,6 +268,12 @@ impl Peer {
|
||||
self.default_conn_id.load()
|
||||
}
|
||||
|
||||
pub fn set_batch_threshold(&self, n: u32) {
|
||||
for conn in self.conns.iter() {
|
||||
conn.value().set_batch_threshold(n);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
|
||||
self.peer_identity_type.load()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@ 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;
|
||||
@@ -17,7 +26,7 @@ use hmac::Mac;
|
||||
use prost::Message;
|
||||
|
||||
use tokio::{
|
||||
sync::{Mutex, broadcast},
|
||||
sync::broadcast,
|
||||
task::JoinSet,
|
||||
time::{Duration, timeout},
|
||||
};
|
||||
@@ -98,7 +107,7 @@ struct PeerSessionTunnelFilter {
|
||||
enabled: bool,
|
||||
my_peer_id: Arc<AtomicCell<PeerId>>,
|
||||
peer_id: Arc<AtomicCell<Option<PeerId>>>,
|
||||
session: Arc<std::sync::Mutex<Option<Arc<PeerSession>>>>,
|
||||
session: Arc<StdMutex<Option<Arc<PeerSession>>>>,
|
||||
}
|
||||
|
||||
impl PeerSessionTunnelFilter {
|
||||
@@ -107,7 +116,7 @@ impl PeerSessionTunnelFilter {
|
||||
enabled,
|
||||
my_peer_id: Arc::new(AtomicCell::new(PeerId::default())),
|
||||
peer_id: Arc::new(AtomicCell::new(None)),
|
||||
session: Arc::new(std::sync::Mutex::new(None)),
|
||||
session: Arc::new(hotpath::mutex!(std::sync::Mutex::new(None))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +125,7 @@ impl PeerSessionTunnelFilter {
|
||||
enabled,
|
||||
my_peer_id: Arc::new(AtomicCell::new(my_peer_id)),
|
||||
peer_id: Arc::new(AtomicCell::new(None)),
|
||||
session: Arc::new(std::sync::Mutex::new(None)),
|
||||
session: Arc::new(hotpath::mutex!(std::sync::Mutex::new(None))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +370,15 @@ impl PeerConn {
|
||||
let throughput = peer_conn_tunnel_filter.filter_output();
|
||||
let filter_chain = TunnelFilterChain::new(session_filter.clone(), peer_conn_tunnel_filter);
|
||||
let peer_conn_tunnel = TunnelWithFilter::new(tunnel, filter_chain);
|
||||
let mut mpsc_tunnel = MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)));
|
||||
let supports_direct = peer_conn_tunnel
|
||||
.info()
|
||||
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp" | "tcp"))
|
||||
.unwrap_or(false);
|
||||
let mut mpsc_tunnel = if supports_direct {
|
||||
MpscTunnel::new_direct(peer_conn_tunnel)
|
||||
} else {
|
||||
MpscTunnel::new(peer_conn_tunnel, Some(Duration::from_secs(7)))
|
||||
};
|
||||
|
||||
let (recv, sink) = (mpsc_tunnel.get_stream(), mpsc_tunnel.get_sink());
|
||||
|
||||
@@ -379,11 +396,12 @@ impl PeerConn {
|
||||
session_filter,
|
||||
noise_handshake_result: None,
|
||||
|
||||
tunnel: Arc::new(Mutex::new(Box::new(
|
||||
tunnel: Arc::new(hotpath::mutex!(tokio::sync::Mutex::new(Box::new(
|
||||
guard!([mut mpsc_tunnel] mpsc_tunnel.close()),
|
||||
))),
|
||||
)
|
||||
as Box<dyn Any + Send + 'static>))),
|
||||
sink,
|
||||
recv: Mutex::new(Some(recv)),
|
||||
recv: hotpath::mutex!(tokio::sync::Mutex::new(Some(recv))),
|
||||
tunnel_info,
|
||||
|
||||
tasks: JoinSet::new(),
|
||||
@@ -433,6 +451,10 @@ impl PeerConn {
|
||||
self.conn_id
|
||||
}
|
||||
|
||||
pub fn set_batch_threshold(&self, n: u32) {
|
||||
self.sink.set_batch_threshold(n);
|
||||
}
|
||||
|
||||
pub fn set_is_hole_punched(&mut self, is_hole_punched: bool) {
|
||||
self.is_hole_punched = is_hole_punched;
|
||||
}
|
||||
@@ -1460,6 +1482,7 @@ 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?)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hotpath::instant::Instant;
|
||||
use rand::{Rng, thread_rng};
|
||||
use tokio::{
|
||||
sync::broadcast,
|
||||
@@ -177,7 +178,7 @@ impl PeerConnPinger {
|
||||
sink.send(req).await?;
|
||||
control_metrics.record_tx(req_len);
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let now = Instant::now();
|
||||
// wait until we get a pong packet in ctrl_resp_receiver
|
||||
let resp = timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
|
||||
@@ -2,19 +2,21 @@ 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, Instant, SystemTime},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
#[cfg(feature = "hotpath")]
|
||||
use hotpath::wrap::tokio::sync::{Mutex, RwLock};
|
||||
#[cfg(not(feature = "hotpath"))]
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::{
|
||||
sync::{
|
||||
Mutex, RwLock,
|
||||
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
},
|
||||
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
task::JoinSet,
|
||||
};
|
||||
|
||||
@@ -277,8 +279,8 @@ impl PeerManager {
|
||||
let rpc_tspt = Arc::new(RpcTransport {
|
||||
my_peer_id,
|
||||
peers: Arc::downgrade(&peers),
|
||||
foreign_peers: Mutex::new(None),
|
||||
packet_recv: Mutex::new(peer_rpc_tspt_recv),
|
||||
foreign_peers: hotpath::mutex!(tokio::sync::Mutex::new(None)),
|
||||
packet_recv: hotpath::mutex!(tokio::sync::Mutex::new(peer_rpc_tspt_recv)),
|
||||
peer_rpc_tspt_sender,
|
||||
encryptor: encryptor.clone(),
|
||||
is_secure_mode_enabled,
|
||||
@@ -410,17 +412,21 @@ impl PeerManager {
|
||||
global_ctx,
|
||||
nic_channel,
|
||||
|
||||
tasks: Mutex::new(JoinSet::new()),
|
||||
tasks: hotpath::mutex!(tokio::sync::Mutex::new(JoinSet::new())),
|
||||
|
||||
packet_recv: Arc::new(Mutex::new(Some(packet_recv))),
|
||||
packet_recv: Arc::new(hotpath::mutex!(tokio::sync::Mutex::new(Some(packet_recv)))),
|
||||
|
||||
peers,
|
||||
|
||||
peer_rpc_mgr,
|
||||
peer_rpc_tspt: rpc_tspt,
|
||||
|
||||
peer_packet_process_pipeline: Arc::new(RwLock::new(Vec::new())),
|
||||
nic_packet_process_pipeline: Arc::new(RwLock::new(Vec::new())),
|
||||
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()
|
||||
))),
|
||||
|
||||
route_algo_inst,
|
||||
|
||||
@@ -431,7 +437,7 @@ impl PeerManager {
|
||||
encryptor,
|
||||
data_compress_algo,
|
||||
|
||||
exit_nodes: RwLock::new(exit_nodes),
|
||||
exit_nodes: hotpath::rw_lock!(tokio::sync::RwLock::new(exit_nodes)),
|
||||
|
||||
reserved_my_peer_id_map: DashMap::new(),
|
||||
recent_have_traffic: Arc::new(DashMap::new()),
|
||||
@@ -1009,7 +1015,17 @@ impl PeerManager {
|
||||
|
||||
self.tasks.lock().await.spawn(async move {
|
||||
tracing::trace!("start_peer_recv");
|
||||
while let Ok(ret) = recv_packet_from_chan(&mut recv).await {
|
||||
loop {
|
||||
let ret = match recv.try_recv() {
|
||||
Ok(pkt) => pkt,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
match recv.recv().await {
|
||||
Some(pkt) => pkt,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||
};
|
||||
let disable_relay_data = global_ctx.flags_arc().disable_relay_data;
|
||||
let Err(mut ret) = Self::try_handle_foreign_network_packet(
|
||||
ret,
|
||||
@@ -1147,9 +1163,11 @@ impl PeerManager {
|
||||
|
||||
self_rx_bytes.add(buf_len as u64);
|
||||
self_rx_packets.inc();
|
||||
traffic_metrics
|
||||
.record_rx(from_peer_id, packet_type, buf_len as u64)
|
||||
.await;
|
||||
if !traffic_metrics.record_rx_fast(from_peer_id, packet_type, buf_len as u64) {
|
||||
traffic_metrics
|
||||
.record_rx(from_peer_id, packet_type, buf_len as u64)
|
||||
.await;
|
||||
}
|
||||
compress_rx_bytes_before.add(buf_len as u64);
|
||||
|
||||
let compressor = DefaultCompressor {};
|
||||
@@ -1438,6 +1456,7 @@ 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(
|
||||
@@ -1523,6 +1542,7 @@ 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>,
|
||||
@@ -1548,8 +1568,8 @@ impl PeerManager {
|
||||
&& (peers.has_peer(gateway) || foreign_network_client.has_next_hop(gateway))
|
||||
{
|
||||
relay_peer_map.send_msg(msg, dst_peer_id, policy).await
|
||||
} else if peers.has_peer(dst_peer_id) {
|
||||
peers.send_msg_directly(msg, dst_peer_id).await
|
||||
} else if let Some(peer) = peers.get_peer_by_id(dst_peer_id) {
|
||||
peer.send_msg(msg).await
|
||||
} else if foreign_network_client.has_next_hop(dst_peer_id) {
|
||||
foreign_network_client.send_msg(msg, dst_peer_id).await
|
||||
} else if let Some(gateway) = peers.get_gateway_peer_id(dst_peer_id, policy.clone()).await {
|
||||
@@ -1574,7 +1594,9 @@ impl PeerManager {
|
||||
if send_result.is_ok()
|
||||
&& let Some(metrics) = direct_tx_metrics
|
||||
{
|
||||
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
|
||||
if !metrics.record_tx_fast(dst_peer_id, packet_type, msg_len) {
|
||||
metrics.record_tx(dst_peer_id, packet_type, msg_len).await;
|
||||
}
|
||||
}
|
||||
|
||||
send_result
|
||||
@@ -1621,6 +1643,7 @@ impl PeerManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
|
||||
pub async fn get_msg_dst_peer_ipv4(&self, ipv4_addr: &Ipv4Addr) -> (Vec<PeerId>, bool) {
|
||||
let mut is_exit_node = false;
|
||||
let mut dst_peers = vec![];
|
||||
@@ -1689,6 +1712,7 @@ impl PeerManager {
|
||||
(dst_peers, is_exit_node)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
|
||||
pub async fn try_compress_and_encrypt(
|
||||
compress_algo: CompressorAlgo,
|
||||
encryptor: &Arc<dyn Encryptor + 'static>,
|
||||
@@ -1706,6 +1730,7 @@ impl PeerManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerManager"))]
|
||||
pub async fn send_msg_by_ip(
|
||||
&self,
|
||||
mut msg: ZCPacket,
|
||||
@@ -1969,6 +1994,18 @@ impl PeerManager {
|
||||
self.peers.clone()
|
||||
}
|
||||
|
||||
pub fn set_peer_conn_batch_threshold(&self, n: u32) {
|
||||
let peers = self.peers.clone();
|
||||
tokio::spawn(async move {
|
||||
let peer_ids = peers.list_peers();
|
||||
for peer_id in peer_ids {
|
||||
if let Some(peer) = peers.get_peer_by_id(peer_id) {
|
||||
peer.set_batch_threshold(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_relay_peer_map(&self) -> Arc<RelayPeerMap> {
|
||||
self.relay_peer_map.clone()
|
||||
}
|
||||
@@ -2197,12 +2234,9 @@ impl PeerManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Debug,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
|
||||
|
||||
use hotpath::instant::Instant;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct PeerMap {
|
||||
alive_client_urls: Arc<Mutex<multimap::MultiMap<url::Url, PeerConnId>>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
|
||||
impl PeerMap {
|
||||
pub fn new(packet_send: PacketRecvChan, global_ctx: ArcGlobalCtx, my_peer_id: PeerId) -> Self {
|
||||
PeerMap {
|
||||
|
||||
@@ -6,13 +6,14 @@ use std::{
|
||||
Arc, Weak,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
time::{Duration, 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::{
|
||||
@@ -1393,6 +1394,7 @@ 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;
|
||||
@@ -1400,6 +1402,7 @@ 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| {
|
||||
@@ -2168,9 +2171,9 @@ struct PeerRouteServiceImpl {
|
||||
interface_peers_generation: AtomicU64,
|
||||
applied_interface_peers_generation: AtomicU64,
|
||||
|
||||
last_update_my_foreign_network: AtomicCell<Option<std::time::Instant>>,
|
||||
last_update_my_foreign_network: AtomicCell<Option<Instant>>,
|
||||
|
||||
peer_info_last_update: AtomicCell<std::time::Instant>,
|
||||
peer_info_last_update: AtomicCell<Instant>,
|
||||
}
|
||||
|
||||
impl Debug for PeerRouteServiceImpl {
|
||||
@@ -2237,7 +2240,7 @@ impl PeerRouteServiceImpl {
|
||||
|
||||
last_update_my_foreign_network: AtomicCell::new(None),
|
||||
|
||||
peer_info_last_update: AtomicCell::new(std::time::Instant::now()),
|
||||
peer_info_last_update: AtomicCell::new(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2433,7 +2436,7 @@ impl PeerRouteServiceImpl {
|
||||
}
|
||||
|
||||
self.last_update_my_foreign_network
|
||||
.store(Some(std::time::Instant::now()));
|
||||
.store(Some(Instant::now()));
|
||||
|
||||
let foreign_networks = self
|
||||
.interface
|
||||
@@ -3154,12 +3157,12 @@ impl PeerRouteServiceImpl {
|
||||
"update_peer_info_last_update, my_peer_id: {:?}, prev: {:?}, new: {:?}",
|
||||
self.my_peer_id,
|
||||
self.peer_info_last_update.load(),
|
||||
std::time::Instant::now()
|
||||
Instant::now()
|
||||
);
|
||||
self.peer_info_last_update.store(std::time::Instant::now());
|
||||
self.peer_info_last_update.store(Instant::now());
|
||||
}
|
||||
|
||||
fn get_peer_info_last_update(&self) -> std::time::Instant {
|
||||
fn get_peer_info_last_update(&self) -> Instant {
|
||||
self.peer_info_last_update.load()
|
||||
}
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ impl PeerSession {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "PeerSession"))]
|
||||
pub fn encrypt_payload(
|
||||
&self,
|
||||
sender_peer_id: PeerId,
|
||||
@@ -350,6 +351,7 @@ 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,6 +1,7 @@
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use hotpath::instant::Instant;
|
||||
use prost::Message;
|
||||
use snow::params::NoiseParams;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, oneshot};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use cidr::Ipv6Inet;
|
||||
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||
use dashmap::DashMap;
|
||||
use hotpath::instant::Instant;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
net::{Ipv4Addr, Ipv6Addr},
|
||||
@@ -157,7 +158,7 @@ pub trait Route {
|
||||
|
||||
async fn get_peer_info(&self, peer_id: PeerId) -> Option<RoutePeerInfo>;
|
||||
|
||||
async fn get_peer_info_last_update_time(&self) -> std::time::Instant;
|
||||
async fn get_peer_info_last_update_time(&self) -> Instant;
|
||||
|
||||
fn get_peer_groups(&self, peer_id: PeerId) -> Arc<Vec<String>>;
|
||||
|
||||
@@ -226,7 +227,7 @@ impl Route for MockRoute {
|
||||
panic!("mock route")
|
||||
}
|
||||
|
||||
async fn get_peer_info_last_update_time(&self) -> std::time::Instant {
|
||||
async fn get_peer_info_last_update_time(&self) -> Instant {
|
||||
panic!("mock route")
|
||||
}
|
||||
|
||||
|
||||
@@ -701,6 +701,10 @@ impl SecureDatagramSession {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
feature = "hotpath",
|
||||
hotpath::measure(impl_type = "SecureDatagramSession")
|
||||
)]
|
||||
pub fn encrypt_payload(
|
||||
&self,
|
||||
dir: SecureDatagramDirection,
|
||||
@@ -719,6 +723,10 @@ impl SecureDatagramSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
feature = "hotpath",
|
||||
hotpath::measure(impl_type = "SecureDatagramSession")
|
||||
)]
|
||||
pub fn decrypt_payload(
|
||||
&self,
|
||||
dir: SecureDatagramDirection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{future::Future, sync::Arc};
|
||||
use std::{future::Future, sync::atomic::{AtomicU64, Ordering}, sync::Arc};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -18,16 +18,54 @@ pub(crate) enum InstanceLabelKind {
|
||||
From,
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
const TRAFFIC_BATCH_SIZE: u64 = 128;
|
||||
#[cfg(test)]
|
||||
const TRAFFIC_BATCH_SIZE: u64 = 1;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrafficCounters {
|
||||
bytes: CounterHandle,
|
||||
packets: CounterHandle,
|
||||
batch: Arc<TrafficBatch>,
|
||||
}
|
||||
|
||||
struct TrafficBatch {
|
||||
bytes: AtomicU64,
|
||||
packets: AtomicU64,
|
||||
}
|
||||
|
||||
impl TrafficCounters {
|
||||
fn new(bytes: CounterHandle, packets: CounterHandle) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
packets,
|
||||
batch: Arc::new(TrafficBatch {
|
||||
bytes: AtomicU64::new(0),
|
||||
packets: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_sample(&self, bytes: u64) {
|
||||
self.bytes.add(bytes);
|
||||
self.packets.inc();
|
||||
let prev = self.batch.packets.fetch_add(1, Ordering::Relaxed);
|
||||
self.batch.bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
if (prev + 1) % TRAFFIC_BATCH_SIZE == 0 {
|
||||
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
|
||||
self.bytes.add(b);
|
||||
self.packets.add(TRAFFIC_BATCH_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let b = self.batch.bytes.swap(0, Ordering::Relaxed);
|
||||
let p = self.batch.packets.swap(0, Ordering::Relaxed);
|
||||
if b > 0 {
|
||||
self.bytes.add(b);
|
||||
}
|
||||
if p > 0 {
|
||||
self.packets.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +98,14 @@ impl AggregateTrafficMetrics {
|
||||
let label_set =
|
||||
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
|
||||
Self {
|
||||
tx: TrafficCounters {
|
||||
bytes: stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
|
||||
packets: stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
|
||||
},
|
||||
rx: TrafficCounters {
|
||||
bytes: stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
|
||||
packets: stats_mgr.get_counter(rx_packets_metric, label_set),
|
||||
},
|
||||
tx: TrafficCounters::new(
|
||||
stats_mgr.get_counter(tx_bytes_metric, label_set.clone()),
|
||||
stats_mgr.get_counter(tx_packets_metric, label_set.clone()),
|
||||
),
|
||||
rx: TrafficCounters::new(
|
||||
stats_mgr.get_counter(rx_bytes_metric, label_set.clone()),
|
||||
stats_mgr.get_counter(rx_packets_metric, label_set),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +160,10 @@ impl LogicalTrafficMetrics {
|
||||
let label_set =
|
||||
LabelSet::new().with_label_type(LabelType::NetworkName(network_name.clone()));
|
||||
Self {
|
||||
total: TrafficCounters {
|
||||
bytes: stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
|
||||
packets: stats_mgr.get_counter(total_packets_metric, label_set),
|
||||
},
|
||||
total: TrafficCounters::new(
|
||||
stats_mgr.get_counter(total_bytes_metric, label_set.clone()),
|
||||
stats_mgr.get_counter(total_packets_metric, label_set),
|
||||
),
|
||||
stats_mgr,
|
||||
network_name,
|
||||
instance_bytes_metric,
|
||||
@@ -135,6 +173,22 @@ impl LogicalTrafficMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_fast(&self, peer_id: PeerId, bytes: u64) -> bool {
|
||||
self.total.add_sample(bytes);
|
||||
if let Some(entry) = self.per_peer.get(&peer_id)
|
||||
&& entry.value().is_resolved()
|
||||
{
|
||||
let counters = match entry.value() {
|
||||
CachedPeerTrafficCounters::Resolved(c)
|
||||
| CachedPeerTrafficCounters::Unknown(c) => c,
|
||||
};
|
||||
counters.add_sample(bytes);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_with_resolver<F, Fut>(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
@@ -214,14 +268,12 @@ impl LogicalTrafficMetrics {
|
||||
let label_set = LabelSet::new()
|
||||
.with_label_type(LabelType::NetworkName(self.network_name.clone()))
|
||||
.with_label_type(instance_label);
|
||||
TrafficCounters {
|
||||
bytes: self
|
||||
.stats_mgr
|
||||
TrafficCounters::new(
|
||||
self.stats_mgr
|
||||
.get_counter(self.instance_bytes_metric, label_set.clone()),
|
||||
packets: self
|
||||
.stats_mgr
|
||||
self.stats_mgr
|
||||
.get_counter(self.instance_packets_metric, label_set),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +356,15 @@ impl TrafficMetricRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_tx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
|
||||
if peer_id == self.my_peer_id {
|
||||
return true;
|
||||
}
|
||||
self.tx_metrics
|
||||
.select(traffic_kind(packet_type))
|
||||
.record_fast(peer_id, bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_tx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
|
||||
if peer_id == self.my_peer_id {
|
||||
return;
|
||||
@@ -314,6 +375,15 @@ impl TrafficMetricRecorder {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) fn record_rx_fast(&self, peer_id: PeerId, packet_type: u8, bytes: u64) -> bool {
|
||||
if peer_id == self.my_peer_id {
|
||||
return true;
|
||||
}
|
||||
self.rx_metrics
|
||||
.select(traffic_kind(packet_type))
|
||||
.record_fast(peer_id, bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_rx(&self, peer_id: PeerId, packet_type: u8, bytes: u64) {
|
||||
if peer_id == self.my_peer_id {
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -52,7 +53,7 @@ struct InflightRequestKey {
|
||||
struct InflightRequest {
|
||||
sender: RpcPacketSender,
|
||||
merger: PacketMerger,
|
||||
start_time: std::time::Instant,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for InflightRequest {
|
||||
@@ -65,14 +66,14 @@ impl std::fmt::Debug for InflightRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PeerInfo {
|
||||
pub(crate) struct PeerInfo {
|
||||
pub peer_id: PeerId,
|
||||
pub compression_info: RpcCompressionInfo,
|
||||
pub last_active: Option<std::time::Instant>,
|
||||
pub last_active: Option<Instant>,
|
||||
}
|
||||
|
||||
type InflightRequestTable = Arc<DashMap<InflightRequestKey, InflightRequest>>;
|
||||
pub type PeerInfoTable = Arc<DashMap<PeerId, PeerInfo>>;
|
||||
pub(crate) type PeerInfoTable = Arc<DashMap<PeerId, PeerInfo>>;
|
||||
|
||||
pub struct Client {
|
||||
mpsc: Mutex<MpscTunnel<Box<dyn Tunnel>>>,
|
||||
@@ -123,7 +124,7 @@ impl Client {
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
let now = std::time::Instant::now();
|
||||
let now = Instant::now();
|
||||
peer_infos.retain(|_, v| {
|
||||
if let Some(last_active) = v.last_active {
|
||||
return now.duration_since(last_active)
|
||||
@@ -230,7 +231,7 @@ impl Client {
|
||||
method: <Self::Descriptor as ServiceDescriptor>::Method,
|
||||
input: bytes::Bytes,
|
||||
) -> Result<bytes::Bytes> {
|
||||
let start_time = std::time::Instant::now();
|
||||
let start_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 {
|
||||
@@ -314,7 +315,7 @@ impl Client {
|
||||
PeerInfo {
|
||||
peer_id: self.to_peer_id,
|
||||
compression_info,
|
||||
last_active: Some(std::time::Instant::now()),
|
||||
last_active: Some(Instant::now()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -385,7 +386,7 @@ impl Client {
|
||||
self.inflight_requests.len()
|
||||
}
|
||||
|
||||
pub fn peer_info_table(&self) -> PeerInfoTable {
|
||||
pub(crate) fn peer_info_table(&self) -> PeerInfoTable {
|
||||
self.peer_info.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use prost::{Message as _, length_delimiter_len};
|
||||
|
||||
use hotpath::instant::Instant;
|
||||
|
||||
use crate::{
|
||||
common::{PeerId, compressor::DefaultCompressor},
|
||||
proto::{
|
||||
@@ -42,10 +44,10 @@ pub async fn decompress_packet(
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
pub struct PacketMerger {
|
||||
pub(crate) struct PacketMerger {
|
||||
first_piece: Option<RpcPacket>,
|
||||
pieces: Vec<RpcPacket>,
|
||||
last_updated: std::time::Instant,
|
||||
last_updated: Instant,
|
||||
}
|
||||
|
||||
impl Default for PacketMerger {
|
||||
@@ -59,7 +61,7 @@ impl PacketMerger {
|
||||
Self {
|
||||
first_piece: None,
|
||||
pieces: Vec::new(),
|
||||
last_updated: std::time::Instant::now(),
|
||||
last_updated: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +134,12 @@ impl PacketMerger {
|
||||
.resize(total_pieces as usize, Default::default());
|
||||
self.pieces[piece_idx as usize] = rpc_packet;
|
||||
|
||||
self.last_updated = std::time::Instant::now();
|
||||
self.last_updated = Instant::now();
|
||||
|
||||
Ok(self.try_merge_pieces())
|
||||
}
|
||||
|
||||
pub fn last_updated(&self) -> std::time::Instant {
|
||||
pub(crate) fn last_updated(&self) -> Instant {
|
||||
self.last_updated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -233,7 +234,7 @@ impl Server {
|
||||
}
|
||||
|
||||
let mut resp_msg = RpcResponse::default();
|
||||
let now = std::time::Instant::now();
|
||||
let now = Instant::now();
|
||||
|
||||
let compression_info = packet.compression_info;
|
||||
let resp_bytes = Self::handle_rpc_request(packet, reg, tunnel_info).await;
|
||||
|
||||
@@ -159,7 +159,7 @@ impl Socket {
|
||||
ack: Option<u32>,
|
||||
state: State,
|
||||
) -> (Socket, flume::Sender<Bytes>) {
|
||||
let (incoming_tx, incoming_rx) = flume::bounded(MPMC_BUFFER_LEN);
|
||||
let (incoming_tx, incoming_rx) = hotpath::channel!(flume::bounded(MPMC_BUFFER_LEN));
|
||||
|
||||
(
|
||||
Socket {
|
||||
|
||||
+161
-15
@@ -1,6 +1,13 @@
|
||||
// this mod wrap tunnel to a mpsc tunnel, based on crossbeam_channel
|
||||
|
||||
use std::{pin::Pin, time::Duration};
|
||||
use std::{
|
||||
cell::UnsafeCell,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::time::timeout;
|
||||
@@ -11,39 +18,151 @@ use super::{Tunnel, TunnelError, ZCPacketSink, ZCPacketStream, packet_def::ZCPac
|
||||
|
||||
use tokio::sync::mpsc::{Receiver, Sender, channel, error::TrySendError};
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
// use tachyonix::{channel, Receiver, Sender, TrySendError};
|
||||
|
||||
use futures::SinkExt;
|
||||
|
||||
/// A simple spinlock protecting a sink. The guard is Send because it only
|
||||
/// contains an atomic flag reference (no lifetime-tied borrow like MutexGuard).
|
||||
struct SpinSink {
|
||||
locked: AtomicBool,
|
||||
sink: UnsafeCell<Pin<Box<dyn ZCPacketSink>>>,
|
||||
pending_count: AtomicU32,
|
||||
batch_threshold: AtomicU32,
|
||||
}
|
||||
|
||||
// SAFETY: access is serialized by the spinlock.
|
||||
unsafe impl Send for SpinSink {}
|
||||
unsafe impl Sync for SpinSink {}
|
||||
|
||||
struct SpinGuard<'a> {
|
||||
spin: &'a SpinSink,
|
||||
}
|
||||
|
||||
impl<'a> SpinGuard<'a> {
|
||||
fn as_mut(&mut self) -> Pin<&mut dyn ZCPacketSink> {
|
||||
// SAFETY: we hold the spinlock, so we have exclusive access
|
||||
let sink = unsafe { &mut *self.spin.sink.get() };
|
||||
sink.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpinGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.spin.locked.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl SpinSink {
|
||||
fn new(sink: Pin<Box<dyn ZCPacketSink>>) -> Self {
|
||||
Self {
|
||||
locked: AtomicBool::new(false),
|
||||
sink: UnsafeCell::new(sink),
|
||||
pending_count: AtomicU32::new(0),
|
||||
batch_threshold: AtomicU32::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_batch_threshold(&self, n: u32) {
|
||||
self.batch_threshold.store(n, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn try_lock(&self) -> Option<SpinGuard<'_>> {
|
||||
if self
|
||||
.locked
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
Some(SpinGuard { spin: self })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MpscTunnelSender(Sender<ZCPacket>);
|
||||
pub struct MpscTunnelSender {
|
||||
channel_tx: Option<Sender<ZCPacket>>,
|
||||
direct_sink: Option<Arc<SpinSink>>,
|
||||
direct_batch_flush: bool,
|
||||
}
|
||||
|
||||
impl MpscTunnelSender {
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnelSender"))]
|
||||
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
|
||||
self.0.send(item).await.with_context(|| "send error")?;
|
||||
Ok(())
|
||||
if let Some(sink) = &self.direct_sink {
|
||||
// Sync fast path: no await needed, returns immediately
|
||||
if let Some(mut guard) = sink.try_lock() {
|
||||
let waker = futures::task::noop_waker();
|
||||
let mut cx = std::task::Context::from_waker(&waker);
|
||||
match guard.as_mut().poll_ready(&mut cx) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
guard.as_mut().start_send(item)?;
|
||||
let count = sink.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let threshold = sink.batch_threshold.load(Ordering::Relaxed);
|
||||
if count >= threshold {
|
||||
sink.pending_count.store(0, Ordering::Relaxed);
|
||||
// Batch flush: writev all accumulated BufList entries.
|
||||
// RingSink: no-op. FramedWriter: single writev syscall.
|
||||
match guard.as_mut().poll_flush(&mut cx) {
|
||||
Poll::Ready(Err(e)) => return Err(e),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
// Accumulate in BufList, no flush yet
|
||||
return Ok(());
|
||||
}
|
||||
Poll::Ready(Err(e)) => return Err(e),
|
||||
Poll::Pending => return Err(TunnelError::BufferFull),
|
||||
}
|
||||
}
|
||||
return Err(TunnelError::BufferFull);
|
||||
}
|
||||
|
||||
// Channel mode: async with backpressure
|
||||
self.send_async(item).await
|
||||
}
|
||||
|
||||
pub fn try_send(&self, item: ZCPacket) -> Result<(), TunnelError> {
|
||||
self.0.try_send(item).map_err(|e| match e {
|
||||
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
|
||||
tx.try_send(item).map_err(|e| match e {
|
||||
TrySendError::Full(_) => TunnelError::BufferFull,
|
||||
TrySendError::Closed(_) => TunnelError::Shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_batch_threshold(&self, n: u32) {
|
||||
if let Some(sink) = &self.direct_sink {
|
||||
sink.set_batch_threshold(n);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_async(&self, item: ZCPacket) -> Result<(), TunnelError> {
|
||||
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
|
||||
match tx.try_send(item) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(TrySendError::Full(item)) => {
|
||||
tx.send(item).await.with_context(|| "send error")?;
|
||||
Ok(())
|
||||
}
|
||||
Err(TrySendError::Closed(_)) => Err(TunnelError::Shutdown),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MpscTunnel<T> {
|
||||
tx: Option<Sender<ZCPacket>>,
|
||||
direct_sink: Option<Arc<SpinSink>>,
|
||||
direct_batch_flush: bool,
|
||||
|
||||
tunnel: T,
|
||||
stream: Option<Pin<Box<dyn ZCPacketStream>>>,
|
||||
|
||||
task: AbortOnDropHandle<()>,
|
||||
task: Option<AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
impl<T: Tunnel> MpscTunnel<T> {
|
||||
pub fn new(tunnel: T, send_timeout: Option<Duration>) -> Self {
|
||||
let (tx, mut rx) = channel(32);
|
||||
let (tx, mut rx) = hotpath::channel!(channel(1024));
|
||||
let (stream, mut sink) = tunnel.split();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
@@ -60,12 +179,32 @@ impl<T: Tunnel> MpscTunnel<T> {
|
||||
|
||||
Self {
|
||||
tx: Some(tx),
|
||||
direct_sink: None,
|
||||
direct_batch_flush: false,
|
||||
tunnel,
|
||||
stream: Some(stream),
|
||||
task: AbortOnDropHandle::new(task),
|
||||
task: Some(AbortOnDropHandle::new(task)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_direct(tunnel: T) -> Self {
|
||||
let (stream, sink) = tunnel.split();
|
||||
let info = tunnel.info();
|
||||
let batch_flush = info
|
||||
.as_ref()
|
||||
.map(|i| matches!(i.tunnel_type.as_str(), "ring" | "udp"))
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
tx: None,
|
||||
direct_sink: Some(Arc::new(SpinSink::new(sink))),
|
||||
direct_batch_flush: batch_flush,
|
||||
tunnel,
|
||||
stream: Some(stream),
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "MpscTunnel"))]
|
||||
async fn forward_one_round(
|
||||
rx: &mut Receiver<ZCPacket>,
|
||||
sink: &mut Pin<Box<dyn ZCPacketSink>>,
|
||||
@@ -79,6 +218,7 @@ 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>>,
|
||||
@@ -96,6 +236,7 @@ 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>>,
|
||||
@@ -124,12 +265,19 @@ impl<T: Tunnel> MpscTunnel<T> {
|
||||
}
|
||||
|
||||
pub fn get_sink(&self) -> MpscTunnelSender {
|
||||
MpscTunnelSender(self.tx.as_ref().unwrap().clone())
|
||||
MpscTunnelSender {
|
||||
channel_tx: self.tx.as_ref().cloned(),
|
||||
direct_sink: self.direct_sink.clone(),
|
||||
direct_batch_flush: self.direct_batch_flush,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.tx.take();
|
||||
self.task.abort();
|
||||
self.direct_sink.take();
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tunnel_info(&self) -> Option<TunnelInfo> {
|
||||
@@ -189,8 +337,7 @@ mod tests {
|
||||
for i in 0..1000000 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
let a = sink1
|
||||
.send(ZCPacket::new_with_payload("hello".as_bytes()))
|
||||
.await;
|
||||
.send_async(ZCPacket::new_with_payload("hello".as_bytes())).await;
|
||||
if a.is_err() {
|
||||
tracing::info!(?a, "t2 exit with err");
|
||||
break;
|
||||
@@ -209,8 +356,7 @@ mod tests {
|
||||
for i in 0..1000000 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
let a = sink2
|
||||
.send(ZCPacket::new_with_payload("hello2".as_bytes()))
|
||||
.await;
|
||||
.send_async(ZCPacket::new_with_payload("hello2".as_bytes())).await;
|
||||
if a.is_err() {
|
||||
tracing::info!(?a, "t3 exit with err");
|
||||
break;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use bytes::Buf;
|
||||
use bytes::Bytes;
|
||||
use bytes::BytesMut;
|
||||
use zerocopy::byteorder::*;
|
||||
use zerocopy::AsBytes;
|
||||
use zerocopy::FromBytes;
|
||||
use zerocopy::FromZeroes;
|
||||
use zerocopy::byteorder::*;
|
||||
|
||||
type DefaultEndian = LittleEndian;
|
||||
|
||||
@@ -483,8 +484,16 @@ impl ZCPacket {
|
||||
let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
|
||||
let total_len = payload_off + payload.len();
|
||||
ret.inner.reserve(total_len);
|
||||
unsafe { ret.inner.set_len(total_len) };
|
||||
ret.mut_payload()[..payload.len()].copy_from_slice(payload);
|
||||
|
||||
// SAFETY: `reserve` guarantees capacity >= total_len.
|
||||
// We zero the header region and copy payload before advancing length,
|
||||
// so every byte in [0..total_len) is initialized before any read.
|
||||
unsafe {
|
||||
let ptr = ret.inner.as_mut_ptr();
|
||||
std::ptr::write_bytes(ptr, 0, payload_off);
|
||||
std::ptr::copy_nonoverlapping(payload.as_ptr(), ptr.add(payload_off), payload.len());
|
||||
ret.inner.set_len(total_len);
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
@@ -492,12 +501,12 @@ impl ZCPacket {
|
||||
let mut ret = Self::new_nic_packet();
|
||||
ret.inner.reserve(cap);
|
||||
let total_len = ret.packet_type.get_packet_offsets().payload_offset - packet_info_len;
|
||||
unsafe { ret.inner.set_len(total_len) };
|
||||
ret.inner.resize(total_len, 0);
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn new_for_foreign_network(
|
||||
network_name: &String,
|
||||
network_name: &str,
|
||||
dst_peer_id: u32,
|
||||
foreign_zc_packet: &ZCPacket,
|
||||
) -> Self {
|
||||
@@ -506,26 +515,71 @@ impl ZCPacket {
|
||||
foreign_network_hdr.get_header_len() + foreign_zc_packet.tunnel_payload().len();
|
||||
|
||||
let mut ret = Self::new_nic_packet();
|
||||
let payload_off = ret.packet_type.get_packet_offsets().payload_offset;
|
||||
ret.inner.reserve(payload_off + total_payload_len);
|
||||
unsafe { ret.inner.set_len(payload_off + total_payload_len) };
|
||||
let offsets = ret.packet_type.get_packet_offsets();
|
||||
let payload_off = offsets.payload_offset;
|
||||
let pm_hdr_off = offsets.peer_manager_header_offset;
|
||||
let total_len = payload_off + total_payload_len;
|
||||
ret.inner.reserve(total_len);
|
||||
|
||||
let fixed_hdr_len = std::mem::size_of::<ForeignNetworkPacketHeader>();
|
||||
ret.mut_payload()[..fixed_hdr_len].copy_from_slice(foreign_network_hdr.as_bytes());
|
||||
|
||||
let name_offset = foreign_network_hdr.network_name_offset.get() as usize;
|
||||
let name_len = foreign_network_hdr.network_name_len.get() as usize;
|
||||
ret.mut_payload()[name_offset..name_offset + name_len]
|
||||
.copy_from_slice(network_name.as_bytes());
|
||||
let foreign_payload = foreign_zc_packet.tunnel_payload();
|
||||
|
||||
ret.mut_payload()[foreign_network_hdr.get_header_len()..]
|
||||
.copy_from_slice(foreign_zc_packet.tunnel_payload());
|
||||
// Construct the PeerManagerHeader on the stack so we can write it
|
||||
// directly into the buffer, avoiding a separate mut_peer_manager_header()
|
||||
// call after set_len.
|
||||
let pm_hdr = PeerManagerHeader {
|
||||
from_peer_id: 0.into(),
|
||||
to_peer_id: 0.into(),
|
||||
packet_type: PacketType::ForeignNetworkPacket as u8,
|
||||
flags: 0,
|
||||
forward_counter: 0,
|
||||
reserved: 0,
|
||||
len: U32::new(total_payload_len as u32),
|
||||
};
|
||||
|
||||
let hdr = ret.mut_peer_manager_header().unwrap();
|
||||
hdr.from_peer_id = 0.into();
|
||||
hdr.to_peer_id = 0.into();
|
||||
hdr.packet_type = PacketType::ForeignNetworkPacket as u8;
|
||||
hdr.len.set(total_payload_len as u32);
|
||||
// SAFETY: `reserve` guarantees capacity >= total_len.
|
||||
// We zero only the tunnel-header reserved space [0..pm_hdr_off], write
|
||||
// the PeerManagerHeader directly at pm_hdr_off, then copy the foreign
|
||||
// network header, network name, and payload. Every byte in [0..total_len)
|
||||
// is initialized before set_len.
|
||||
unsafe {
|
||||
let ptr = ret.inner.as_mut_ptr();
|
||||
|
||||
// Zero the tunnel header reserved space only (not the PM header region)
|
||||
std::ptr::write_bytes(ptr, 0, pm_hdr_off);
|
||||
|
||||
// Write PeerManagerHeader directly
|
||||
std::ptr::copy_nonoverlapping(
|
||||
pm_hdr.as_bytes().as_ptr(),
|
||||
ptr.add(pm_hdr_off),
|
||||
std::mem::size_of::<PeerManagerHeader>(),
|
||||
);
|
||||
|
||||
// Copy foreign network fixed header
|
||||
std::ptr::copy_nonoverlapping(
|
||||
foreign_network_hdr.as_bytes().as_ptr(),
|
||||
ptr.add(payload_off),
|
||||
fixed_hdr_len,
|
||||
);
|
||||
|
||||
// Copy network name
|
||||
std::ptr::copy_nonoverlapping(
|
||||
network_name.as_ptr(),
|
||||
ptr.add(payload_off + name_offset),
|
||||
name_len,
|
||||
);
|
||||
|
||||
// Copy foreign payload
|
||||
std::ptr::copy_nonoverlapping(
|
||||
foreign_payload.as_ptr(),
|
||||
ptr.add(payload_off + foreign_network_hdr.get_header_len()),
|
||||
foreign_payload.len(),
|
||||
);
|
||||
|
||||
ret.inner.set_len(total_len);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
@@ -585,7 +639,8 @@ impl ZCPacket {
|
||||
}
|
||||
|
||||
pub fn payload_bytes(mut self) -> BytesMut {
|
||||
self.inner.split_off(self.payload_offset())
|
||||
self.inner.advance(self.payload_offset());
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn peer_manager_header(&self) -> Option<&PeerManagerHeader> {
|
||||
@@ -650,11 +705,12 @@ impl ZCPacket {
|
||||
}
|
||||
|
||||
pub fn tunnel_payload_bytes(mut self) -> BytesMut {
|
||||
self.inner.split_off(
|
||||
self.inner.advance(
|
||||
self.packet_type
|
||||
.get_packet_offsets()
|
||||
.peer_manager_header_offset,
|
||||
)
|
||||
);
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn convert_type(mut self, target_packet_type: ZCPacketType) -> Self {
|
||||
@@ -695,12 +751,13 @@ impl ZCPacket {
|
||||
.get_packet_offsets()
|
||||
.peer_manager_header_offset;
|
||||
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
|
||||
unsafe { buf.set_len(new_pm_offset) };
|
||||
buf.resize(new_pm_offset, 0);
|
||||
buf.extend_from_slice(tunnel_payload);
|
||||
return Self::new_from_buf(buf, target_packet_type);
|
||||
}
|
||||
|
||||
Self::new_from_buf(self.inner.split_off(new_offset), target_packet_type)
|
||||
self.inner.advance(new_offset);
|
||||
Self::new_from_buf(self.inner, target_packet_type)
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Bytes {
|
||||
@@ -764,7 +821,6 @@ impl ZCPacket {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
#[test]
|
||||
fn test_zc_packet() {
|
||||
@@ -810,68 +866,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ impl RingSink {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure_all)]
|
||||
impl Sink<SinkItem> for RingSink {
|
||||
type Error = SinkError;
|
||||
|
||||
@@ -196,7 +197,8 @@ pub struct RingTunnelListener {
|
||||
|
||||
impl RingTunnelListener {
|
||||
pub fn new(key: url::Url) -> Self {
|
||||
let (conn_sender, conn_receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (conn_sender, conn_receiver) =
|
||||
hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
|
||||
RingTunnelListener {
|
||||
listener_addr: key,
|
||||
conn_sender,
|
||||
|
||||
+144
-6
@@ -266,14 +266,151 @@ fn get_zcpacket_from_buf(buf: BytesMut, allow_stun: bool) -> Result<ZCPacket, Tu
|
||||
Ok(zc_packet)
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
#[cfg(feature = "udp-gso")]
|
||||
const UDP_BATCH_SIZE: usize = 8;
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
async fn forward_from_ring_to_udp(
|
||||
mut ring_recv: RingStream,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
conn_id: u32,
|
||||
) -> Option<TunnelError> {
|
||||
tracing::debug!("udp forward from ring to udp");
|
||||
use quinn_udp::{Transmit, UdpSockRef, UdpSocketState};
|
||||
use std::io;
|
||||
|
||||
tracing::debug!("udp forward from ring to udp (GSO batch)");
|
||||
|
||||
let udp_state = match UdpSocketState::new(UdpSockRef::from(&**socket)) {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "failed to init UdpSocketState, fallback to per-packet send");
|
||||
return forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await;
|
||||
}
|
||||
};
|
||||
let max_gso = udp_state.max_gso_segments();
|
||||
tracing::info!(max_gso, "udp GSO segments supported");
|
||||
|
||||
loop {
|
||||
// 1. Get first packet (await)
|
||||
let first = match ring_recv.next().await {
|
||||
Some(Ok(pkt)) => convert_to_udp_bytes(pkt, conn_id),
|
||||
Some(Err(e)) => return Some(e),
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// 2. Try to drain more packets (non-blocking via poll_next with noop waker)
|
||||
use std::pin::Pin;
|
||||
use std::task::{Poll, Context};
|
||||
use futures::task::noop_waker;
|
||||
use futures::Stream;
|
||||
let mut batch: Vec<bytes::Bytes> = vec![first];
|
||||
while batch.len() < UDP_BATCH_SIZE.min(max_gso) {
|
||||
let waker = noop_waker();
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
match Pin::new(&mut ring_recv).poll_next(&mut cx) {
|
||||
Poll::Ready(Some(Ok(pkt))) => {
|
||||
batch.push(convert_to_udp_bytes(pkt, conn_id));
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check if all same size (GSO requirement)
|
||||
let seg_size = batch[0].len();
|
||||
let all_same = batch.iter().all(|b| b.len() == seg_size);
|
||||
|
||||
if batch.len() == 1 || !all_same || max_gso == 1 {
|
||||
// Fallback: send individually
|
||||
for buf in &batch {
|
||||
if let Err(e) = send_one(&udp_state, socket, addr, buf).await {
|
||||
return Some(TunnelError::IOError(e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// GSO batch: concatenate + single sendmsg
|
||||
let mut contents = Vec::with_capacity(seg_size * batch.len());
|
||||
for buf in &batch {
|
||||
contents.extend_from_slice(buf);
|
||||
}
|
||||
let transmit = Transmit {
|
||||
destination: *addr,
|
||||
ecn: None,
|
||||
contents: &contents,
|
||||
segment_size: Some(seg_size),
|
||||
src_ip: None,
|
||||
};
|
||||
if let Err(e) = send_one_gso(&udp_state, socket, &transmit).await {
|
||||
return Some(TunnelError::IOError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
fn convert_to_udp_bytes(mut packet: ZCPacket, conn_id: u32) -> bytes::Bytes {
|
||||
let mut packet = packet.convert_type(ZCPacketType::UDP);
|
||||
let udp_payload_len = packet.udp_payload().len();
|
||||
let header = packet.mut_udp_tunnel_header().unwrap();
|
||||
header.conn_id.set(conn_id);
|
||||
header.len.set(udp_payload_len as u16);
|
||||
header.msg_type = UdpPacketType::Data as u8;
|
||||
packet.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
async fn send_one(
|
||||
udp_state: &quinn_udp::UdpSocketState,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
buf: &[u8],
|
||||
) -> Result<(), std::io::Error> {
|
||||
use quinn_udp::{Transmit, UdpSockRef};
|
||||
let transmit = Transmit {
|
||||
destination: *addr,
|
||||
ecn: None,
|
||||
contents: buf,
|
||||
segment_size: None,
|
||||
src_ip: None,
|
||||
};
|
||||
send_one_gso(udp_state, socket, &transmit).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "udp-gso")]
|
||||
async fn send_one_gso(
|
||||
udp_state: &quinn_udp::UdpSocketState,
|
||||
socket: &Arc<UdpSocket>,
|
||||
transmit: &quinn_udp::Transmit<'_>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
use quinn_udp::UdpSockRef;
|
||||
loop {
|
||||
match udp_state.send(UdpSockRef::from(&**socket), transmit) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "udp-gso"))]
|
||||
async fn forward_from_ring_to_udp(
|
||||
mut ring_recv: RingStream,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
conn_id: u32,
|
||||
) -> Option<TunnelError> {
|
||||
forward_from_ring_to_udp_fallback(ring_recv, socket, addr, conn_id).await
|
||||
}
|
||||
|
||||
async fn forward_from_ring_to_udp_fallback(
|
||||
mut ring_recv: RingStream,
|
||||
socket: &Arc<UdpSocket>,
|
||||
addr: &SocketAddr,
|
||||
conn_id: u32,
|
||||
) -> Option<TunnelError> {
|
||||
tracing::debug!("udp forward from ring to udp (per-packet)");
|
||||
loop {
|
||||
let buf = ring_recv.next().await?;
|
||||
let packet = match buf {
|
||||
@@ -291,7 +428,6 @@ async fn forward_from_ring_to_udp(
|
||||
header.msg_type = UdpPacketType::Data as u8;
|
||||
|
||||
let buf = packet.into_bytes();
|
||||
tracing::trace!(?udp_payload_len, ?buf, "udp forward from ring to udp");
|
||||
let ret = socket.send_to(&buf, &addr).await;
|
||||
if ret.is_err() {
|
||||
return Some(TunnelError::IOError(ret.unwrap_err()));
|
||||
@@ -572,8 +708,9 @@ pub struct UdpTunnelListener {
|
||||
|
||||
impl UdpTunnelListener {
|
||||
pub fn new(addr: url::Url) -> Self {
|
||||
let (close_event_send, close_event_recv) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (conn_send, conn_recv) = tokio::sync::mpsc::channel(100);
|
||||
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));
|
||||
Self {
|
||||
addr: addr.clone(),
|
||||
socket: None,
|
||||
@@ -784,7 +921,8 @@ impl UdpTunnelConnector {
|
||||
"udp build tunnel for connector"
|
||||
);
|
||||
|
||||
let (close_event_sender, mut close_event_recv) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (close_event_sender, mut close_event_recv) =
|
||||
hotpath::channel!(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());
|
||||
|
||||
@@ -6,6 +6,8 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hotpath::instant::Instant;
|
||||
|
||||
use super::{
|
||||
FromUrl, IpVersion, Tunnel, TunnelError, TunnelInfo, TunnelListener, TunnelUrl, ZCPacketSink,
|
||||
ZCPacketStream,
|
||||
@@ -346,7 +348,7 @@ struct WgPeer {
|
||||
data: Option<WgPeerData>,
|
||||
tasks: JoinSet<()>,
|
||||
|
||||
access_time: AtomicCell<std::time::Instant>,
|
||||
access_time: AtomicCell<Instant>,
|
||||
}
|
||||
|
||||
impl WgPeer {
|
||||
@@ -369,7 +371,7 @@ impl WgPeer {
|
||||
data: None,
|
||||
tasks: JoinSet::new(),
|
||||
|
||||
access_time: AtomicCell::new(std::time::Instant::now()),
|
||||
access_time: AtomicCell::new(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +387,7 @@ impl WgPeer {
|
||||
}
|
||||
|
||||
async fn handle_packet_from_peer(&self, packet: &[u8]) {
|
||||
self.access_time.store(std::time::Instant::now());
|
||||
self.access_time.store(Instant::now());
|
||||
tracing::trace!("Received {} bytes from peer", packet.len());
|
||||
let data = self.data.as_ref().unwrap();
|
||||
// TODO: improve this
|
||||
@@ -468,7 +470,7 @@ pub struct WgTunnelListener {
|
||||
|
||||
impl WgTunnelListener {
|
||||
pub fn new(addr: url::Url, config: WgConfig) -> Self {
|
||||
let (conn_send, conn_recv) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (conn_send, conn_recv) = hotpath::channel!(tokio::sync::mpsc::unbounded_channel());
|
||||
WgTunnelListener {
|
||||
addr,
|
||||
config,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"happy-dom": "16.8.1",
|
||||
"minimatch": "10.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# 计划 001:将共享 metrics/throughput 计数改为线程安全实现
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/common/stats_manager.rs easytier/src/tunnel/stats.rs easytier/src/tunnel/filter.rs easytier/src/proto/rpc_impl/server.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P1
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: none
|
||||
- **类别**: bug
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
核心 metrics 和 tunnel throughput 计数器当前用 `UnsafeCell<u64>` 保存,并通过 safe methods 在 `Send + Sync` 类型上暴露。VPN 核心运行在多线程 Tokio runtime 上,RPC、tunnel send/receive 和统计快照可能并发访问这些 counters;这会造成 Rust 层面的数据竞争和未定义行为,不只是“统计不准”。完成后应保证所有共享计数使用 atomic 或 lock-backed primitive,且新增并发测试证明 safe API 可多线程调用。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/common/stats_manager.rs` — 通用 metrics manager;当前 `UnsafeCounter` 和 `MetricData` 手写 `Send + Sync`。
|
||||
- `easytier/src/tunnel/stats.rs` — tunnel throughput 统计;当前单独实现一套 `UnsafeCell` counters。
|
||||
- `easytier/src/tunnel/filter.rs` — `StatsRecorderTunnelFilter` 在 send/receive filter 中更新 `Arc<Throughput>`。
|
||||
- `easytier/src/proto/rpc_impl/server.rs` — RPC server paths 会更新 stats manager counters,可作为并发使用背景参考,不要求修改。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/common/stats_manager.rs:406
|
||||
pub unsafe fn add(&self, delta: u64) {
|
||||
let ptr = self.value.get();
|
||||
unsafe {
|
||||
*ptr = (*ptr).saturating_add(delta);
|
||||
}
|
||||
}
|
||||
|
||||
// easytier/src/common/stats_manager.rs:455
|
||||
unsafe impl Send for UnsafeCounter {}
|
||||
unsafe impl Sync for UnsafeCounter {}
|
||||
|
||||
// easytier/src/common/stats_manager.rs:548
|
||||
pub fn add(&self, delta: u64) {
|
||||
unsafe {
|
||||
self.metric_data.counter.add(delta);
|
||||
self.metric_data.touch();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/tunnel/stats.rs:64
|
||||
#[derive(Debug)]
|
||||
pub struct Throughput {
|
||||
tx_bytes: UnsafeCell<u64>,
|
||||
rx_bytes: UnsafeCell<u64>,
|
||||
tx_packets: UnsafeCell<u64>,
|
||||
rx_packets: UnsafeCell<u64>,
|
||||
}
|
||||
|
||||
// easytier/src/tunnel/stats.rs:83
|
||||
unsafe impl Send for Throughput {}
|
||||
unsafe impl Sync for Throughput {}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/tunnel/filter.rs:265
|
||||
fn before_send(&self, data: SinkItem) -> Option<SinkItem> {
|
||||
self.throughput.record_tx_bytes(data.buf_len() as u64);
|
||||
Some(data)
|
||||
}
|
||||
|
||||
// easytier/src/tunnel/filter.rs:270
|
||||
fn after_received(&self, data: StreamItem) -> Option<StreamItem> {
|
||||
match data {
|
||||
Ok(v) => {
|
||||
self.throughput.record_rx_bytes(v.buf_len() as u64);
|
||||
Some(Ok(v))
|
||||
}
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仓库约定:Rust 代码使用 `anyhow`/`thiserror` 做错误上下文,async tests 使用 `#[tokio::test]`;已有测试集中在 `easytier/src/tests/` 和各模块 `#[cfg(test)]` 中。保持现有 public method names,避免扩大 API 改动。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Feature check | `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` | exit 0 |
|
||||
| Targeted tests | `cargo test --package easytier stats_manager --features full -- --nocapture` | exit 0; new stats tests pass |
|
||||
| Targeted tests | `cargo test --package easytier tunnel::stats --features full -- --nocapture` | exit 0; new throughput tests pass |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/common/stats_manager.rs`
|
||||
- `easytier/src/tunnel/stats.rs`
|
||||
- `easytier/src/tunnel/filter.rs`(仅当 type/API 调整需要同步编译)
|
||||
- `easytier/src/tests/mod.rs` 或同文件内 `#[cfg(test)]` 测试(仅用于新增测试入口)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- `easytier/src/peers/*` route 或 RPC 行为;这些由后续计划处理。
|
||||
- `easytier-web/`、`easytier-gui/`、frontend packages。
|
||||
- 任何 public metric names、labels、serialized output shape 的语义变更。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/001-thread-safe-metrics-throughput`
|
||||
- Commit message style follows existing conventional commits, for example `fix: clarify config parse errors` or `fix(connector): classify manual reconnect timeouts by stage`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:替换 `UnsafeCounter` 为 atomic-backed counter
|
||||
|
||||
在 `easytier/src/common/stats_manager.rs` 中将 `UnsafeCounter` 改为持有 `AtomicU64`。保留现有 `new`、`new_with_value`、`add`、`inc`、`get`、`reset`、`set` 方法名,但将它们改成 safe methods,使用 `Ordering::Relaxed` 即可,因为这些 counters 只做统计,不承载同步 happens-before 语义。
|
||||
|
||||
同时移除 `UnsafeCounter` 的 manual `unsafe impl Send/Sync`,让 compiler 从 `AtomicU64` 自动推导。
|
||||
|
||||
**验证**:`cargo test --package easytier stats_manager --features full -- --nocapture` → exit 0;如果此时没有匹配测试,命令应显示 0 failed。
|
||||
|
||||
### 步骤 2:处理 `MetricData::last_updated`
|
||||
|
||||
`MetricData` 当前持有 `UnsafeCell<Instant>`。不要继续共享可变 `Instant`。二选一:
|
||||
|
||||
- 推荐:将 last update 表示为 `AtomicU64`,存储从 `StatsManager` 创建时刻起的 monotonic micros 或 millis;读取时只在内部转换为需要的 age/duration。
|
||||
- 可接受:用 `parking_lot::Mutex<Instant>` 保护 `last_updated`,如果改动最小且性能足够。
|
||||
|
||||
选择方案后,移除 `MetricData` 的 manual `unsafe impl Send/Sync`。保持外部 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 和数据竞争风险。
|
||||
@@ -0,0 +1,160 @@
|
||||
# 计划 002:为 peer RPC/control packet 队列加入背压和过载行为
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_manager.rs easytier/src/peers/foreign_network_manager.rs easytier/src/common/stats_manager.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P1
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: plans/001-thread-safe-metrics-throughput.md
|
||||
- **类别**: perf
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
Peer RPC/control packet transport 当前使用 `mpsc::unbounded_channel()`,network-facing packet processor 对每个 RPC packet 直接 `send(...).unwrap()`。如果远端或本地 relay 突发控制面 packet,队列可以无限增长,导致内存膨胀和控制面延迟;如果 receiver 关闭,`unwrap()` 还会 panic。完成后应有明确 bounded capacity、drop/backpressure policy 和可观测 drop 计数。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/peers/peer_manager.rs` — local peer RPC transport 队列和 packet processor。
|
||||
- `easytier/src/peers/foreign_network_manager.rs` — foreign-network RPC transport 队列和 relay/local packet ingestion。
|
||||
- `easytier/src/common/stats_manager.rs` — 如果 001 已完成,应复用线程安全 metrics 记录 queue drops。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_manager.rs:275
|
||||
// TODO: remove these because we have impl pipeline processor.
|
||||
let (peer_rpc_tspt_sender, peer_rpc_tspt_recv) = mpsc::unbounded_channel();
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_manager.rs:1245
|
||||
struct PeerRpcPacketProcessor {
|
||||
peer_rpc_tspt_sender: UnboundedSender<ZCPacket>,
|
||||
}
|
||||
|
||||
// easytier/src/peers/peer_manager.rs:1257
|
||||
self.peer_rpc_tspt_sender.send(packet).unwrap();
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/foreign_network_manager.rs:362
|
||||
let (rpc_transport_sender, peer_rpc_tspt_recv) = mpsc::unbounded_channel();
|
||||
|
||||
// easytier/src/peers/foreign_network_manager.rs:529
|
||||
rpc_sender.send(zc_packet).unwrap();
|
||||
```
|
||||
|
||||
仓库约定:control-plane errors 通常通过 `tracing::{debug,warn,error}` 记录;packet hot path 应避免 blocking await。已有 data-plane queues elsewhere 倾向显式容量和丢弃策略;本计划应保持 hot path 非阻塞。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Feature check | `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` | exit 0 |
|
||||
| Targeted tests | `cargo test --package easytier peer_manager --features full -- --nocapture` | exit 0; new queue tests pass if present |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/peers/peer_manager.rs`
|
||||
- `easytier/src/peers/foreign_network_manager.rs`
|
||||
- `easytier/src/common/stats_manager.rs`(仅用于添加/复用 drop metric names;不要重做 001)
|
||||
- `easytier/src/tests/*`(仅新增/调整本计划相关测试)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- RPC protocol message definitions and generated protobuf code。
|
||||
- Routing semantics、credential trust、foreign network topology logic。
|
||||
- Frontend, web server, GUI。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/002-bound-peer-rpc-queues`
|
||||
- Commit message style follows existing conventional commits, for example `fix: route_update message is not lag`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:定义 bounded capacity 和 overload policy
|
||||
|
||||
在两个文件中引入同一个小常量,建议名称为 `PEER_RPC_PACKET_QUEUE_CAPACITY`,初始值建议 `1024` 或 `4096`。如果已有相近 queue capacity 常量,复用仓库风格。
|
||||
|
||||
Policy 必须明确:packet hot path 不等待;当队列满或 receiver closed 时,丢弃当前 RPC/control packet,记录 `tracing::warn!` 或 rate-limited debug,并增加 drop counter。不要 panic。
|
||||
|
||||
**验证**:`cargo fmt --all -- --check` → exit 0。
|
||||
|
||||
### 步骤 2:替换 `peer_manager.rs` 的 unbounded channel
|
||||
|
||||
将 `mpsc::unbounded_channel()` 替换为 `mpsc::channel(PEER_RPC_PACKET_QUEUE_CAPACITY)`。更新 `RpcTransport`、`PeerRpcPacketProcessor` 字段类型,从 `UnboundedSender`/unbounded receiver 改成 bounded `Sender`/`Receiver`。
|
||||
|
||||
在 `try_process_packet_from_peer` 中不要 `.await`,使用 `try_send(packet)`。如果 `Full` 或 `Closed`,记录并返回 `None`,保持原有“这是 RPC packet,不再进入 data-plane pipeline”的行为。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_manager --features full -- --nocapture` → exit 0;如果没有匹配测试,至少必须编译通过。
|
||||
|
||||
### 步骤 3:替换 `foreign_network_manager.rs` 的 unbounded channel
|
||||
|
||||
同样将 foreign-network RPC transport 改为 bounded channel,并在 ingestion path 使用 `try_send(zc_packet)`。不得保留 `unwrap()`。
|
||||
|
||||
如果两个文件都需要相同 helper,优先在各文件内保持小函数,避免为了复用引入新模块。最小正确改动优先。
|
||||
|
||||
**验证**:`cargo test --package easytier foreign_network_manager --features full -- --nocapture` → exit 0;如果没有匹配测试,至少必须编译通过。
|
||||
|
||||
### 步骤 4:添加队列满/receiver closed 的单元测试或小型回归测试
|
||||
|
||||
尽量在模块内新增不依赖真实网络 namespace 的测试:创建 bounded channel 容量为 1,填满后调用封装的 send helper,断言不会 panic 且返回/drop counter 行为正确。如果代码结构不允许直接测试 private helper,可以抽出一个 file-local helper function,例如 `try_enqueue_rpc_packet(...) -> bool`,测试 helper。
|
||||
|
||||
不要为了测试启动完整三节点网络;这属于慢集成测试,不适合验证 queue behavior。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_rpc_queue --features full -- --nocapture` → exit 0;如果测试名不同,使用实际新增测试过滤器,输出中新增测试通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 queue helper tests,覆盖队列未满、队列满、receiver closed 三种情况。
|
||||
- 如果添加 drop metric,测试满队列时 counter 增加。
|
||||
- 不要求新增 full network integration 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` 优先级,应另写计划。
|
||||
@@ -0,0 +1,176 @@
|
||||
# 计划 003:避免 OSPF 对 stale/no-op sync payload 重算路由
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P2
|
||||
- **工作量**: S
|
||||
- **风险**: MED
|
||||
- **依赖**: none
|
||||
- **类别**: perf
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
OSPF sync handler 当前已经能判断 `peer_infos` 是否实际写入了更新版本,但函数只返回 `Result<(), Error>`,调用方仍对任何非空 payload 设置 `need_update_route_table = true`。在重复、乱序或旧版本 sync 消息较多时,会触发完整 route-table rebuild,造成不必要 CPU 和锁竞争。完成后只有 stored topology state 变化时才重算路由,同时保持 duplicate peer ID 检查和 trust 更新语义。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/peers/peer_ospf_route.rs` — OSPF route sync、state mutation 和 route-table rebuild 逻辑都在同一文件中。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:868
|
||||
fn update_peer_infos(
|
||||
&self,
|
||||
my_peer_id: PeerId,
|
||||
my_peer_route_id: u64,
|
||||
dst_peer_id: PeerId,
|
||||
peer_infos: &[RoutePeerInfo],
|
||||
raw_peer_infos: &[DynamicMessage],
|
||||
) -> Result<(), Error> {
|
||||
let mut need_inc_version = false;
|
||||
// ...
|
||||
if need_inc_version {
|
||||
self.version.inc();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:3623
|
||||
service_impl.synced_route_info.update_peer_infos(
|
||||
my_peer_id,
|
||||
service_impl.my_peer_route_id,
|
||||
from_peer_id,
|
||||
pi,
|
||||
rpi,
|
||||
)?;
|
||||
// ...
|
||||
session.update_dst_saved_peer_info_version(pi, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:3647
|
||||
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||
session.update_dst_saved_conn_info_version(conn_info, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
```
|
||||
|
||||
仓库约定:性能修复必须保守;route correctness 优先于少重算。已有 `foreign_network_changed` 风格 change flag,应匹配这种模式,不要引入复杂 scheduler。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; new change-flag tests pass if present |
|
||||
| Integration tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/peers/peer_ospf_route.rs`
|
||||
- `easytier/src/tests/*`(仅当需要新增 route sync regression test)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- OSPF graph algorithm、route-table data structures、credential trust policy。
|
||||
- protobuf schema and generated code。
|
||||
- GUI/Web/frontend。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/003-avoid-noop-ospf-route-rebuilds`
|
||||
- Commit message style follows existing conventional commits, for example `fix: route_update message is not lag`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:让 `update_peer_infos` 返回是否改变 state
|
||||
|
||||
将 `update_peer_infos` 返回类型从 `Result<(), Error>` 改为 `Result<bool, Error>`,返回 `need_inc_version`。保持 duplicate peer ID 检查、raw peer info 更新和 version increment 逻辑不变。
|
||||
|
||||
调用方保存为 `let peer_infos_changed = ...?;`。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0 或无匹配测试但编译通过。
|
||||
|
||||
### 步骤 2:确认 `update_conn_info` 是否已有 changed flag
|
||||
|
||||
阅读同文件中 `update_conn_info` 和 `update_conn_info_one_peer`。如果 `update_conn_info_one_peer` 已返回 `bool`,则让 `update_conn_info` 聚合并返回 `bool`。如果当前 `update_conn_info` 已返回 bool,只使用现有返回值,不重复实现。
|
||||
|
||||
不要改变 accept/reject credential conn info 的条件;只改变“是否设置 `need_update_route_table`”的判断。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
|
||||
|
||||
### 步骤 3:仅在 actual change 时设置 `need_update_route_table`
|
||||
|
||||
在 sync handler 中改为:
|
||||
|
||||
- `peer_infos_changed` 为 true 时才设置 `need_update_route_table = true`。
|
||||
- `conn_info_changed` 为 true 时才设置 `need_update_route_table = true`。
|
||||
- `session.update_dst_saved_peer_info_version(...)` 和 `session.update_dst_saved_conn_info_version(...)` 是否应在 unchanged payload 时调用,需要按现有 session version semantics 判断;如果它只是记录对端已发送版本,可保留调用,避免重复请求。
|
||||
|
||||
**验证**:`cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
|
||||
### 步骤 4:新增 no-op update regression tests
|
||||
|
||||
优先添加 module-level unit tests,直接构造 `SyncedRouteInfo` 或现有内部结构:
|
||||
|
||||
- 首次插入较新 `RoutePeerInfo` 返回 `true`。
|
||||
- 再次插入相同 version 或旧 version 返回 `false`。
|
||||
- `update_conn_info` 对相同 connected peers 返回 `false`,对变化集合返回 `true`。
|
||||
|
||||
如果内部类型构造太复杂,使用现有 route sync tests 的 helper;不要为了测试暴露 public API,最多使用 `#[cfg(test)]` helper。
|
||||
|
||||
**验证**:使用实际新增测试过滤器运行,例如 `cargo test --package easytier noop_route_update --features full -- --nocapture` → exit 0;输出中新增测试通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 `update_peer_infos` changed flag 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` 处理。
|
||||
@@ -0,0 +1,176 @@
|
||||
# 计划 004:复用 OSPF route-table 构图以减少拓扑更新成本
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/peers/peer_ospf_route.rs easytier/src/tests`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P2
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: plans/003-avoid-noop-ospf-route-rebuilds.md
|
||||
- **类别**: perf
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
每次 OSPF 拓扑更新当前会分别为 least-hop 和 least-cost route table 调用 `build_from_synced_info`。每次调用都会从 synced info 重新构建 peer graph,并重新构建 peer/CIDR indexes。对于 peer 数和 proxy CIDR 数较大的 mesh,这把一次拓扑变化放大成两次完整构图和多次 map/trie 重建。完成后应保持 route selection 结果不变,同时复用同一份 graph/materialized synced view,减少 CPU 和分配成本。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/peers/peer_ospf_route.rs` — `update_route_table`、graph builder、least-hop/least-cost map generation、CIDR trie rebuild 均在此文件。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:1628
|
||||
// build next hop map
|
||||
let (graph, start_node) =
|
||||
Self::build_peer_graph_from_synced_info(my_peer_id, synced_info, cost_calc);
|
||||
|
||||
// easytier/src/peers/peer_ospf_route.rs:1649
|
||||
if matches!(policy, NextHopPolicy::LeastHop) {
|
||||
self.gen_next_hop_map_with_least_hop(&graph, &start_node, version);
|
||||
} else {
|
||||
self.gen_next_hop_map_with_least_cost(&graph, &start_node, version);
|
||||
};
|
||||
|
||||
// easytier/src/peers/peer_ospf_route.rs:1655
|
||||
let mut new_cidr_prefix_trie = PrefixMap::new();
|
||||
let mut new_cidr_v6_prefix_trie = PrefixMap::new();
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/peers/peer_ospf_route.rs:2453
|
||||
fn update_route_table(&self) {
|
||||
// ...
|
||||
self.route_table.build_from_synced_info(
|
||||
self.my_peer_id,
|
||||
&self.synced_route_info,
|
||||
NextHopPolicy::LeastHop,
|
||||
calc_locked.as_ref().unwrap(),
|
||||
);
|
||||
|
||||
self.route_table_with_cost.build_from_synced_info(
|
||||
self.my_peer_id,
|
||||
&self.synced_route_info,
|
||||
NextHopPolicy::LeastCost,
|
||||
calc_locked.as_ref().unwrap(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
仓库约定:core routing behavior must be preserved。先添加 characterization tests,再重构;不要在同一计划里改 route policy。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Targeted tests | `cargo test --package easytier peer_ospf_route --features full -- --nocapture` | exit 0; route characterization tests pass |
|
||||
| Archive tests | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/peers/peer_ospf_route.rs`
|
||||
- `easytier/src/tests/*`(仅新增/调整 route-table characterization tests)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- Route protocol schema and wire format。
|
||||
- Credential/trust semantics。
|
||||
- Peer center、foreign network manager、data-plane tunnels。
|
||||
- Any UI or config surface。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/004-reuse-ospf-route-graph`
|
||||
- Commit message style follows existing conventional commits, for example `refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:添加 route-table characterization tests
|
||||
|
||||
在修改实现前,新增测试覆盖至少一个包含以下元素的小拓扑:
|
||||
|
||||
- 本 peer、两个 reachable peers、一个 unreachable 或 outdated peer。
|
||||
- 至少一个 IPv4 proxy CIDR 和一个 IPv6 proxy CIDR。
|
||||
- least-hop 和 least-cost 结果不同或至少都被断言。
|
||||
|
||||
测试应断言当前 `route_table` 和 `route_table_with_cost` 对 peer next-hop、peer reachability、CIDR lookup 的结果。优先使用现有测试 helper;如果内部 API 不便,添加 `#[cfg(test)]` helper,不改变生产 API。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;新增 characterization tests 在重构前通过。
|
||||
|
||||
### 步骤 2:抽出一次性 graph build 输入
|
||||
|
||||
在 `peer_ospf_route.rs` 中把 `build_from_synced_info` 内部的 graph construction 拆成私有 helper,例如:
|
||||
|
||||
- `build_peer_graph_from_synced_info(...)` 已存在则复用。
|
||||
- 新增 small struct 持有 `graph`、`start_node`、`version` 和后续 index rebuild 需要的 synced snapshot references。
|
||||
|
||||
不要改变 `gen_next_hop_map_with_least_hop` 或 `gen_next_hop_map_with_least_cost` 的算法。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
|
||||
|
||||
### 步骤 3:让 `update_route_table` 对两种 policy 复用 graph
|
||||
|
||||
把 `update_route_table` 改为在持有 `cost_calculator` read lock 时构建一次 graph/materialized input,然后分别对 `self.route_table` 和 `self.route_table_with_cost` 应用 least-hop / least-cost generation。
|
||||
|
||||
如果现有 `RouteTable::build_from_synced_info` 是唯一封装点,可以新增一个 sibling method,例如 `build_from_prebuilt_graph(...)`,保持旧方法用于兼容 tests 或其他调用方。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0;characterization tests 仍通过。
|
||||
|
||||
### 步骤 4:避免重复构建共享 indexes
|
||||
|
||||
检查 `build_from_synced_info` 中 peer info map、IPv4 map、CIDR tries 的生成是否依赖 policy-specific next-hop map。如果只依赖 reachability 或 synced info,可移动到共享 helper;如果依赖每个 `RouteTable` 自己的 `next_hop_map`,不要强行共享,避免改变 semantics。
|
||||
|
||||
允许分阶段收益:只共享 graph build 也可完成本计划;共享 CIDR/index 只有在 characterization tests 能证明 behavior 不变时才做。
|
||||
|
||||
**验证**:`cargo test --package easytier peer_ospf_route --features full -- --nocapture` → exit 0。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 route-table characterization tests,先在重构前证明现有行为,再在重构后保持通过。
|
||||
- 测试覆盖 least-hop、least-cost、CIDR lookup、unreachable peer exclusion。
|
||||
- 如果可行,加入一个轻量 counter/helper 在 test-only path 确认 graph builder 调用次数从 2 降为 1;如果这需要侵入生产代码,则不要做。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] `update_route_table` 不再对同一 synced topology 构建两次 peer graph。
|
||||
- [ ] least-hop 和 least-cost route outputs 与 characterization tests 中的旧行为一致。
|
||||
- [ ] 没有改变 routing protocol、credential trust 或 config behavior。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
|
||||
- [ ] 没有修改范围外文件。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- `plans/003-avoid-noop-ospf-route-rebuilds.md` 未完成,且当前 route rebuild trigger 仍会对 no-op payload 重算。
|
||||
- 复用 graph 需要改变 least-hop 或 least-cost algorithm。
|
||||
- 现有代码让 `cost_calc` 在两次 build 之间发生有意状态变化;如果确认 `begin_update`/`end_update` 依赖两次独立 build,停止并报告。
|
||||
- Characterization tests 无法稳定构造 route-table expected outputs。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- reviewer 应重点审查是否在锁持有期间引入更长 critical section。
|
||||
- 未来新增 route policy 时应复用本计划抽出的 prebuilt graph input,而不是再调用完整 `build_from_synced_info`。
|
||||
- 本计划不拆分 `peer_ospf_route.rs` 大文件;只做局部性能重构。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 计划 005:补齐 SOCKS5 exit-node 集成测试覆盖
|
||||
|
||||
> **执行者说明**:按步骤执行本计划。每一步都必须运行验证命令,并确认结果符合预期后再继续。如果触发“STOP 条件”中的任一情况,立即停止并报告,不要自行发挥。完成后更新 `plans/README.md` 中本计划的状态行,除非 reviewer 明确说明由他们维护索引。
|
||||
>
|
||||
> **漂移检查(首先运行)**:`git diff --stat 78146d16..HEAD -- easytier/src/tests/three_node.rs easytier/src/tests/mod.rs easytier/src/gateway easytier/src/vpn_portal easytier/src/peers`
|
||||
> 如果本计划写成后任何范围内文件发生变化,继续前必须对照“当前状态”中的摘录与实时代码;如果不匹配,按 STOP 条件处理。
|
||||
|
||||
## 状态
|
||||
|
||||
- **优先级**: P2
|
||||
- **工作量**: M
|
||||
- **风险**: MED
|
||||
- **依赖**: none
|
||||
- **类别**: tests
|
||||
- **计划生成于**: commit `78146d16`, 2026-06-18
|
||||
|
||||
## 为什么重要
|
||||
|
||||
测试文件顶部明确 TODO 指出需要覆盖 `socks5 + exit node == self || proxy_cidr == 0.0.0.0/0` 的出口节点能力。现有 `socks5_vpn_portal` 测试只覆盖固定 destination 和 `10.1.2.0/24` proxy CIDR,不能证明默认出口路由或 self-exit 场景。完成后,这条核心 VPN routing/use-case 会有 characterization test,后续修改 SOCKS5、proxy CIDR 或 exit-node 行为时不再盲改。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- `easytier/src/tests/three_node.rs` — 三节点集成测试和 SOCKS5 portal 测试所在文件。
|
||||
- `easytier/src/gateway/socks5.rs`、`easytier/src/gateway/socks5/dataplane.rs` — SOCKS5 gateway implementation;仅在测试失败定位时阅读,默认不修改。
|
||||
- `easytier/src/peers/peer_ospf_route.rs` — proxy CIDR 和 route selection 行为;默认不修改。
|
||||
|
||||
当前代码摘录:
|
||||
|
||||
```rust
|
||||
// easytier/src/tests/three_node.rs:16
|
||||
// TODO: 需要加一个单测,确保 socks5 + exit node == self || proxy_cidr == 0.0.0.0/0 时,可以实现出口节点的能力。
|
||||
```
|
||||
|
||||
```rust
|
||||
// easytier/src/tests/three_node.rs:1753
|
||||
pub async fn socks5_vpn_portal(
|
||||
#[values("10.144.144.1", "10.144.144.3", "10.1.2.4")] dst_addr: &str,
|
||||
) {
|
||||
// ...
|
||||
let _insts = init_three_node_ex(
|
||||
"tcp",
|
||||
|cfg| {
|
||||
if cfg.get_inst_name() == "inst3" {
|
||||
// 添加子网代理配置
|
||||
cfg.add_proxy_cidr("10.1.2.0/24".parse().unwrap(), None)
|
||||
.unwrap();
|
||||
}
|
||||
cfg
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
仓库约定:这些网络集成测试使用 `#[tokio::test]` 和 `#[serial_test::serial]`,部分测试需要 Linux network namespace/root capabilities。保持测试 isolated and repeatable;不要让新增测试依赖外部网络。
|
||||
|
||||
## 需要使用的命令
|
||||
|
||||
| Purpose | Command | Expected on success |
|
||||
|---------|---------|---------------------|
|
||||
| Format | `cargo fmt --all -- --check` | exit 0 |
|
||||
| Lint | `cargo clippy --all-targets --features full --all -- -D warnings` | exit 0, no warnings |
|
||||
| Targeted test | `cargo test --package easytier socks5_vpn_portal --features full -- --nocapture --test-threads 1` | exit 0; existing and new SOCKS5 tests pass |
|
||||
| CI-style archive | `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` | exit 0 |
|
||||
|
||||
如果本地环境缺少 root/network namespace 能力,targeted test 可能失败。此时仍必须确保 compile/archive 通过,并在结果中明确记录环境缺口。
|
||||
|
||||
## 临时目录约定
|
||||
|
||||
- 临时文件、scratch 目录和 disposable worktree 必须放在 `$HOME/tmp` 下。
|
||||
- 如果 `$HOME/tmp` 不存在且本计划需要临时空间,先创建它。
|
||||
- 不要把临时产物放进被修改仓库。
|
||||
|
||||
## 范围
|
||||
|
||||
**范围内**(只能修改这些文件):
|
||||
- `easytier/src/tests/three_node.rs`
|
||||
- `easytier/src/tests/mod.rs`(仅当需要注册 helper/module)
|
||||
|
||||
**范围外**(即使看起来相关也不要触碰):
|
||||
- Production SOCKS5/gateway/routing code。若测试暴露 bug,停止并报告;不要在本计划里修生产逻辑。
|
||||
- Any CI workflow, docs, GUI/Web/frontend。
|
||||
- Existing tests unrelated to SOCKS5 portal or exit-node behavior。
|
||||
|
||||
## Git 工作流
|
||||
|
||||
- Branch: `advisor/005-cover-socks5-exit-node`
|
||||
- Commit message style follows existing conventional commits, for example `test: add tests` from `CONTRIBUTING.md`.
|
||||
- Do NOT push or open a PR unless the operator instructed it.
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:阅读现有 `socks5_vpn_portal` helper pattern
|
||||
|
||||
在 `easytier/src/tests/three_node.rs` 中阅读完整 `socks5_vpn_portal` 测试,特别是如何启动三节点、如何启动 TCP listener、如何通过 `tokio_socks::tcp::socks5::Socks5Stream` 访问目标地址、如何 cleanup。
|
||||
|
||||
不要复制大量代码后分叉;优先抽取小 helper,例如 `run_socks5_tcp_echo_case(...)`,让现有测试和新增测试共享。
|
||||
|
||||
**验证**:`cargo fmt --all -- --check` → exit 0(如果尚未修改,仍应通过)。
|
||||
|
||||
### 步骤 2:新增 `0.0.0.0/0` proxy CIDR exit-node case
|
||||
|
||||
新增一个 serial async test,命名建议 `socks5_vpn_portal_default_ipv4_exit_node`。测试应:
|
||||
|
||||
- 使用 `init_three_node_ex` 创建三节点。
|
||||
- 让某个非客户端节点配置 `cfg.add_proxy_cidr("0.0.0.0/0".parse().unwrap(), None).unwrap()`。
|
||||
- 通过 SOCKS5 portal 访问一个由测试内部启动的 TCP echo server 地址。
|
||||
- 断言 payload round-trip 成功。
|
||||
|
||||
测试目标地址必须是本地/测试 namespace 可控地址,不允许依赖公网。
|
||||
|
||||
**验证**:`cargo test --package easytier socks5_vpn_portal_default_ipv4_exit_node --features full -- --nocapture --test-threads 1` → exit 0;若因权限环境失败,错误必须是环境相关,而非编译或断言失败。
|
||||
|
||||
### 步骤 3:新增 self-exit case 或明确不可测原因
|
||||
|
||||
根据 TODO 中的 `exit node == self`,新增第二个测试,命名建议 `socks5_vpn_portal_self_exit_node`。它应覆盖 SOCKS5 入口节点同时也是 exit node 的场景。
|
||||
|
||||
如果现有 config API 没有清晰方式表达 “exit node == self”,不要猜测配置。先搜索现有 tests 中 `exit_nodes`、`add_proxy_cidr`、`vpn_portal` 的用法;如果仍不明确,STOP 并报告需要 maintainer 确认配置语义。
|
||||
|
||||
**验证**:`cargo test --package easytier socks5_vpn_portal_self_exit_node --features full -- --nocapture --test-threads 1` → exit 0;或 STOP 报告不可测配置语义。
|
||||
|
||||
### 步骤 4:移除或更新 TODO
|
||||
|
||||
如果两个场景都已覆盖,将 `three_node.rs:16` 的 TODO 删除或改成剩余未覆盖场景的精确 TODO。不要删除仍未覆盖的提醒。
|
||||
|
||||
**验证**:`cargo test --package easytier socks5_vpn_portal --features full -- --nocapture --test-threads 1` → exit 0;现有和新增 SOCKS5 portal tests 通过。
|
||||
|
||||
### 步骤 5:运行完整相关门禁
|
||||
|
||||
**验证**:
|
||||
- `cargo fmt --all -- --check` → exit 0。
|
||||
- `cargo clippy --all-targets --features full --all -- -D warnings` → exit 0。
|
||||
- `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` → exit 0。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- 新增 `socks5_vpn_portal_default_ipv4_exit_node`:覆盖 `proxy_cidr == 0.0.0.0/0`。
|
||||
- 新增 `socks5_vpn_portal_self_exit_node`:覆盖 SOCKS5 入口节点作为出口节点。
|
||||
- 复用现有 `socks5_vpn_portal` 的 TCP echo/payload pattern,保持 `#[serial_test::serial]`。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] TODO 中提到的 `0.0.0.0/0` exit-node 场景有测试覆盖。
|
||||
- [ ] TODO 中提到的 self-exit 场景有测试覆盖,或计划按 STOP 条件阻塞并说明配置语义缺口。
|
||||
- [ ] 新测试不依赖公网服务。
|
||||
- [ ] `cargo fmt --all -- --check` exits 0。
|
||||
- [ ] `cargo clippy --all-targets --features full --all -- -D warnings` exits 0。
|
||||
- [ ] `cargo nextest archive --archive-file tests.tar.zst --package easytier --features full` exits 0。
|
||||
- [ ] 没有修改 production code 或范围外文件。
|
||||
- [ ] 已更新 `plans/README.md` 中本计划的状态行。
|
||||
|
||||
## STOP 条件
|
||||
|
||||
- 新增测试暴露 production bug:不要修生产代码,停止并报告 failing test、命令和错误摘要。
|
||||
- self-exit 的配置语义无法从现有代码/tests 中确认。
|
||||
- 测试只能通过访问公网验证;这不符合仓库测试隔离要求。
|
||||
- 为了让测试通过需要放宽 assertions 或增加 sleeps 超过现有测试风格。
|
||||
|
||||
## 维护说明
|
||||
|
||||
- reviewer 应重点审查测试是否真正走 SOCKS5 portal 和 exit-node route,而不是退化成本地直连。
|
||||
- 后续修改 proxy CIDR、exit-node、SOCKS5 dataplane 时,应运行本计划新增的 targeted tests。
|
||||
- 本计划只建立测试基线;如果发现 bug,应另写修复计划。
|
||||
@@ -0,0 +1,200 @@
|
||||
# 计划 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。需充分测试。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 实施计划
|
||||
|
||||
由 improve skill 于 2026-06-18 生成,基于 commit `78146d16`。除非依赖关系另有要求,请按以下顺序执行。每个执行者在开始前必须完整阅读计划,遵守 STOP 条件,并在完成后更新自己的状态行。
|
||||
|
||||
## 执行顺序与状态
|
||||
|
||||
| Plan | 标题 | 优先级 | 工作量 | 依赖 | Status |
|
||||
|------|------|--------|--------|------|--------|
|
||||
| 001 | 将共享 metrics/throughput 计数改为线程安全实现 | P1 | M | — | DONE in worktree, not merged |
|
||||
| 002 | 为 peer RPC/control packet 队列加入背压和过载行为 | P1 | M | 001 | DONE in worktree, not merged |
|
||||
| 003 | 避免 OSPF 对 stale/no-op sync payload 重算路由 | P2 | S | — | DONE in worktree, not merged |
|
||||
| 004 | 复用 OSPF route-table 构图以减少拓扑更新成本 | P2 | M | 003 | DONE in worktree, not merged |
|
||||
| 005 | 补齐 SOCKS5 exit-node 集成测试覆盖 | P2 | M | — | DONE in worktree, not merged |
|
||||
|
||||
状态值:TODO | IN PROGRESS | DONE | DONE in worktree, not merged | BLOCKED(附一行原因) | REJECTED(附一行理由,例如 finding 已独立修复或方案放弃)
|
||||
|
||||
## Reconcile 2026-06-18
|
||||
|
||||
- 001: `/home/fanmi/tmp/easytier-exec-001`, branch `advisor/001-thread-safe-metrics-throughput`, commit `7b6e4dfe`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 002: `/home/fanmi/tmp/easytier-exec-002`, branch `advisor/002-bound-peer-rpc-queues`, commit `34d2193d`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 003: `/home/fanmi/tmp/easytier-exec-003`, branch `advisor/003-avoid-noop-ospf-route-rebuilds`, commit `1be77b51`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 004: `/home/fanmi/tmp/easytier-exec-004`, branch `advisor/004-reuse-ospf-route-graph`, commit `325c2e5d`; worktree clean; not contained in `main` at `78146d16`.
|
||||
- 005: `/home/fanmi/tmp/easytier-exec-005`, branch `advisor/005-cover-socks5-exit-node`, commit `2cb51b71`; worktree clean; not contained in `main` at `78146d16`.
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- 002 依赖 001,因为队列背压计划应暴露 queue depth/drop counters;这些 counters 应复用 001 中线程安全后的 metrics primitive,避免在新代码里继续扩散 `UnsafeCell` 模式。
|
||||
- 004 依赖 003,因为先抑制 no-op sync 的无效重算,再做共享构图重构,能让性能测试和行为变化更容易归因。
|
||||
- 005 独立执行,但如果未来要修改 SOCKS5、exit-node 或 `0.0.0.0/0` proxy CIDR 行为,应先落地 005 作为 characterization baseline。
|
||||
|
||||
## 已考虑并拒绝的发现
|
||||
|
||||
- `/api/v1/generate-config` 和 `/api/v1/parse-config` 是否应要求登录:证据显示 route layering 可能使其公开,但可能是产品意图;不属于本次“正确性和性能”范围,且应先补意图测试再判断。
|
||||
- OSPF 7k 行 god module 整体拆分:确认是技术债,但范围过大;应先执行 003、004 并增加 characterization tests 后再规划。
|
||||
- 前端测试/DX、依赖清理、安全 hardening:有价值,但用户本次只要求正确性和性能计划。
|
||||
Generated
+763
-23
File diff suppressed because it is too large
Load Diff
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
NS_NAME="et_bench"
|
||||
|
||||
echo "=== Creating netns: $NS_NAME ==="
|
||||
# Clean up old ns
|
||||
sudo ip netns del "$NS_NAME" 2>/dev/null || true
|
||||
|
||||
# Create namespace
|
||||
sudo ip netns add "$NS_NAME"
|
||||
|
||||
# Enable loopback inside namespace
|
||||
sudo ip netns exec "$NS_NAME" ip link set lo up
|
||||
|
||||
echo "=== netns $NS_NAME ready ==="
|
||||
echo "Both instances will share this namespace's loopback."
|
||||
echo "TCP/UDP connections to 127.0.0.1 will work inside it."
|
||||
echo ""
|
||||
echo "Now run the bench with HOTPATH_NETNS=$NS_NAME:"
|
||||
echo " HOTPATH_TUNNEL=tcp HOTPATH_NETNS=$NS_NAME HOTPATH_BENCH_SECS=15 \\"
|
||||
echo " cargo run --profile hotpath --features hotpath --example cpu_hotspot_ring"
|
||||
Reference in New Issue
Block a user