Sfoglia il codice sorgente

修改UDP子节点错误问题

Gogs 2 settimane fa
parent
commit
1829c35aab
7 ha cambiato i file con 299 aggiunte e 217 eliminazioni
  1. 194 201
      README.md
  2. 17 9
      config.json
  3. 2 0
      config.py
  4. 4 1
      relay_client.py
  5. 14 0
      relay_server.py
  6. 25 2
      socks_edge.py
  7. 43 4
      transparent_edge.py

+ 194 - 201
README.md

@@ -1,31 +1,75 @@
 # mynetspeeder
 
-`mynetspeeder` 现在支持 **透明接管当前服务器的 `direct` TCP 出站**
+`mynetspeeder` 用于给当前主机上的 `direct` 出站做透明加速,并支持可选的 UDP 并行竞速
 
-目标场景:
+## 当前结论
 
-- 你本机继续通过 `sing-box` 的 `hy2 19887/19888` 入站使用外网
-- `sing-box` 出站仍然保持 `direct`
-- `mynetspeeder` 在系统层透明接管这些 `direct` TCP 流量
-- 当前主 VPS 直接出站可立即工作
-- 配置的子节点 VPS 会自动连接并参与竞速
-- 子节点不在线时,不影响当前主 VPS 直接使用
+- 现在项目的推荐组合是:`TCP 走透明接管`,`UDP 走显式 SOCKS5 UDP ASSOCIATE`
+- 只要 `config.json` 里 `socks_port > 0`,`start-transparent.sh` 就会启动 `socks` 入口
+- 此时即使传了 `--enable-udp`,脚本也会关闭 UDP 透明接管,改为仅保留 TCP 透明接管
+- 所以上一次配置里如果同时出现 `socks_port > 0` 和“UDP 透明”描述,那是文档/配置语义混杂,不是当前实际生效路径
+
+对应脚本行为可直接概括为:
 
-## 当前能力
+- `TCP`:继续通过 `iptables/ip6tables nat OUTPUT -> REDIRECT -> edge`
+- `UDP`:若 `socks_port > 0`,优先走 `socks` 显式入口;不再做透明 UDP 重定向
+- `UDP`:只有在 `socks_port == 0` 且显式加 `--enable-udp` 时,才走透明 UDP
+
+## 现在有哪些模式
 
 - `relay`:子节点 VPS 中继
-- `edge`:主 VPS 透明接管 `direct` TCP 出站
-- `probe`:查看节点探测与在线状态
-- 主 VPS 本地直连始终可用
-- 子节点可选,多节点在线时自动参与冗余竞速
-- 一键启停脚本:透明接管 `OUTPUT` 链中的 TCP 连接
+- `edge`:主 VPS 透明接管 `direct` 出站
+- `socks`:显式 UDP 入口,给需要手动交付 UDP/QUIC 的应用使用
+- `probe`:查看子节点探测与在线状态
+- `summary`:汇总日志里的 TCP / UDP 胜率
+
+## 典型场景
 
-## 重要说明
+- 你本机继续通过 `sing-box` 的 `hy2 19887/19888` 入站使用外网
+- `sing-box` 的 `direct` 出站不需要改动
+- `mynetspeeder` 在系统层接管这些 `direct` 流量
+- 主 VPS 直连、子节点 relay、以及可选的 UDP 并行路径都会参与竞速
+
+## 架构流程图
+
+### 推荐组合:TCP 透明 + UDP SOCKS
+
+```mermaid
+flowchart LR
+    A[应用 / sing-box] -->|TCP 出站| B[系统 nat OUTPUT REDIRECT]
+    B --> C[Transparent Edge :19080]
+    C --> D1[Direct TCP 副本 1..N]
+    C --> D2[Relay TCP 路径 1..N]
+    D1 --> E[TCP winner]
+    D2 --> E
+    E --> F[目标站点]
+
+    A -->|UDP / QUIC| G[SOCKS5 UDP ASSOCIATE :19180]
+    G --> H1[Direct UDP 副本 1..N]
+    G --> H2[Relay UDP 路径 1..N]
+    H1 --> I[UDP winner]
+    H2 --> I
+    I --> F
+```
 
-- 当前透明模式先实现的是 **TCP**
-- UDP 默认关闭,需要显式开启
-- 这是为了先让你现有视频、网页、下载这类 TCP 场景可直接用起来
-- 开启 UDP 时,脚本会自动放行 `ESTABLISHED,RELATED` 回包,避免本机入站/回包再次被 `OUTPUT` 重定向到 `19080` 形成自环
+### 备用组合:TCP 透明 + UDP 透明
+
+```mermaid
+flowchart LR
+    A[应用 / sing-box] -->|TCP 出站| B[系统 nat OUTPUT REDIRECT]
+    A -->|UDP 出站| B
+    B --> C[Transparent Edge :19080]
+    C --> D1[Direct TCP 副本 1..N]
+    C --> D2[Relay TCP 路径 1..N]
+    C --> D3[Direct UDP 副本 1..N]
+    C --> D4[Relay UDP 路径 1..N]
+    D1 --> E1[TCP winner]
+    D2 --> E1
+    D3 --> E2[UDP winner]
+    D4 --> E2
+    E1 --> F[目标站点]
+    E2 --> F
+```
 
 ## 配置文件
 
@@ -38,9 +82,16 @@
   "direct_redundancy": 2,
   "direct_max_redundancy": 3,
   "direct_redundancy_v6": 3,
+  "tcp_warmup_initial_bytes": 131072,
   "tcp_warmup_bytes": 1048576,
   "tcp_loser_grace_ms": 1500,
   "probe_interval": 15,
+  "udp_redundancy": 1,
+  "udp_direct_redundancy": 2,
+  "udp_always_broadcast": true,
+  "udp_copy_interval_ms": 8,
+  "socks_host": "127.0.0.1",
+  "socks_port": 19180,
   "relays": [
     {"name": "hk1", "host": "1.2.3.4", "port": 9009, "token": "demo", "weight": 100},
     {"name": "jp1", "host": "5.6.7.8", "port": 9009, "token": "demo", "weight": 95}
@@ -48,37 +99,85 @@
 }
 ```
 
-说明
+### warmup 说明
 
-- `relays` 可以为空,空时只走当前主 VPS 直接出站
-- 子节点启动后会自动连上并参与竞速
-- 子节点没启动时不会影响使用
+- `tcp_warmup_initial_bytes`:启动时的较小起步值
+- `tcp_warmup_bytes`:稳定后允许抬升到的上限值
+- 系统会根据目标、地址族和历史胜率,把 warmup 从起步值逐步抬高到上限
+- 起步阶段更偏向快速首开,稳定后更偏向吞吐和抗抖动
 
-## 子节点一键安装
+## 透明 TCP
 
-把项目目录复制到子节点后,直接执行
+透明 TCP 会对每条连接同时拉起
 
-```bash
-sudo /home/mynetspeeder/scripts/install.sh /opt/mynetspeeder
-```
+- 当前主机直连路径
+- 若干在线 relay 路径
+- 多个 direct 冗余副本(由 `direct_redundancy` / `direct_max_redundancy` 控制)
 
-安装完成后可直接使用全局命令
+行为规则
 
-```bash
-mynetspeeder --help
-```
+- 前 `tcp_warmup_initial_bytes` 到 `tcp_warmup_bytes` 之间,上行数据会更积极地发给所有候选路径
+- 谁先回下行数据,谁成为 winner
+- loser 会再保留 `tcp_loser_grace_ms` 毫秒后关闭
+- 某目标或某地址族如果长期更偏向 relay,主节点会自动把 direct 冗余再提一档
 
-子节点启动 relay:
+## 透明 UDP
 
-```bash
-sudo /opt/mynetspeeder/scripts/start-relay.sh 你的token
-```
+透明 UDP 采用并行竞速架构,需要通过 `--enable-udp` 显式开启。
 
-子节点停止 relay
+但要注意一个优先级规则:
 
-```bash
-sudo /opt/mynetspeeder/scripts/stop-relay.sh
-```
+- 若 `config.json` 中 `socks_port > 0`,`start-transparent.sh` 会输出 `udp transparent capture disabled: socks5 is enabled, UDP will use socks only`
+- 也就是说,`--enable-udp` 在这种情况下不会真正打开 UDP 透明接管
+- 因此“开启了 socks 的配置”与“UDP 透明接管”在同一次启动里默认不是并行共存关系,而是前者覆盖后者
+
+当前实现方式:
+
+- 系统 UDP 包先进入 `edge` 的 UDP listener
+- 每个 `(source, target)` 流会同时建立本机 direct UDP 路径和在线 relay UDP 路径
+- `udp_redundancy` 控制同一包的额外重复次数
+- `udp_direct_redundancy` / `_v4` / `_v6` 控制 direct UDP 并发副本数
+- `udp_always_broadcast=true` 时,即使已有 winner,后续包仍持续并发发送
+- 首个有效下行回复会成为 winner,后续重复包按 winner 规则收敛
+
+适合场景:
+
+- 视频 / 直播 / 游戏里需要 QUIC/UDP 的流量
+- 希望和 TCP 一样走多路径并行竞速的场景
+
+## 显式 SOCKS5 UDP 入口
+
+`socks` 命令用于显式交付 UDP/QUIC,不是普通 TCP CONNECT 代理。
+
+当前行为:
+
+- 支持 `UDP ASSOCIATE`
+- `TCP CONNECT` 目前是禁用的
+- 适合让支持手动代理配置的应用,把 UDP 流量显式交给 `mynetspeeder`
+
+如果 `config.json` 里设置了 `socks_port > 0`,`start-transparent.sh` 会自动把它一起拉起来;此时若同时开启 `--enable-udp`,脚本会优先让 UDP 走 SOCKS 显式入口,并关闭 UDP 透明重定向。
+
+## 哪种组合效能最好
+
+结论先说:
+
+- **默认最优组合**:`TCP 透明接管` + `UDP SOCKS 显式入口`
+- **仅在应用根本无法配置 SOCKS UDP 时**,才退回 `TCP 透明接管` + `UDP 透明接管`
+
+原因如下:
+
+- `TCP` 天然适合透明接管:系统层无感接入,不需要改应用配置,且本项目的 TCP warmup、direct 冗余、relay 竞速都已经围绕透明模式做了完整优化
+- `UDP/QUIC` 更适合显式 SOCKS:应用主动把目标地址和 UDP 数据交给 `socks`,路径更直接,状态更清晰,避免透明重定向场景里对 `SO_ORIGINAL_DST`、conntrack、自环规避、NAT 回包等额外依赖
+- `UDP` 对时延抖动和状态保持更敏感,显式入口通常比透明重定向更稳定,尤其在 QUIC、游戏、实时音视频这类长流场景
+- 当前项目里 `socks` UDP 并不是“单路径代理”,它内部同样会做 `direct UDP + relay UDP` 并行竞速,所以不会损失 UDP 加速能力
+- 透明 UDP 的主要优势是“应用零改动”,不是“性能一定更高”;在可控环境下,它更多是兼容方案而不是首选方案
+
+建议按场景选择:
+
+- 你的应用或 `sing-box` 能明确把 UDP/QUIC 指到 `127.0.0.1:19180`:优先选 `TCP 透明 + UDP SOCKS`
+- 应用完全不能单独配置 UDP 代理:选 `TCP 透明 + UDP 透明`
+- 如果 UDP 目标以短包、低时延、长时间会话为主:更建议 `UDP SOCKS`
+- 如果只是想“全局无感接管所有协议”:才考虑透明 UDP,但要接受稳定性和可维护性略差一些
 
 ## 启动方式
 
@@ -102,7 +201,15 @@ cp /home/mynetspeeder/demo-config.json /home/mynetspeeder/config.json
 sudo /home/mynetspeeder/scripts/start-transparent.sh --kernel auto --capture-uid <sing-box运行UID> /home/mynetspeeder/config.json
 ```
 
-不指定 `--capture-uid` 时,默认接管所有用户发起的流量:
+如果要同时开启透明 UDP:
+
+```bash
+sudo /home/mynetspeeder/scripts/start-transparent.sh --enable-udp --capture-uid <sing-box运行UID> /home/mynetspeeder/config.json
+```
+
+注意:如果 `socks_port` 已开启,脚本会把 UDP 优先交给 SOCKS 显式入口,而不是再做透明 UDP 重定向;此时实际生效的是 `TCP 透明 + UDP SOCKS`。
+
+不指定 `--capture-uid` 时,会接管所有用户发起的流量:
 
 ```bash
 sudo /home/mynetspeeder/scripts/start-transparent.sh /home/mynetspeeder/config.json
@@ -120,207 +227,93 @@ sudo /home/mynetspeeder/scripts/stop-transparent.sh
 python3 -m mynetspeeder probe --config /home/mynetspeeder/config.json --once
 ```
 
-汇总透明模式胜率:
+汇总日志胜率:
 
 ```bash
 python3 -m mynetspeeder summary --log-file /var/log/mynetspeeder-edge.log
 ```
 
+## 安装子节点
 
-## Ubuntu 20 / 24 内核模式
-
-当前版本新增 `--kernel auto|20|24`:
-
-- `auto`:默认,根据系统自动判断
-- `20`:更保守的兼容模式
-- `24`:更积极的 Ubuntu 24 优化模式
-
-Ubuntu 24 模式会额外:
-
-- 打印 `iptables` 后端类型(`nf_tables` / `legacy`)
-- 启动后执行规则自检
-- 自动缩短 direct / relay 建连超时
-- 启用更积极的 `happy eyeballs` 建连参数
-
-如需手动指定:
+把项目目录复制到子节点后:
 
 ```bash
-sudo /home/mynetspeeder/scripts/start-transparent.sh --kernel 24 --capture-uid $(id -u singbox) /home/mynetspeeder/config.json
+sudo /home/mynetspeeder/scripts/install.sh /opt/mynetspeeder
 ```
 
-配置文件还支持
+安装完成后可直接用:
 
-```json
-{
-  "kernel_mode": "auto",
-  "direct_open_timeout": 6.0,
-  "relay_open_timeout": 6.0,
-  "tcp_connect_happy_eyeballs_delay": 0.25,
-  "relay_reconnect_delay": 3.0,
-  "relay_tcp_nodelay": true
-}
+```bash
+mynetspeeder --help
 ```
 
+启动 relay:
 
-## 单机增强 direct 竞速
-
-当前版本新增更激进的主节点 `direct` 冗余能力,默认保持 relay 逻辑不变:
-
-- `direct_redundancy`:默认 direct 并发连接数
-- `direct_max_redundancy`:当某目标或某地址族近期更偏向 relay 胜出时,允许自动放大到的最大 direct 并发数
-- `direct_redundancy_v4`:可单独指定 IPv4 目标的 direct 并发数
-- `direct_redundancy_v6`:可单独指定 IPv6 目标的 direct 并发数
+```bash
+sudo /opt/mynetspeeder/scripts/start-relay.sh 你的token
+```
 
-示例
+停止 relay:
 
-```json
-{
-  "direct_redundancy": 2,
-  "direct_max_redundancy": 3,
-  "direct_redundancy_v4": 2,
-  "direct_redundancy_v6": 3
-}
+```bash
+sudo /opt/mynetspeeder/scripts/stop-relay.sh
 ```
 
-说明:
+## 内核模式
 
-- 同一目标会同时发起多条 `direct` 连接
-- 谁先拿到有效下行,谁成为 winner
-- 其他 `direct` 副本会按现有 `tcp_loser_grace_ms` 延迟关闭
-- 若某个目标或某个地址族近期经常是 relay 胜出,主节点会自动把该目标的 direct 冗余数提升 1 档,但不会超过 `direct_max_redundancy`
-- 当前优化只增强主节点 direct 路径,不改变子节点 relay 内部出站逻辑
+`--kernel auto|20|24` 会影响透明模式下的建连策略:
 
-## 工作方式
+- `auto`:自动判断
+- `20`:更保守
+- `24`:更积极,通常会更短的 direct / relay 建连超时,并启用更激进的 happy eyeballs 参数
 
-透明模式启动后:
+手动指定示例
 
-- 不需要改 `sing-box` 的 `direct` 出站配置
-- `iptables` 会把系统的 TCP 出站重定向到 `mynetspeeder edge`
-- `edge` 会同时尝试:
-  - 当前主 VPS 直接连目标
-  - 若干在线子节点 relay 代为连接目标
-- 前 `tcp_warmup_bytes` 的上行数据会更积极地并发发给所有候选路径
-- winner 出现后,loser 会额外保留 `tcp_loser_grace_ms` 毫秒,再关闭
-- 谁先回下行数据,谁成为胜出路径
-- 其它路径会关闭
+```bash
+sudo /home/mynetspeeder/scripts/start-transparent.sh --kernel 24 --capture-uid $(id -u singbox) /home/mynetspeeder/config.json
+```
 
-## 一键脚本说明
+## 一键脚本做了什么
 
 `start-transparent.sh` 会自动:
 
-- 只接管指定 UID 的 TCP 出站,避免影响整机连接
-
 - 创建运行用户 `mynetspeeder`
 - 以该用户启动 `edge`
+- 只接管指定 UID 的 TCP 出站,避免影响整机连接
 - 自动排除:
   - `127.0.0.0/8`
+  - `169.254.0.0/16`
   - 所有 relay IP
   - `mynetspeeder` 自己发起的连接
-- 接管全局 `OUTPUT` 的 TCP 出站流量
-
-## 现有限制
-
-- 透明接管当前只支持 IPv4 TCP
-- UDP 默认关闭,需要显式开启
-- `iptables` 规则是全局级别,除 `mynetspeeder` 自己和 relay IP 外,其他本机 TCP 流量也会被接管
+- 在 `--enable-udp` 时额外启用 UDP 透明接管,并放行 `ESTABLISHED,RELATED` 回包
 
+## 当前限制
 
-## 当前服务器用法
+- 透明 TCP / UDP 都是基于系统层重定向实现,规则范围较大
+- UDP 自环通过 listener 侧过滤和脚本规则一起规避,但仍建议先从小流量场景验证
+- `socks` 模式目前只做 UDP 显式入口,不提供 TCP CONNECT
 
-当前服务器已将 `sing-box` 改为 `singbox` 用户运行,因此透明接管请直接使用:
-
-```bash
-sudo /home/mynetspeeder/scripts/start-transparent.sh --capture-uid $(id -u singbox) /home/mynetspeeder/config.json
-```
-
-停止:
-
-```bash
-sudo /home/mynetspeeder/scripts/stop-transparent.sh
-```
+## 配置建议
 
+推荐使用下面这类思路:
 
-## IPv6 与 UDP
+- 保留 `socks_port: 19180`
+- 平时只把 `TCP` 交给 `start-transparent.sh`
+- 需要 UDP/QUIC 加速的应用,显式配置到本机 `SOCKS5 UDP` 入口
+- 不要把“已开启 `socks_port`”的配置再理解成“UDP 透明也同时生效”
 
-当前版本新增:
+如果你要强制使用透明 UDP:
 
-- IPv6 透明 TCP 接管
-- UDP 透明接管(默认关闭)
-- `iptables` + `ip6tables` 的 TCP REDIRECT 规则,UDP 仅在显式开启时生效
+- 把 `socks_port` 设为 `0`
+- 再使用 `--enable-udp` 启动
+- 这时实际才是 `TCP 透明 + UDP 透明`
 
-说明:
-
-- UDP 只有在显式开启时才会接管
-- 如遇特定 UDP/QUIC 场景异常,优先先验证 TCP 是否正常
-
-
-## 当前状态
+## 运行状态
 
 当前版本已支持:
 
 - `singbox` 用户的透明 TCP 接管
-- IPv4 / IPv6 TCP 透明监听
-- IPv4 / IPv6 UDP 透明监听(默认关闭)
-- 无 relay 时自动只走当前主机 `direct`
-
-推荐启动命令:
-
-```bash
-sudo /home/mynetspeeder/scripts/start-transparent.sh --capture-uid 996 /home/mynetspeeder/config.json
-```
-
-
-## UDP 说明
-
-当前已发现 UDP 透明接管在部分环境下会产生自环(例如日志里目标变成 `127.0.0.1:19080`)。
-
-因此当前默认:
-
-- 只启用 TCP 透明接管
-- UDP 透明接管默认关闭
-
-如果你要实验 UDP,再显式加:
-
-```bash
-sudo /home/mynetspeeder/scripts/start-transparent.sh --enable-udp --capture-uid 996 /home/mynetspeeder/config.json
-```
-
-当前版本新增更激进的 UDP 冗余参数:
-
-```json
-{
-  "udp_redundancy": 1,
-  "udp_direct_redundancy": 2,
-  "udp_direct_redundancy_v4": 2,
-  "udp_direct_redundancy_v6": 2,
-  "udp_always_broadcast": true,
-  "udp_copy_interval_ms": 8
-}
-```
-
-说明:
-
-- `udp_redundancy`:每个 UDP 包额外重复发送的次数
-- `udp_direct_redundancy`:UDP 默认本地 direct 并发副本数
-- `udp_direct_redundancy_v4`:可单独指定 IPv4 目标的 UDP direct 副本数
-- `udp_direct_redundancy_v6`:可单独指定 IPv6 目标的 UDP direct 副本数
-- `udp_always_broadcast`:即使已有 winner,后续包仍持续并发发往所有可用路径
-- `udp_copy_interval_ms`:多副本之间的间隔,单位毫秒
-
-默认策略更偏向抗丢包和稳态可用,而不是节省流量。
-
-如果你希望同时启用本机显式 SOCKS5 出站入口,只需要在 `config.json` 增加端口:
-
-```json
-{
-  "socks_host": "127.0.0.1",
-  "socks_port": 19180
-}
-```
-
-说明:
-
-- `socks_port` 为 `0` 或不填:不启动
-- `socks_port` 大于 `0`:`start-transparent.sh` 会自动一并启动
-- 适合让 `sing-box` 把指定 UDP/QUIC 流量显式交给 `mynetspeeder`
-- 不需要额外手动执行单独脚本
+- IPv4 / IPv6 透明监听
+- 可选透明 UDP 并行竞速
+- 可选显式 SOCKS5 UDP 入口
+- 无 relay 时自动只走本机 `direct`

+ 17 - 9
config.json

@@ -1,17 +1,25 @@
 {
   "strategy": "top3",
-  "redundancy": 3,
-  "direct_redundancy": 3,
+  "redundancy": 1,
+  "direct_redundancy": 2,
   "direct_max_redundancy": 3,
-  "direct_redundancy_v6": 3,
-  "udp_direct_redundancy": 3,
-  "tcp_warmup_bytes": 2097152,
-  "tcp_loser_grace_ms": 1500,
-  "probe_interval": 3,
-  "relay_reconnect_delay": 1,
+  "direct_redundancy_v4": 2,
+  "direct_redundancy_v6": 2,
+  "tcp_warmup_initial_bytes": 131072,
+  "tcp_warmup_bytes": 1048576,
+  "tcp_loser_grace_ms": 800,
+  "probe_interval": 15,
+  "relay_reconnect_delay": 3,
   "relay_reconnect_max_delay": 10,
+  "udp_redundancy": 0,
+  "udp_direct_redundancy": 2,
+  "udp_direct_redundancy_v4": 2,
+  "udp_direct_redundancy_v6": 2,
+  "udp_always_broadcast": true,
+  "udp_copy_interval_ms": 8,
   "socks_host": "127.0.0.1",
   "socks_port": 19180,
   "relays": [
-  ] 
+    {"name": "hk1", "host": "23.95.134.159", "port": 9009, "token": "130", "weight": 100}
+  ]
 }

+ 2 - 0
config.py

@@ -24,6 +24,7 @@ class Config:
     strategy: Strategy = "top3"
     kernel_mode: KernelMode = "auto"
     redundancy: int = 3
+    tcp_warmup_initial_bytes: int = 131072
     tcp_warmup_bytes: int = 1048576
     probe_interval: float = 15.0
     tcp_loser_grace_ms: int = 1500
@@ -58,6 +59,7 @@ class Config:
             strategy=raw.get("strategy", "top3"),
             kernel_mode=raw.get("kernel_mode", "auto"),
             redundancy=raw.get("redundancy", 3),
+            tcp_warmup_initial_bytes=max(0, raw.get("tcp_warmup_initial_bytes", 131072)),
             tcp_warmup_bytes=raw.get("tcp_warmup_bytes", 1048576),
             probe_interval=raw.get("probe_interval", 15.0),
             tcp_loser_grace_ms=raw.get("tcp_loser_grace_ms", 1500),

+ 4 - 1
relay_client.py

@@ -9,7 +9,7 @@ import time
 from typing import Awaitable, Callable, Dict
 
 from .config import Config, RelayNode
-from .protocol import AUTH, PING, PONG, STATUS_OK, TCP_CLOSE, Frame, encode_json, read_frame, write_frame
+from .protocol import AUTH, PING, PONG, STATUS_OK, TCP_CLOSE, TCP_STATUS, Frame, encode_json, read_frame, write_frame
 from .scheduler import Scheduler
 
 FrameHandler = Callable[["RelayConnection", Frame], Awaitable[None]]
@@ -78,6 +78,9 @@ class RelayConnection:
                 if frame.kind == PONG:
                     self.last_pong_at = time.monotonic()
                     continue
+                if frame.kind == TCP_STATUS and frame.packet_id != STATUS_OK:
+                    detail = frame.payload.decode("utf-8", errors="replace") if frame.payload else "unknown"
+                    print(f"[edge] relay status error name={self.node.name} session={frame.session_id} stream={frame.stream_id} detail={detail}")
                 handler = self.handlers.get((frame.session_id, frame.stream_id))
                 if handler:
                     self._dispatch_frame(frame, handler)

+ 14 - 0
relay_server.py

@@ -140,14 +140,24 @@ class RelayChannel:
                     meta = decode_json(frame.payload[: frame.packet_id])
                     payload = frame.payload[frame.packet_id :]
                 except Exception:
+                    print(
+                        f"[relay] udp meta decode failed session={frame.session_id} stream={frame.stream_id} "
+                        f"meta_len={frame.packet_id} payload_len={len(frame.payload)}"
+                    )
                     if session is None:
+                        await self.safe_send(Frame(TCP_STATUS, frame.session_id, frame.stream_id, 0, STATUS_ERR, b"udp meta decode failed"))
                         return
                     payload = frame.payload
             if session is None:
                 if meta is None:
+                    await self.safe_send(Frame(TCP_STATUS, frame.session_id, frame.stream_id, 0, STATUS_ERR, b"udp meta missing"))
                     return
                 try:
                     family = int(meta.get("family", 0)) or 0
+                    print(
+                        f"[relay] udp session open session={frame.session_id} stream={frame.stream_id} "
+                        f"target={meta.get('host')}:{meta.get('port')} family={family} meta_len={frame.packet_id} payload_len={len(payload)}"
+                    )
                     transport, protocol = await asyncio.get_running_loop().create_datagram_endpoint(
                         lambda: RelayUdpProtocol(self, frame.session_id, frame.stream_id),
                         remote_addr=(meta["host"], int(meta["port"])),
@@ -156,6 +166,10 @@ class RelayChannel:
                     session = UdpSession(frame.session_id, frame.stream_id, transport, protocol, meta["host"], int(meta["port"]), family)
                     self.udp_sessions[key] = session
                 except Exception as exc:
+                    print(
+                        f"[relay] udp session open failed session={frame.session_id} stream={frame.stream_id} "
+                        f"meta={meta!r} error={exc!r}"
+                    )
                     await self.safe_send(Frame(TCP_STATUS, frame.session_id, frame.stream_id, 0, STATUS_ERR, str(exc).encode()))
                     return
             if session.transport is not None:

+ 25 - 2
socks_edge.py

@@ -101,6 +101,7 @@ class UdpFlowState:
     direct_failures: set[str] = field(default_factory=set)
     relay_failures: dict[str, int] = field(default_factory=dict)
     relay_error_seen: set[str] = field(default_factory=set)
+    target_family: int = 0
 
     def touch(self, now: float) -> None:
         self.last_activity = now
@@ -115,7 +116,9 @@ class TcpRaceSession:
     local_reader: asyncio.StreamReader
     local_writer: asyncio.StreamWriter
     links: list[RelayLink]
-    warmup_bytes: int
+    warmup_initial_bytes: int
+    warmup_max_bytes: int
+    stats: Dict[str, int]
     winning_link: RelayLink | None = None
     winner_name: str | None = None
     opened: int = 0
@@ -125,10 +128,27 @@ class TcpRaceSession:
     open_event: asyncio.Event = field(default_factory=asyncio.Event)
     winner_event: asyncio.Event = field(default_factory=asyncio.Event)
     pump_task: asyncio.Task | None = None
+    warmup_bytes: int = 0
     win_counts: Dict[str, int] = field(default_factory=dict)
 
+    @staticmethod
+    def _stability_score(stats: Dict[str, int]) -> float:
+        total = sum(stats.values())
+        if total < 2:
+            return 0.0
+        dominant = max(stats.values())
+        confidence = min(1.0, total / 20.0)
+        return max(0.0, ((dominant / total) - 0.5) * 2.0 * confidence)
+
+    def _effective_warmup_bytes(self) -> int:
+        low = max(0, self.warmup_initial_bytes)
+        high = max(low, self.warmup_max_bytes)
+        score = self._stability_score(self.stats)
+        return low + int((high - low) * score)
+
     async def start(self) -> None:
         meta = encode_json({"host": self.target_host, "port": self.target_port})
+        self.warmup_bytes = self._effective_warmup_bytes()
         for link in self.links:
             link.tcp_sessions[(self.session_id, self.stream_id)] = self
             await link.send(Frame(TCP_OPEN, self.session_id, self.stream_id, 0, 0, meta))
@@ -239,6 +259,7 @@ class UdpAssociateServer(asyncio.DatagramProtocol):
             print(f"[edge] udp client rebound old={self.client_addr[0]}:{self.client_addr[1]} new={addr[0]}:{addr[1]}")
             self._reset_client_state(addr)
         host, port, payload = self._parse_socks_udp(data)
+        family = socket.AF_INET6 if ":" in host else socket.AF_INET
         loop = asyncio.get_running_loop()
         now = loop.time()
         flow_key = ((addr[0], addr[1]), host, port)
@@ -249,6 +270,7 @@ class UdpAssociateServer(asyncio.DatagramProtocol):
                 client_addr=(addr[0], addr[1]),
                 target_host=host,
                 target_port=port,
+                target_family=family,
                 created_at=now,
                 last_activity=now,
             )
@@ -379,6 +401,7 @@ class SocksEdge:
         self.udp_stream_ids = itertools.count(1)
         self.udp_flow_sessions: dict[tuple[int, int], UdpFlowState] = {}
         self.udp_server: UdpAssociateServer | None = None
+        self.tcp_win_counts: Dict[str, int] = {}
 
     async def start(self) -> None:
         await self.scheduler.start()
@@ -486,7 +509,7 @@ class SocksEdge:
 
     async def forward_udp(self, flow: UdpFlowState, payload: bytes, packet_id: int, udp_server: UdpAssociateServer) -> None:
         await self._ensure_udp_direct_paths(flow, udp_server)
-        meta = encode_json({"host": flow.target_host, "port": flow.target_port})
+        meta = encode_json({"host": flow.target_host, "port": flow.target_port, "family": flow.target_family})
         links = self._selected_udp_links()
         direct_names = tuple(name for name in sorted(flow.direct_sockets))
         relay_names = tuple(link.node.name for link in links)

+ 43 - 4
transparent_edge.py

@@ -200,7 +200,8 @@ class TransparentSession:
     reader: asyncio.StreamReader
     writer: asyncio.StreamWriter
     paths: list[BasePath]
-    warmup_bytes: int
+    warmup_initial_bytes: int
+    warmup_max_bytes: int
     loser_grace_ms: int
     stats: dict[str, int]
     target_stats: dict[tuple[str, int], dict[str, int]]
@@ -215,6 +216,27 @@ class TransparentSession:
     closed: bool = False
     pump_task: asyncio.Task | None = None
     loser_close_task: asyncio.Task | None = None
+    warmup_bytes: int = 0
+    open_tasks: list[asyncio.Task] = field(default_factory=list)
+
+    @staticmethod
+    def _stability_score(stats: dict[str, int]) -> float:
+        total = sum(stats.values())
+        if total < 2:
+            return 0.0
+        dominant = max(stats.values())
+        confidence = min(1.0, total / 20.0)
+        return max(0.0, ((dominant / total) - 0.5) * 2.0 * confidence)
+
+    def _effective_warmup_bytes(self) -> int:
+        low = max(0, self.warmup_initial_bytes)
+        high = max(low, self.warmup_max_bytes)
+        key = (self.target.host, self.target.port)
+        target_stats = self.target_stats.get(key, {})
+        family_key = "ipv6" if self.target.family == socket.AF_INET6 else "ipv4"
+        family_stats = self.family_stats.get(family_key, {})
+        score = max(self._stability_score(self.stats), self._stability_score(target_stats), self._stability_score(family_stats))
+        return low + int((high - low) * score)
 
     def _record_win(self, winner: BasePath) -> None:
         self.stats[winner.name] = self.stats.get(winner.name, 0) + 1
@@ -237,8 +259,17 @@ class TransparentSession:
         print(f"[edge] tcp win session={self.session_id} target={self.target.host}:{self.target.port} winner={winner.name} direct={direct_wins} relay={relay_wins} relay_breakdown={relay_detail} target_pref={target_pref} target_direct={target_direct} target_relay={target_relay} target_breakdown={target_detail} family_pref={family_pref} family={family_key} family_direct={family_direct} family_relay={family_relay}")
 
     async def start(self) -> None:
-        await asyncio.gather(*(path.open(self.target) for path in self.paths), return_exceptions=True)
-        await asyncio.wait_for(self.open_event.wait(), timeout=15)
+        self.warmup_bytes = self._effective_warmup_bytes()
+        self.open_tasks = [asyncio.create_task(path.open(self.target)) for path in self.paths]
+        try:
+            await asyncio.wait_for(self.open_event.wait(), timeout=15)
+        except Exception:
+            for task in self.open_tasks:
+                if not task.done():
+                    task.cancel()
+            await asyncio.gather(*self.open_tasks, return_exceptions=True)
+            self.open_tasks.clear()
+            raise
         if self.opened_count == 0:
             raise ConnectionError(self.errors[0] if self.errors else "all paths failed")
         self.pump_task = asyncio.create_task(self._pump_local())
@@ -320,6 +351,12 @@ class TransparentSession:
             self.loser_close_task.cancel()
             with contextlib.suppress(Exception):
                 await self.loser_close_task
+        pending_open_tasks = [task for task in self.open_tasks if task is not asyncio.current_task() and not task.done()]
+        for task in pending_open_tasks:
+            task.cancel()
+        if pending_open_tasks:
+            with contextlib.suppress(Exception):
+                await asyncio.gather(*pending_open_tasks, return_exceptions=True)
         await asyncio.gather(*(path.close() for path in self.paths), return_exceptions=True)
         self.writer.close()
         with contextlib.suppress(Exception):
@@ -401,6 +438,8 @@ class RelayUdpPath(BasePath):
     async def _handle_frame(self, _conn: RelayConnection, frame: Frame) -> None:
         if frame.kind == UDP_RECV:
             await self.on_frame(self, "data", frame.payload)
+        elif frame.kind == TCP_STATUS and frame.packet_id != STATUS_OK:
+            await self.on_frame(self, "status", frame.payload or b"udp relay error")
 
     async def send(self, data: bytes) -> None:
         if self.closed or self.connection.closed:
@@ -730,7 +769,7 @@ class TransparentEdge:
         try:
             target = self._get_original_dst(writer)
             session_id = next(self.session_ids)
-            session = TransparentSession(session_id=session_id, target=target, reader=reader, writer=writer, paths=[], warmup_bytes=self.config.tcp_warmup_bytes, loser_grace_ms=self.config.tcp_loser_grace_ms, stats=self.tcp_win_counts, target_stats=self.tcp_target_wins, family_stats=self.tcp_family_wins)
+            session = TransparentSession(session_id=session_id, target=target, reader=reader, writer=writer, paths=[], warmup_initial_bytes=self.config.tcp_warmup_initial_bytes, warmup_max_bytes=self.config.tcp_warmup_bytes, loser_grace_ms=self.config.tcp_loser_grace_ms, stats=self.tcp_win_counts, target_stats=self.tcp_target_wins, family_stats=self.tcp_family_wins)
             paths: list[BasePath] = self._build_direct_paths(session)
             for connection in self.manager.available():
                 stream_id = next(self.stream_ids)