libuvcpp 上游问题与 PR 方案¶
状态(2026-10-21):libuvcpp
v1.8.2已发布,uvscope 已切换到发布版 SDK,patches/upstream/下的 0001–0004 已删除。1.8.1 收进了 SNI、POSIX 同步 I/O 超时与 install 完整性;1.8.2 收进了 SOCKS5 会话统计、TLS 证书链、AF_UNSPEC、总 deadline、 WebSocket 逐帧、HTTP 流式响应、h2 逐帧事件与 h3/QUIC 事件。本文保留为问题复现与验证的 历史记录,不再描述当前构建方式。
基线:Antruly/libuvcpp v1.8.0(提交 13685c1)。
本文只记录在当前仓库与机器上复现过的问题、对应补丁和验证方式。没有复现证据的
内容标为“待验证”,不写成结论。
历史上补丁位于 patches/upstream/,按文件名顺序应用(现已随 1.8.2 发布移除):
| 补丁 | 主题 | 去向 |
|---|---|---|
0001-tls-sni.patch |
TLS SNI;IP 字面量不发 SNI | 已包含于 v1.8.1 |
0002-sync-io-timeouts.patch |
POSIX 同步 I/O 超时;超时/EOF 区分;失败状态码 | 已包含于 v1.8.1 |
0003-install-complete.patch |
cmake --install 产出可用的 SDK |
已包含于 v1.8.1 |
0004-socks5-proxy-stage.patch |
区分"连代理失败"与代理应答后的失败(PR #42) | 已包含于 v1.8.2 |
切换后 scripts/build-libuvcpp.sh 只克隆发布 tag,再应用 patches/upstream/0*.patch;CMake 在
检测到低于 1.8.2 的 SDK 时直接失败。验证方式仍是 --self-check 与 scripts/smoke-test.sh。
1.8.2 上继续携带的补丁¶
0005 — WebSocket 客户端握手 Header¶
现象:后端 WebSocket 调试需要发送 Authorization、Cookie、Origin 或
Sec-WebSocket-Protocol。1.8.2 的 uvcpp_ws_client 只生成固定握手头(Host / Upgrade /
Connection / Key / Version / Extensions),没有公开的设置入口,这类目标无法连接。
补丁:0005-ws-client-handshake-headers.patch 新增
uvcpp_ws_client::set_handshake_header(name, value);拒绝 CR/LF 注入,忽略保留的握手头,
按名字大小写不敏感地覆盖重复项,并在 do_handshake() 组包时逐条追加。
验证:smoke 的 WebSocket echo 服务在缺少 X-Smoke: ok 时返回 403;带该头时握手成功,
文本回显、PING/PONG 帧、Close 1000 与删除清理均通过。上游若接受该 API,可删除此补丁。
已随 1.8.2 发布(原计划批次)¶
2026-10-20 按 uvscope 0.3 / 0.4 的需求提交了上游 issue 批次,跟踪页: #55。v1.8.2(2026-10-20 发布) 已包含下列全部改动:
| Issue | 主题 | uvscope 影响 | 状态 |
|---|---|---|---|
| #44 | 域名解析 AF_UNSPEC / IPv6 |
所有出站;IPv6-only 目标 | 1.8.2 已发布 |
| #45 | peer 证书链与校验错误 | 0.4 TLS 实时证书检查 | 1.8.2 已发布 |
| #46 | SOCKS5 会话结束 / 字节数 | 0.3 服务端页面完整性 | 1.8.2 已发布 |
| #47 | 客户端 br 解压 / 阈值 | 0.4 body 解码 | 1.8.2 已发布 |
| #48 | 同步 *_wait 总 deadline |
0.3 timeoutMs 契约 |
1.8.2 已发布 |
| #49 | UDP 域名解析 / IPv6 connect | 0.3 UDP 探测 | 1.8.2 已发布 |
| #54 | ws client 的 SOCKS5 阶段 | 0.4 WS 代理错误分类 | 1.8.2 已发布 |
同批发布的还有 #50 WebSocket 逐帧接口、#51 HTTP 客户端流式响应、#52 HTTP/2 帧事件、
53 HTTP/3 / QUIC 能力清单;它们是 0.4 后续切片的输入,uvscope 尚未接入。¶
0001–0004 补丁已随本次切换删除。
1. TLS SNI 缺失(P0,已修复)¶
现象¶
访问任何依赖 SNI 的 HTTPS 虚拟主机时,要么握手失败,要么拿到该 IP 的默认证书,
证书主机名校验随即失败。uvscope 0.1.0 的所有公网 HTTPS 调试都受影响。
根因¶
uvcpp_ssl 没有暴露 SSL_set_tlsext_host_name(),两条客户端路径都没有发送
server_name:
- 异步 memory-BIO 路径:
src/net/uvcpp_tcp_client.cpp的apply_tls_hostname()(v1.8.0 约 3263 行); - 同步阻塞路径:
src/web/uvcpp_http_client.cpp的do_ssl_handshake()(约 1091 行)。
同步路径自己 new uvcpp_ssl(ctx, fd),不经过 uvcpp_tcp_client::connect(),
所以两条路必须各接一次,不能只在其中一处修。
补丁¶
0001-tls-sni.patch:
- 新增
uvcpp_ssl::set_server_name(const char*, size_t); - 两条路径都在握手前调用;
- 按 RFC 6066 §3,IP 字面量不发送 SNI(
uv_ip4_addr/uv_ip6_addr判定), 避免部分服务端拒绝带 IP 的server_name;证书校验仍按原主机名进行。
验证¶
本地 TLS 服务端用 SSLContext.sni_callback 记录 server_name:
async localhost -> SNI=localhost
async 127.0.0.1 -> SNI=None
sync localhost -> SNI=localhost
sync 127.0.0.1 -> SNI=None
2. 同步 *_wait 在 POSIX 上忽略 timeout_ms(P0,已修复)¶
现象¶
connect_wait() / send_wait() / send_wait_ssl() 的 timeout_ms 只在 Windows
生效。Linux/macOS 上:
- 服务端收下请求后不响应:客户端一直等到对端关闭,然后把空响应当成功返回;
- TCP 已连接但对端不开始 TLS 握手:
do_ssl_handshake()无限阻塞。
这不是理论问题:uvscope 的稳定路径正是同步 HTTP/1.1,connect_wait/send_wait
的返回值被直接写进调试结果。
复现(修复前,timeout_ms = 1000)¶
服务端收下请求后静默 4 秒再关闭:
plain: RESULT rc=0 elapsed_ms=4000 status=200
tls: RESULT rc=0 elapsed_ms=4000 status=200
timeout_ms 被完全忽略;超时还被交付成 200 OK。
服务端只 accept、不做 TLS 握手,客户端 connect_wait(..., 3000):
$ timeout 6 ./timeout_test tls localhost <port>
exit=124 # 6 秒后仍在握手里
根因¶
send_wait_ssl()/send_wait_plain()里设置SO_RCVTIMEO/SO_SNDTIMEO的代码整段包在#ifdef _WIN32里;注释声称 POSIX 没有等价写法,但SO_RCVTIMEO/SO_SNDTIMEO在 Linux 和 macOS 上都可用。read_one_message()把读取返回的n <= 0一律当 EOF。即使超时真的发生, 半条响应也会被当成完整响应。uvcpp_ssl::read()用0同时表示close_notify和WANT_READ;同步路径 无法区分“对端正常关闭”和“超时”。- 握手失败后用一个
for (i < 4)的紧循环重试;在阻塞 socket 上每次重试都会 再等满一个timeout_ms,把 3 秒超时放大成 12 秒。 - 失败时
uvcpp_http_response保留默认的200 OK,调用方日志里会出现 “请求失败,但状态码 200”。
补丁¶
0002-sync-io-timeouts.patch:
- 两个平台都安装 socket 收发超时;
EAGAIN/EWOULDBLOCK/ETIMEDOUT映射为UV_ETIMEDOUT;read_one_message()返回负的 libuv 错误码,不再把错误当 EOF;- TLS 读取用
last_ssl_error() == SSL_ERROR_ZERO_RETURN区分正常关闭; - 删除握手重试循环(阻塞 socket 上 0 就是超时);
- 同步失败路径把
resp.status_code复位为HTTP_STATUS_NONE(0)。
验证(修复后)¶
plain silent: RESULT rc=-110 elapsed_ms=1024 status=0
tls silent: RESULT rc=-110 elapsed_ms=1037 status=0
handshake stall: connect_wait rc=-110
normal TLS: RESULT rc=0 elapsed_ms=0 status=200
-110 是 libuv 的 UV_ETIMEDOUT。
残留¶
SO_RCVTIMEO 是每次 I/O 的超时,不是整次请求的总 deadline。对端每隔
timeout_ms - ε 发一个字节,仍可以把请求拖住。后续可以把
timeout_ms 换成单调时钟 deadline,并在每次读写前用 poll() 截断等待时间。
这一点不阻塞当前补丁。
3. cmake --install 产出的前缀不可用(P1,已修复)¶
现象¶
package_release.py 手工组装的 release zip 可以使用,但从源码
cmake --install --prefix X 出来的前缀不行:
- Linux:
libuvcpp.so的DT_NEEDED里有libllhttp.so.9.2,前缀里没有;ldd显示not found; - Windows MinGW:
libuvcpp.dll的导入表依赖libllhttp.dll,前缀bin/里没有,程序以0xC0000135(STATUS_DLL_NOT_FOUND)静默退出; - 安装后的共享库没有 RPATH,即使把依赖手工拷到同一个
lib/也找不到; - 用 FetchContent 编 libuv 时,头装在
include/libuv/,但公开头写的是#include <uv.h>,导出的 CMake target 没有把这个目录传给消费者。
uvscope 的 scripts/build-libuvcpp.sh 之所以手工拷贝 libllhttp.dll,
就是在绕开这条 install 规则。
补丁¶
0003-install-complete.patch:
- 安装 FetchContent 依赖里所有
SHARED_LIBRARYtarget(llhttp、zlib、libuv),install(TARGETS ...)会一并生成 SONAME 符号链接; - 给
uvcpp/uvcpp_s设置INSTALL_RPATH: Linux$ORIGIN,macOS@loader_path; - 只在自己编译 libuv 时把
include/libuv写进INTERFACE_INCLUDE_DIRECTORIES(系统 libuv 不安装这个目录,写了会让find_package在 generate 阶段报 “includes non-existent path”); - 同一配置下在
uvcpp.pc的Cflags里补-I${includedir}/libuv。
验证¶
$ cmake --install build --prefix /tmp/uvcpp-install-pr
$ ls /tmp/uvcpp-install-pr/lib
libllhttp.so libllhttp.so.9.2 libllhttp.so.9.2.0 libuvcpp.so cmake pkgconfig
$ readelf -d /tmp/uvcpp-install-pr/lib/libuvcpp.so | grep RUNPATH
Library runpath: [$ORIGIN]
$ ldd /tmp/uvcpp-install-pr/lib/libuvcpp.so | grep llhttp
libllhttp.so.9.2 => /tmp/uvcpp-install-pr/lib/libllhttp.so.9.2
消费者:
find_package(uvcpp CONFIG REQUIRED)
target_link_libraries(consumer PRIVATE uvcpp::uvcpp)
$ cmake -S consumer -B build -DCMAKE_PREFIX_PATH=/tmp/uvcpp-install-pr
$ cmake --build build
$ LD_LIBRARY_PATH=/tmp/uvcpp-install-pr/lib ./build/consumer
uv=1.48.0 web=1
建议一并加的 CI¶
在 CI 里增加一个 install + consumer 冒烟格:安装到临时前缀,配置并运行一个
只包含 <uvcpp.h>、<net/uvcpp_tcp_client.h> 的消费者。这样 install 规则和
package_release.py 两条路径不会再次漂移。
3.1 SOCKS5 失败阶段不可区分(P1,已修复)¶
现象¶
配置 SOCKS5 代理后,socks5_last_error() 无法区分失败发生在哪一侧:
- 代理端口没人监听:
uv_strerror(ECONNREFUSED)→connection refused; - 代理已应答并拒绝 CONNECT:
reply_string(CONNECTION_REFUSED)也是connection refused。
uvscope 需要把错误分类为“代理不可达”和“目标拒绝”,两者不能混为一谈。
补丁¶
0004-socks5-proxy-stage.patch(上游 PR
#42,已合并):
uvcpp_tcp_client新增只读socks5_proxy_connect_failed();在异步/同步 的代理 TCP 连接、DNS 解析与连接超时路径置位,进入握手阶段前清零;uvcpp_http_client转发同名访问器。
验证¶
uvscope 的 scripts/smoke-test.sh 对三类失败做断言:
代理端口关闭 -> proxy.proxy_unreachable
代理应答后目标关闭 -> proxy.target_rejected
错误密码 -> proxy.auth_failed
同一目标经代理成功时,TCP / HTTP / UDP 三条客户端路径都能拿到目标响应。
4. 主机名解析只取 IPv4(P1,待实现)¶
事实¶
两条解析路径都写死 hints.ai_family = AF_INET:
- 异步:
src/net/uvcpp_tcp_client.cppconnect_transport()的getaddrinfo回调(v1.8.0 约 884 行); - 同步:同文件
resolve_host_sync()(约 1624 行)。
数字 IPv6 字面量有快速路径,但域名解析不会得到 AAAA。IPv6-only 主机直接 失败;双栈主机只会走 A。
建议方案¶
分两步,避免一次改动引入 Happy Eyeballs 的复杂度:
AF_UNSPEC+ 依次尝试返回的地址。异步路径需要为每次尝试准备一个新的uv_tcp_t(同一个 handle 在 connect 失败后重连的行为在不同 libuv 版本上 不一致,不能假设);同步路径把resolve_host_sync()改成返回地址列表,connect_wait()逐个尝试。- 再做 250ms 的 Happy Eyeballs(RFC 8305)并发/延迟竞速。没有第 1 步的
逐个尝试,不建议直接开
AF_UNSPEC:部分网络会先拿到 AAAA,但实际只有 IPv4 出口。
在完成 1 之前,不建议只把 AF_INET 改成 AF_UNSPEC 就发布。
5. 关于“异步 TLS / h2 不可用”的更正(已复测)¶
0.1.0 的开发记录里写过“异步 TLS 的 ALPN 为空、send() 返回 UV_EINVAL”。
在 v1.8.0 + SNI 上重新复测,没有复现:
- 用
-DUVCPP_ENABLE_NGHTTP2=ON构建 libuvcpp; - 本地 TLS 服务端
set_alpn_protocols(['h2']); set_http2_enabled(true)→connect()→send():
connect cb status=0 alpn=h2
send rc=0
server FIRSTBYTES= 505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
ALPN 协商为 h2,send() 返回 0,h2 客户端 preface 正常发出。服务端直接断开
时回调拿到 -125(UV_ECANCELED),这是预期路径。
UV_EINVAL 的真实来源是 on_tcp_data() 里的
parser_->has_error():HTTP/1.1 解析器收到了非 HTTP 字节。它对应的场景是
ALPN 没协商出 h2,但对端仍在发 h2 帧,属于对端协议错误或旧版客户端没发
SNI/ALPN,而不是“异步 TLS 一律不可用”。旧结论应撤回。
仍然成立的风险:
- HTTP/3 / QUIC 路径本次没有构建和复测;
- 没有在 Windows MinGW、macOS 和真实公网服务上跑同一套 ALPN 矩阵;
- 发布构建仍然关闭
NGHTTP2/HTTP3,在补齐跨平台 CI 之前不建议打开。
6. 优化项(不阻塞)¶
| 项 | 现状 | 建议 |
|---|---|---|
| 依赖构建时间 | 每个平台/每个 CI job 都从源码编 libuv、llhttp、zlib、nghttp2 | CI 缓存 patched libuvcpp,或用 FETCHCONTENT_SOURCE_DIR_* 指向缓存仓库 |
| 发布与安装漂移 | package_release.py 手工组装,cmake --install 是另一条路 |
加 install+consumer CI 格(见第 3 节) |
| 大块 SSL 写入 | uvcpp_ssl::write/read 把 size_t 强转 int |
超过 INT_MAX 时分块;当前 HTTP 请求不会触发,属防御性修复 |
| 同步路径总 deadline | 只有每次 I/O 超时 | 单调时钟 deadline + poll()(见第 2 节残留) |
| 错误响应状态码 | 已在 0002 修复为 HTTP_STATUS_NONE |
补一条断言,锁定“失败不得返回 200” |
7. 提交 PR 的建议顺序¶
每个补丁一个 PR,不要合并成一个大 PR:
fix(tls): send SNI for DNS host names— 0001(master 已包含;PR #38 仍为 open)fix(web): honor timeout_ms in blocking *_wait paths— 0002(master 已包含;PR #39 仍为 open)fix(build): make cmake --install produce a usable SDK— 0003(已合并,PR #40)fix(net): expose the SOCKS5 proxy-connect stage— 0004(PR #42,已合并)
建议的验证命令(每个 PR 都附上):
git clone --depth 1 --branch v1.8.0 https://github.com/Antruly/libuvcpp.git
cd libuvcpp
git apply /path/to/000x-*.patch
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DUVCPP_BUILD_WEB=ON -DUVCPP_BUILD_WEBAPP=ON -DUVCPP_BUILD_NET=ON \
-DUVCPP_ENABLE_OPENSSL=ON -DUVCPP_ENABLE_ZLIB=ON \
-DUVCPP_ENABLE_NGHTTP2=OFF -DUVCPP_ENABLE_QUIC=OFF -DUVCPP_ENABLE_HTTP3=OFF \
-DUVCPP_BUILD_TESTS=OFF
cmake --build build -j
cmake --install build --prefix /tmp/uvcpp-prefix
0002 的最小回归用例(本地静默 TLS 服务端 + send_wait(..., 1000)):
- 服务端不响应:
rc == UV_ETIMEDOUT,耗时约 1 秒,resp.status_code == 0; - 服务端正常响应:
rc == 0,resp.status_code == 200; - 服务端只 accept 不握手:
connect_wait(..., 3000)在约 3 秒返回UV_ETIMEDOUT,不得无限阻塞。
0003 的最小回归用例:安装到临时前缀后,配置并运行一个
find_package(uvcpp CONFIG) 消费者;Linux 上 ldd 不得出现 not found。