Skip to content

fix(ssl): 加载完整证书链,并让 TLS 版本上下限双向生效 - #1

Merged
Antruly merged 3 commits into
Antruly:masterfrom
sercebr:fix/ssl-cert-chain-and-version-bounds
Sep 19, 2026
Merged

Antruly merged 3 commits into
Antruly:masterfrom
sercebr:fix/ssl-cert-chain-and-version-bounds

Conversation

@sercebr

@sercebr sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

背景

在对 uvcpp_ssl_context 做一轮 TLS 专项测试时发现两个**"配置看着设上了、实际没起作用"的缺陷。两者都不影响启动、不影响编译、不影响自签证书下跑通 demo —— 只有在真实的证书链和真实的版本要求**下才会暴露。

测试环境:本地私有 CA 树(根 CA → 中间 CA → 服务器证书),客户端为 OpenSSL 3.5.6 / Python 3.15 ssl / Node 24 tls。


问题 1:load_certificate_file() 只加载第一张证书,中间证书发不出去

现象

给服务端配置一个含两张证书的 PEM(叶子 + 中间):

$ grep -c "BEGIN CERTIFICATE" leaflocal-chain.crt
2

但用 openssl s_client -showcerts 看服务端实际发出的链:

0 s:C=CN, O=libuvcpp-test, CN=localhost
  i:C=CN, O=libuvcpp-test, CN=libuvcpp-test-Intermediate
实发张数: 1

拿一个只信任根 CA 的客户端去连(这正是浏览器与 curl 的默认状态):

客户端信任库 校验结果
只有根 CA ❌ verify error:num=20: unable to get local issuer certificate
Verify return code: 21 (unable to verify the first certificate)
根 CA + 中间证书 ✅ Verify return code: 0 (ok)

静态交叉验证排除了证书材料本身的问题:

$ openssl verify -CAfile ca.crt leaflocal.crt
error 20 at 0 depth lookup: unable to get local issuer certificate

$ openssl verify -CAfile ca.crt -untrusted intermediate.crt leaflocal.crt
leaflocal.crt: OK

材料是对的,缺的只是"服务端把中间证书发出去"这一步。

根因

src/ssl/uvcpp_ssl_context.cpp:

if (SSL_CTX_use_certificate_file(ctx_, path.c_str(), SSL_FILETYPE_PEM) != 1) {

SSL_CTX_use_certificate_file() 只读 PEM 里的第一张证书;读整条链要用 SSL_CTX_use_certificate_chain_file()。症状与这个差别完全吻合。

为什么这条值得单独修

uvcpp_web_app.h 里 enable_ssl() 的注释写的是:

cert_file 是证书链,key_file 是私钥。

也就是说实现与自己的文档承诺不符。而现实世界里几乎每张证书都挂在中间 CA 下面(Let's Encrypt 的 R3/R10、DigiCert、企业内网 PKI 都是)—— 这种部署下浏览器、curl、移动端会全部拒绝连接,唯一"能用"的办法是关掉证书校验,而那不是选项。

补充一句边界:"叶子由根直接签"的两级链只发叶子是正确的(根证书本来就不该由服务端发送,客户端自己有)。所以本次修改对那种配置没有影响,问题精确定位在"链上除叶子之外的部分没有发"。

修改

  • load_certificate_file() → 改用 SSL_CTX_use_certificate_chain_file();
  • load_certificate_data()(enable_ssl_pem() 走的路径)有同一个问题,一并修:先读叶子,
    再循环读剩下的并逐个 SSL_CTX_add_extra_chain_cert() —— 读法照搬 OpenSSL 自己的 process_chain()。

问题 2:set_min_version() 只能把下限往下放,不能往上抬

现象

app.enable_ssl(cert, key);
app.ssl_context()->set_min_version(tls_version::TLS_1_3);

启动日志确认配置确实传进去了:

[webdemo] TLS 已配置 (file),min=1.3 max=默认

但用只支持 TLS 1.2 的客户端去连:

握手成功,版本 = TLSv1.2        ← min=1.3 没起作用

根因

void uvcpp_ssl_context::set_min_version(tls_version ver) {
  min_version_ = ver;
  if (!ctx_) return;
  long opts = SSL_CTX_get_options(ctx_);
  opts |= SSL_OP_NO_TLSv1;                    // ← 只置了 v1
  if (ver <= TLS_1_0) opts &= ~SSL_OP_NO_TLSv1;
  if (ver <= TLS_1_1) opts &= ~SSL_OP_NO_TLSv1_1;
  if (ver <= TLS_1_2) opts &= ~SSL_OP_NO_TLSv1_2;
  SSL_CTX_set_options(ctx_, opts);
}

这个写法只会清标志,从不置 NO_TLSv1_1 / NO_TLSv1_2。而构造函数(set_default_verify())在 kTlsFloor = TLS_1_2 下只置了 NO_TLSv1 和 NO_TLSv1_1 —— NO_TLSv1_2 从来没被置过。

于是 set_min_version(TLS_1_3) 想做的那件事(禁掉 1.2)没有任何一行代码去执行。更巧的是:默认下限就是 TLS_1_2,所以"抬到 1.3"这个最常见的用法恰好唯一失效。

set_max_version() 有对称的问题 —— 它只置不清:

if (ver < TLS_1_3)
  SSL_CTX_set_options(ctx_, SSL_CTX_get_options(ctx_) | SSL_OP_NO_TLSv1_3);

NO_TLSv1_3 一旦置上就再也去不掉,"先压低上限、再放宽"做不到。

修改

两个 setter 都改成先把相关禁用位全置上(或全清掉),再按新值逐个置位:

// set_min_version
opts |= SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 | SSL_OP_NO_TLSv1_2;
if (ver <= TLS_1_2) opts &= ~SSL_OP_NO_TLSv1_2;
if (ver <= TLS_1_1) opts &= ~SSL_OP_NO_TLSv1_1;
if (ver <= TLS_1_0) opts &= ~SSL_OP_NO_TLSv1;

改完之后:TLS_1_3 下限下 1.2 被拒、TLS_1_2 下限下 1.2 可用且 1.0/1.1 仍被拒、上限可在 1.2/1.3 之间自由来回设。


回归用例

tests/functional/web_ssl_func.cpp 新增两组,判据取在 SSL_CTX 自身的状态上,不起网络、不依赖外部证书文件:

用例 判据
certificate_chain_loaded 两张证书的 PEM 加载后,SSL_CTX_get_extra_chain_certs() 必须是 1 张;单张的 PEM 必须是 0 张
version_bounds_settable set_min_version(TLS_1_3) 后 SSL_OP_NO_TLSv1_2 必须置上;放回 TLS_1_2 后必须清掉且 NO_TLSv1/NO_TLSv1_1 仍置上;set_max_version 同理可双向

两组用例都不需要外部证书文件 —— 自己用 generate_self_signed() 造一张证书,再拼成多张的 PEM。

这两组用例的设计意图是能抓住原缺陷(据 OpenSSL 的文档语义推断,未实际运行,见文末"测试说明"):

  • load_certificate_file() 换回 _file 时,两张证书的 PEM 只会加载第一张,extra_chain_count() 读到 0,第一条断言必然失败;
  • set_min_version() 换回原实现时,它从不置 SSL_OP_NO_TLSv1_2,version_bounds_settable 的第一条断言必然失败。

一个相关但本次没有修的问题:TLS 握手阶段不受 idle_timeout_ms 约束

同一轮测试里还发现:idle_timeout_ms 覆盖不到 TLS 握手阶段。

框架现有的语义很扎实 —— 连接空闲从"最近一次收到字节"算起、正在收的请求从"请求的第一个字节"算起,滴字节刷新不了。但握手阶段既不是"没收到字节",也还没有请求:每来一个字节都会刷新连接级计时器。

实测 idle_timeout_ms=4000,每 500 ms 滴 1 字节喂 ClientHello:

每 500ms 滴 1 字节持续 30.0s,连接仍然挂着   ← 是超时值的 7.5 倍

每条这样的连接都占着一份 SSL 对象 + 读写缓冲区,而客户端只需极低带宽就能维持。

没有放进这个 PR,因为它需要引入一个新概念("自 accept 起 N 毫秒未完成握手就断开"),涉及 idle_sweep() 的判据与一处新的配置项,与上面两个纯 bug 修复的性质不同,值得单独讨论。如果希望一起做,我可以另开一个 PR。


其他(不是缺陷,供参考)

  • 默认配置实测协商 TLS 1.3 + AES-256-GCM,TLS 1.0/1.1 默认被拒,NULL/匿名套件被拒,无 TLS 压缩,ALPN 不会错误协商 h2,实现了 RFC 5746 —— 默认参数是安全的,不需要调优。
  • 证书与私钥不配对、证书路径写错都在启动时立即失败(退出码非 0)并给出明确的 OpenSSL 错误文本,而不是拖到第一次握手。这个设计是对的,实测确实做到了 —— 否则表现会是"端口在听、每条连接建完就被拒",极难排查。
  • 明文打到 TLS 端口 → 直接断开,不会静默降级成明文服务。✓

测试说明

  • 环境:Windows 10 + MSVC,OpenSSL(随库构建)、Python 3.15 / Node 24 / OpenSSL 3.5.6 作为客户端。
  • ⚠️ 本补丁未经编译验证:改动所在的源文件需要 OpenSSL 开发头,而测试机上没有该环境、仓库的 FetchContent 依赖也拉不下来。改动本身是单文件内的局部替换(两个函数各改一行、一个函数改写实现),已逐行复核,但请以 CI 的编译结果为准。
  • 上面所有"现象/根因"的复现数据都来自对预编译包(1.1.0)的实测,代码引用为 master(1.1.7)的当前内容 —— 两处缺陷在 master 上均仍然存在。

两个独立的缺陷,共同点都是"配置看着设上了、实际没起作用"。

1) load_certificate_file() 用的是 SSL_CTX_use_certificate_file(),它只读 PEM
   里的**第一张**证书,中间证书因此永远发不到对端。只信任根 CA 的客户端
   (浏览器与 curl 的默认状态)没法把链补全,握手直接失败:

       verify error:num=20: unable to get local issuer certificate
       Verify return code: 21 (unable to verify the first certificate)

   而 enable_ssl() 的文档承诺的正是"cert_file 是证书**链**"。现实中几乎每张
   证书都挂在中间 CA 下面(Let's Encrypt / DigiCert / 企业 PKI),所以这一条
   直接决定 HTTPS 在真实部署下能不能用。

   改用 SSL_CTX_use_certificate_chain_file();并让 load_certificate_data()
   (enable_ssl_pem() 走的路径)同样读完整个链 —— 读法照搬 OpenSSL 自己
   process_chain() 的做法。

2) set_min_version() 只置 NO_TLSv1 再逐个清标志,这个写法**只能把下限往下放、
   不能往上抬**:设成 TLS_1_3 时 NO_TLSv1_2 从构造起就没被置过,TLS 1.2 客户端
   照样协商成功。而构造时给的默认下限恰好是 TLS_1_2(webapp 的 kTlsFloor),
   所以"抬到 1.3"这个最常见的用法正是失效的那一个。

   set_max_version() 有对称的问题:只置不清,先压低上限再放宽做不到。

   两者都改成"先把相关禁用位全置上(或全清掉),再按新值逐个置位"。

配套加两组回归用例(tests/functional/web_ssl_func.cpp):判据取在 SSL_CTX
自身的状态上(额外链证书张数、选项位),不起网络,也不需要外部证书文件
—— 用例自己用 generate_self_signed() 造证书再拼成多张的 PEM。
CI 在五个平台上一致地报出:

    [web_ssl] version_bounds_settable
        set_min_version(TLS_1_2) 之后 TLS 1.2 仍然被禁
      -> FAIL

(同一轮里 `certificate_chain_loaded` 是全过的,证书链那一条修复没问题。)

失败的根因是上一个提交**照抄了原实现的写法**,把同一个错误一起继承了下来:

    long opts = SSL_CTX_get_options(ctx_);
    opts |= SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 | SSL_OP_NO_TLSv1_2;
    if (ver <= TLS_1_2) opts &= ~SSL_OP_NO_TLSv1_2;   // ← 白清
    ...
    SSL_CTX_set_options(ctx_, opts);

`SSL_CTX_set_options(ctx, op)` 的语义是「**加上** op 里的位」,它**不会清**
没给的位 —— 想清必须另调 `SSL_CTX_clear_options()`。所以那句 `&= ~` 之后再
`set_options`,位从来没被去掉过。

也就是说原缺陷比 issue 里描述的还要广一层:不只是"下限抬不上去",
**往下放同样做不到**(第一条断言能过、第二条过不去,正好对上:置位是有效的,
清位是无效的)。

改用 OpenSSL 3.0 起官方指定的 proto version API:

    SSL_CTX_set_min_proto_version(ctx_, openssl_version_of(ver));
    SSL_CTX_set_max_proto_version(ctx_, openssl_version_of(ver));

顺带把构造期那条下限(`set_default_verify()`)也换成同一个 API —— 原来它也是
在位运算上设的,虽然方向碰巧对,但"构造时设的"和"运行中改的"是两套机制,
其中一套还是无效的,不该留着。

断言相应改成读 `SSL_CTX_get_min_proto_version()` / `get_max_proto_version()`:
判据与实现用同一个量,不会各说各话。
@sercebr

sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

CI 抓到了这份补丁里的一个错误,已修正(第二个提交 28f90d5)

五个平台一致地报出:

[web_ssl] certificate_chain_loaded
  -> PASS
[web_ssl] version_bounds_settable
    set_min_version(TLS_1_2) 之后 TLS 1.2 仍然被禁
  -> FAIL
  • 证书链那一半没问题 —— certificate_chain_loaded 全平台通过,SSL_CTX_use_certificate_chain_file() 的改动是有效的。
  • 版本上下限那一半,我原来的修法是无效的,因为它照抄了原实现的写法,把同一个错误一起继承了下来。

错在哪

long opts = SSL_CTX_get_options(ctx_);
opts |= SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 | SSL_OP_NO_TLSv1_2;
if (ver <= TLS_1_2) opts &= ~SSL_OP_NO_TLSv1_2;   // ← 白清
...
SSL_CTX_set_options(ctx_, opts);

SSL_CTX_set_options(ctx, op) 的语义是「加上 op 里的位」,它不会清没给的位 —— 想清必须另调 SSL_CTX_clear_options()。所以那句 &= ~ 之后再 set_options,位从来没被去掉过。

这也说明原缺陷比本 PR 描述里写的还要广一层:不只是"下限抬不上去"(我原来的描述),往下放同样做不到。两条断言的表现正好对上这个诊断:第一条(置位)能过,第二条(清位)过不去 —— 置位有效、清位无效。

现在的改法

改用 OpenSSL 3.0 起官方指定的 proto version API,不再在位运算上辩方向:

SSL_CTX_set_min_proto_version(ctx_, openssl_version_of(ver));
SSL_CTX_set_max_proto_version(ctx_, openssl_version_of(ver));

顺带把构造期那条下限(set_default_verify())也换成同一个 API —— 它原来也是在位运算上设的,方向碰巧对,但"构造时设的"和"运行中改的"不该是两套机制、其中一套还是无效的。

断言相应改成读 SSL_CTX_get_min_proto_version() / get_max_proto_version(),判据与实现用同一个量。

需要一次批准

新推送的 run 处于 action_required(首次贡献者需要维护者放行)。麻烦有权限的同学点一下,让 CI 把这一版跑完。

CI 在 mingw64 上报:

    web_ssl_func.cpp:470:66: error: missing terminating " character
    web_ssl_func.cpp:479:18: error: missing terminating " character

原因是写 `std::cout << "...\n"` 时那个换行转义**落成了字符串里的一个真实
换行**,于是字面量跨行、引号没闭合。

改成 `<< std::endl`,整个函数的字符串里不再出现任何反斜杠转义 —— 这样就不
存在"转义在某一层被吃掉"的余地了。

(其它平台的上一个 run 也一起挂了,只是 mingw 的日志先被看到。)
@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

你提出的问题, 我大概看了一下, 写了这么多, 测试很严谨 很感谢可以第一个测试这个框架的实际项目, 我这边全面分析一下再给你回复

@Antruly
Antruly merged commit 366c1ad into Antruly:master Sep 19, 2026
16 checks passed
Antruly added a commit that referenced this pull request Sep 19, 2026
合入外部贡献的 `fix(ssl): 加载完整证书链,并让 TLS 版本上下限双向生效` 之后,
按"每推一批功能升一档 patch"的节奏前进一档;`UVCPP_VERSION_IS_RELEASE` 保持 0,
HTTP/2 全部完成前不出 release。

判据取在 `uvcppConfig.cmake` 的版本串上(版本头只在 configure 期进构建,
`cmake --build` 不会因此重编任何 obj):重新 configure 后为 `1.1.13`。

Co-Authored-By: Claude Code <noreply@anthropic.com>
sercebr added a commit that referenced this pull request Sep 24, 2026
## 一、病灶:`post()` 的"快路径"要先**造一个闭包**才能走

`uvcpp_web_context::advance()` 里给处理器发的那枚 `next` 是这个形状:

    uvcpp_web_next next = uvcpp_web_next([self]() {
      self->post([self]() { self->advance(); });      // ← 每调一次 next,先造一个闭包
    });

而 `post()` 在 loop 线程上本来就是**就地调用**(`uvcpp_web_context.cpp:73`,注释自己
写着"已经在 loop 线程上就地跑:省一次投递")。也就是说:链上每一步都要先
`new` 一个捕获 `shared_ptr` 的闭包、把它塞进 `std::function`,只为了让 `post()`
当场同步执行它 —— **分配完就扔**。

GCC 的 `std::function` 只在可调用对象**平凡可拷贝**(`__is_location_invariant`)时才用
内联存储,捕获 `shared_ptr` 的 lambda 不在其列 ⇒ 这一次构造**必落堆**。

分配调用点普查(`ac-site.sh` + `allocount2.so`)里它就是一个独立的点:

    1.0034 次/请求  _Function_handler<void (),
                    uvcpp::uvcpp_web_context::advance()::{lambda()#1}>::_M_invoke(...)

## 二、改法:loop 线程上直接 `advance()`,跨线程仍走 `post()`

    uvcpp_web_next next = uvcpp_web_next([this, self]() {
      if (host_.on_loop_thread()) { advance(); return; }   // 就地,与 post() 的快路径逐字同义
      self->post([self]() { self->advance(); });           // 跨线程:闭包**必须**存下来
    });

捕获 `this` 是安全的:同一个闭包里的 `self` 就保着这个对象的命。语义无变化 ——
`post()` 的快路径就是 `fn()`(`.cpp:72-76`),原地替换成 `advance()` 是同一件事。

## 三、判据

**① 分配计数(`ac-cpu.sh`,三批交错,两臂各自新建)**

| 批 | 基线(24.0177 / 24.0200 / 24.0252) | 本笔(23.0165 / 23.0225 / 23.0217) |
|---|---|---|
| 1 | 24.0177 | 23.0165 |
| 2 | 24.0200 | 23.0225 |
| 3 | 24.0252 | 23.0217 |

**−1.00 次/请求**,三批同向、批内离散 < 0.04%。rps 那两列在噪声里(88.6k~116k 两臂互有
胜负),**本笔不据它立论**。

**② 站点普查的鉴别对照(这是"消掉的正是那一处"的判据,不是总数)**

`ac-site.sh` 两臂对跑,`resolve_sites.py` 的站点名集合逐条 diff:

    基线 24.1918 次/请求 ← 含 _M_invoke 1.0034
    本笔 23.1876 次/请求 ← **该站点名整个消失**(不是改名、不是挪位)

同一份 diff 里其余站点**一个都没动**(前 25 名的名单只因少一条而整体上移一位,
数值仍在 1.0034~1.0105 的同一带宽内)。`_M_manager` 两臂都 **2.0074** —— 那一次
拷贝是**按值形参**,本笔有意保留(见下)。

**③ 全量 ctest**:`b/v130` **100/100**。

## 四、★ 一处试过、实测后**必须撤掉**的改动:`std::move(next)`

第一版把调用点也改了:`(*chain_)[idx](req_, resp_, std::move(next))`。全量 ctest
当场红 9 条,红的全是"留了 next"那一族(第 5「挂起期间不发响应」、第 6「末环留
next」、第 11「收尾之后的 next」)。

根因:`kept_next` 判据是**调用前后的引用计数差**(`kept_next = after > before`),
它依赖**局部 `next` 在调用后仍持着那一份引用**。移动进去之后:

    没留副本:before = N(含局部),调用后形参析构 ⇒ after = N-1  ⇒ 差 -1
    留了副本:before = N,处理器 +1、形参析构 -1、局部已搬空 ⇒ after = N ⇒ 差 0

两种情形都不大于 0 ⇒ **一律判成"没留",响应被抢先发出去**(正是这个判据要防的
那个静默错误)。所以这一处**必须按值拷贝**,它那一次分配是判据的代价。

## 五、范围与文档

只动一个 `.cpp`(外加注释的**行数保持不变**地重写了一段已与实现不符的说明)。
无头文件、无 ABI、无门禁宏。

升档 1.3.12-dev → **1.3.13-dev**(7 处,形状与前一笔逐字相同)。

**行号锁动了一条**:`doc/webapp-support-guide.md:622` 引的
`uvcpp_web_context.cpp:359-364` 因本笔在**其上方**插入 15 行而平移到 **374-379**。
按"先改文档号码、再刷新锁"的次序做,刷新后锁里那条的**三个哈希与新址逐位相同**
(`29063e0cffa90988 24ee3aaf8c65c117 3125847ecbdb6249`)—— 这就是"纯平移、内容没变"
的判据。四条门禁 `check_doc_versions` / `check_docs` / `check_doc_lines` /
`check_doc_lines_scenes` 全过。
Antruly pushed a commit that referenced this pull request Sep 24, 2026
--------------------------------------------------------------------
这一笔改了什么
--------------------------------------------------------------------
`uvcpp_web_next` 从 `using uvcpp_web_next = std::function<void()>;` 换成**一个类**
(`src/webapp/uvcpp_web_handler.h`):框架给的 next 只持一份上下文的 `shared_ptr`,
构造与拷贝都不碰堆;手造形态(传任意无参可调用对象)内部仍走 `std::function`。

**为什么非改不可**:`std::function` 只在可调用对象**平凡可拷贝**时才用内联存储
(GCC 的 `__is_location_invariant` = `is_trivially_copyable`,**与 sizeof 无关**),
而 `advance()` 里那个闭包捕获了 `[this, self]`(一份 `shared_ptr`)⇒ 每处理一环两次
堆分配(造一次 + 按值传参再拷一次);本仓每请求跑两环 ⇒ 4 次。站点普查里这两笔
各 2.00 次/请求,正是本次的落点。

**改法(逐条)**:
- `uvcpp_web_next` 类:默认构造 / 拷贝构造、拷贝赋值 / 移动构造、移动赋值 / `swap`
  / `explicit operator bool` / `operator()` / 从任意无参可调用对象构造(模板 + SFINAE
  排除自己,不与拷贝构造打架)。
- 框架形态走**私有静态 `bind()`**(`uvcpp_web_context` 是 friend):`bind(self)` 就是
  `n.ctx_ = self`,零分配;`operator()` 转调 `ctx_->next_resume_chain()`。
- 新 TU `src/webapp/uvcpp_web_handler.cpp`:只放 `operator()` 的实现 —— 它要
  `uvcpp_web_context` 的完整定义,而上下文头**反过来**包含 handler 头(handler 的
  签名是上下文的一部分)⇒ 放头里就是循环包含。空 next 调用照旧抛
  `std::bad_function_call`。
- `uvcpp_web_context::next_resume_chain()`(私有):**逐字等于**原来那个闭包的体 ——
  loop 线程上就地 `advance()`,跨线程才 `post()` 投回去。
- `advance()` 里 `std::function<void()> next = [this, self]() {...}` 换成
  `uvcpp_web_next next = uvcpp_web_next::bind(self);`。

★ **判据为什么还活着(本笔最大的风险)**:`kept_next` 判据量的是 `self.use_count()`
在调用前后的差。旧形状靠"闭包捕获一份 `shared_ptr` + 按值传参再拷一份"把这个差做出来;
新形状靠**拷贝构造把 `ctx_` 那份 `shared_ptr` 抄一份** ⇒ 差值一模一样。1.3.13 试过把
那处改成移动,第 5/6/11 条用例当场红 —— 本笔用**同一处**变异复验(见下)。

--------------------------------------------------------------------
四条树 + `sizeof` 两臂(都为本笔尾的最终树)
--------------------------------------------------------------------
- 四棵树全量重建 + 全量 ctest:**b/v130 100/100、b/v130h2 107/107、b/wsdl 104/104、
  b/nobapp 46/46,全绿**。
- `sizeof` 两臂(`t1/ci_fix/m4_sizeof_probe.cpp` + `m4_sizeof_drive.py`:同一份探针
  源码、同一份 flags,只把项目自己的 include 目录换成 `git archive HEAD` 展开的树),
  **两档配置**(默认档、h2 档)逐项相同:
      `uvcpp_web_next`     **32 → 48**   ← 断点(16 的 `shared_ptr` + 32 的 `function`)
      `uvcpp_web_stream`   **288 → 304** ← 跟着 +16:它有一个 `uvcpp_web_next resume_`
      `uvcpp_web_context`  1216 == 1216
      `uvcpp_web_app`      1088 == 1088
      `uvcpp_web_response`  496 ==  496
      `uvcpp_http_server`   464 ==  464
      `uvcpp_tcp_client`    480 ==  480   (对照臂)
      `uvcpp_buf`            48 ==   48   (对照臂)
  阳性对照:两臂 `uvcpp_web_handler.h` / `uvcpp_web_stream.h` / `uvcpp_web_context.h`
  的 md5 **都不同**;`uvcpp_web_app.h` 的 md5 **相同**(反面对照:相等**不是**"读了同一
  份头")。附带一条旁证:`sizeof(std::function<void()>)` 两臂都是 **32** ⇒ 上面那个
  32 就是旧类型的本体。

--------------------------------------------------------------------
分配数(立论只用整数)与翻面对照
--------------------------------------------------------------------
- `ac-one.sh`(LD_PRELOAD 计数 ÷ loadgen 的 `DONE`),本笔尾最终树上:
      M4 之前那一臂(`b/v130` 重建前链出来的臂二进制,先拷成 `bench_uvcpp_prem4`
      再被覆盖)**12.0260 / 12.0138 / 12.0113 / 12.0185 / 12.0123**
      M4 之后 **8.0204 / 8.0185 / 8.0162 / 8.0099**
  ⇒ **−4.00**,九次读数极差 < 0.02。翻面对照就在**同一个 VM 会话里**交错跑。
- 站点普查(`ac-site.sh`,按返回地址归因,TOPN = 60):
      改前 12.0877 次/请求:`uvcpp_web_context::advance()` **2.0055** +
        `_Function_handler<void (), uvcpp_web_context::advance()::{lambda()#1}>::_M_manager`
        **2.0055**,其余 8 条 1.00 档 + 连接级那批(100 连接建连各 1 次)。
      改后 8.1188 次/请求:**那两行整笔消失**(`grep -c "advance()"` = **0**),
        剩下 8 条 1.00 档逐条不动(`alloc_buf` 1.0037、其余 7 条各 1.0019),
        覆盖率 99.7% → 99.6%(分位数在噪声里,条数没变)。
  ⇒ 「刚好降 4.00、其它笔不许同时变」这条形状对上了。

--------------------------------------------------------------------
µs 侧(诚实边界第一条):**这一笔量不出来**
--------------------------------------------------------------------
`ac-cpu.sh` 三次**交错**配对(pre/post/pre/post/pre/post):
      12.0113 → 8.0102 次/请求;服务端 **6.694 → 6.852** µs/请求
      12.0185 → 8.0108;**11.411 → 6.935**
      12.0123 → 8.0149;**7.545 → 10.790**
两条臂各自的极差(6.69~11.41 与 6.85~10.79)**远大于**预期效应(4 次分配约
0.2~0.3 µs)⇒ **µs 侧无符号、不可分辨**。按规矩如实说:本笔在 µs 层面**只算账面
好看**,"到 5 次 ⇒ rps 追平"**仍然不成立**(20 次 malloc ≈ 1.0~1.6 µs,1 核缺口
5.85 µs)。

--------------------------------------------------------------------
变异(判据要能红)
--------------------------------------------------------------------
`t1/ci_fix/m4_mut_move.py`:把 `(*chain_)[idx](req_, resp_, next);` 改成
`std::move(next)`(正向锚点命中**恰好 1 次**、反向锚点 0 次),重编 b/v130 后
**9 条用例红**:`test_web_app_app_func` / `context_func` / `multiloop_func` /
`pipeline_func` / `send_file_func` / `static_func` / `stream_func` /
`upload_route_func` / `worklimit_func`;`context_func` 里红的是
「留了 next → 挂起,不抢先发送」「挂起期间不发响应」「末环留 next → 挂起,什么都不发」
这几条 —— 正是"留了 next 的 handler 被误判成没留、响应被抢先发出去"的形状。
⇒ **换类型之后这条判据仍然活着**(本笔最需要证明的一件事)。撤掉变异后
`git diff | sha1sum` 与施加前**逐位相同**(`8f85d68027745ce1822f0a42c854ccb5b8ba4e26`)。

--------------------------------------------------------------------
文档、版本号、行号锁
--------------------------------------------------------------------
- 版本号 **7 处**:`uvcpp_version.h` PATCH 22 → 23;两版 README 各自的徽章
  (`version-1.3.23--dev-blue.svg`,shields.io 的双连字符)与正文串;两版 README 的
  「性能」段各补**一条** M4 变更日志(含 `sizeof` 32 → 48 的代价)。
- `doc/webapp-guide.md` §4:删掉 `using uvcpp_web_next = std::function<void()>;`
  那一行,补一段说明(类是什么、**用法一字未变**、按值传参**不能**换移动、`sizeof`
  32 → 48)。**刻意不给新引用编号**:新键会改锁的键数,把"纯位移"那条对账判据的
  形状换掉。
- 行号锁:`plan_lock` 给出**恰好两条位移** —— `uvcpp_web_context.cpp:374-379` →
  `385-390`(**+11**)、`uvcpp_web_context.h:447-447` → `468-468`(**+21**),与"本笔
  在它们上方插了多少行"逐条相等(独立旁证);`[歧]`/`[胀]`/`[撞]` 全 0。次序照记忆:
  先改文档号码(`m4_rewrite_docs.py`,2 条,映射全部来自门禁自己的 `plan_lock`;
  改前**逐条读过文档句子**:`:447` 那句写的是「`run()` 只存链的指针」、`cpp:374-379`
  那句写的是「代价照实记:一条永远不 `end()` 的流」)→ `--update`(**第一遍 rc=0**:
  新增 2 / 撤销 2 / 平移 0 / 重刷 0 / 撑长 0 / 歧义 0 / 撞键 0)→ 对账
  (`m4_lock_reconcile.py`):**880 == 880 键、内容哈希多重集逐条相同(只在旧锁
  0 种 0 条、只在新锁 0 种 0 条)、消失键的哈希在新锁里 2/2 找得到、出现键的哈希
  旧锁里 2/2 有过、同名键改了值 0 条** ⇒ 纯位移,没有"处方吃掉证据"。
- 其它文档门禁:`check_docs.py` 全过(3 条判据);`check_doc_versions.py` 全过
  (4 处版本串 == `1.3.23-dev`);`check_doc_lines_scenes.py` 与期望不符 **0 格**。
  ★ `check_doc_snippets.py` **这一笔没跑成**,如实记:它要一个带 `libuvcpp.so` 的
  **全模块包**,而 VM 工作区里的构建树是静态档(`package_release.py` 认定缺 `.so`
  与 libuv 头,**拒绝出残缺的包**;`/tmp` 下现成的包是别的提交的,头是旧的、判不出
  本笔)。**改为定点检查**(`t1/ci_fix/m4_snippet_probe.cpp`):同一段栅栏文本 +
  等价前言 + **活树**头文件,`-c`、零 `-D`,编过;再配一份**鉴别**探针
  (`m4_snippet_disc.cpp`:`static_assert(!is_same<uvcpp_web_next, std::function<void()>>)`)
  —— 它在 **M4 之前**的头上**编不过**、在 M4 之后的头上编得过 ⇒ 两臂读的确实不是
  同一份头;而**消费者面**那几行(默认构造 / 拷贝构造 / 拷贝赋值 / `swap` / 移动
  构造赋值 / `operator bool` / 调用 / 从 lambda 造)**两臂都过** ⇒ **源码兼容**。

--------------------------------------------------------------------
发布时基点前移(远端 master 在本书写期间前进了一笔)—— **没有强推**
--------------------------------------------------------------------
第一次推送被 `t1/push_master.sh` 的**快进守卫**拦下:远端 master 已是 `97656bd`,
不是本笔的祖先。查明是作者把 PR #33「fix(wsdl): Windows 动态库下一个 SOAP 符号都
不导出 —— 四个公共头补上 `UVCPP_API`」(`fc431e7` + 合并提交 `97656bd`)合了进来,
其第一父正是本笔的原基点 `23cec4b`。
- **没有强推**:把本笔 `git rebase --onto 97656bd 23cec4b` 到远端 master 上。
  无冲突:PR #33 只动 `src/wsdl/` 四个公共头,本笔一行没碰 wsdl;版本号也没撞车 ——
  新基点仍是 `1.3.22`,作者那一笔没动 `uvcpp_version.h`(所以本笔的 `1.3.23-dev`
  仍是「一次推送 = 一个版本号」里的那一号)。重排后本笔仍是**单个提交、只署
  `sercebr`、无尾注**。
- 重排前后两棵树的差异**只有那四个 wsdl 头**(`git diff --stat` 逐条对上 PR #33 的
  改动面)⇒ 本笔自己的改动一字未动。
- 重排后**重跑四棵树全量重建 + 全量 ctest**:`v130` **100/100**、`v130h2` **107/107**、
  `wsdl` **104/104**、`nobapp` **46/46**。★ 其中 `b/wsdl` 编的正是被改的那四个头,
  这一条不是空跑。
- 重排后**重跑分配读数**(同一 VM 会话、与 pre 臂交错):
      pre 臂 **12.0107 / 12.0105**  vs  post 臂 **8.0148 / 8.0117**  ⇒ 仍是 **−4.00**;
      站点普查 **12.1032 → 8.0720**(`advance()` 与 `_M_manager` 那两行 2.0048
      **整笔消失**,其余 1.00 档逐条不动)。数字与重排前**一字未变**。
- 独立旁证:重排后重编的压测臂与重排前那一支 **sha256 逐位相同**
  (`6963883afac9f89265a835b101db59583295f73f6dea99c2a5494ba385ae98c6`)
  ⇒ PR #33 不碰这条臂用到的任何东西,上面所有读数(含 µs 配对、`sizeof`、变异)
  **原样成立**,不需要重做。

--------------------------------------------------------------------
诚实边界(不许含糊)
--------------------------------------------------------------------
1. **分配次数 ≠ 成本**:省掉的 4 次分配按 0.05~0.08 µs/次算约 0.2~0.3 µs,而 1 核
   缺口是 5.85 µs ⇒ 本笔**不构成**"rps 追平"的依据。上面三次交错配对还显示 µs 侧
   **不可分辨**(设备噪声大于效应)⇒ 本笔在 µs 层面只算**账面好看**。
2. **内核态那 40~53% 不在本轮射程**(五条候选已排除,仍有 2.4~3.4 µs 未归属)。
3. **破坏面逐条点名(源码 + ABI 双重断点)**:
   - **动了哪个公开类型**:`uvcpp_web_next`(原来是 `std::function<void()>` 的别名,
     现在是一个类)。`uvcpp_web_stream` 里那份 `uvcpp_web_next resume_` 成员跟着变大
     ⇒ `sizeof(uvcpp_web_stream)` **288 → 304**。**其余公开类型的布局与 vtable 一字
     未动**(上面八项 `sizeof` 两档配置逐项实测)。
   - **源码面**:把 `next` 当 `std::function<void()>` 用的写法(硬转成
     `std::function<void()>`、调 `std::function` 才有的成员、`.target<...>`)
     **编不过了**。树里这种用法 **0 处**(grep 命中的 `static_cast<std::function<...>>`
     全在 `uvcpp_tcp_client.cpp`,指的是别的回调);两处手造 next 的功能用例与文档
     示例照旧编得过。
   - **ABI 面**:拿旧头编译的调用方**必须重编**。
   - **版本号仍按 1.3.x 走**(用户 09-25 的明确要求),所以破坏面靠这段提交信息点名,
     **不靠 minor 号暗示**。
4. 回退点仍是 `refs/tags/pre-13x-alloc-cut` = `be7cfdf`。
sercebr added a commit that referenced this pull request Sep 25, 2026
站点 = 计划清单剩下的五行之一 `uvcpp_http_response::to_string`(1.00 次/请求)。

病根:`to_string()` 每次都 `std::string result; result.reserve(est);` —— 造一个串、
把字节拼进去、返回。而服务端唯一的那个热调用点(`uvcpp_http_server.cpp:828`)拿到它
之后**立刻**把这些字节拷进写请求自己的头部缓冲(`uvcpp_tcp_client.cpp:1474` 那次
memcpy),然后那个串当场析构 ⇒ 这次分配是纯浪费:拷完就扔。

改法(加一个**增量** API,既有语义一字不改):

  · `uvcpp_http_response::to_string_into(std::string& out, bool include_body)`
    (声明 `uvcpp_http_response.h:187`;实现体 `uvcpp_http_response.cpp:184`)与
    `to_string` **同一份实现**(`out.clear()` 之后照原样拼写、容量留下;`to_string`
    退化成"造一个空串再调这里")⇒ 两条路出来的字节逐字节相同。
  · `conn_ctx` 增 `std::string wire_recycle`(`uvcpp_http_server.h:903`,**私有嵌套
    类型**)—— 目的地挂在**连接**上 ⇒ 容量跨请求留下。
  · `enqueue_write` / `start_write` 的头部参数由按值改按 `const` 引用
    (`uvcpp_http_server.h:919/930/962`):两条写路径都在返回之前把头部字节拷走
    (`write_owned` 的 memcpy `uvcpp_tcp_client.cpp:1474`/`write` 的
    `uvcpp_buf headbuf(head, head_len)` `:1342`)⇒ 按引用传安全;**入队那条路**
    自己留一份拷贝(`uvcpp_http_server.cpp:1263/1278`),因为队列要活到冲刷时。
  · `send_response` 两处调用点换成 `to_string_into(ctx.wire_recycle, …)`
    (`uvcpp_http_server.cpp:828/842/844`)。

分配(交错 5 组配对,1 循环档 `8081 0 1`,分母 = loadgen 的 DONE):

    对照 `bench_n1_base`   5.0117 / 5.0149 / 5.0097 / 5.0129 / 5.0106
    本笔                   4.0099 / 4.0094 / 4.0103 / 4.0172 / 4.0102
    ⇒ **Δ = −1.00**(臂内散布 ≈0.005)    5.02 → 4.02

对照臂是**开工前**存下的同一份二进制(`cp bench_uvcpp bench_n1_base`,sha256
`5e80d835…`)⇒ 不是"照基线重编",是那一刻真跑过的那个文件。

站点普查(`ac-site.sh`,TOPN=60):`uvcpp_http_response::to_string` 那行**整笔消失**;
新出现的 `to_string_into` 只有 **0.0013**(100 次 = 100 条连接 ⇒ **每连接一次**:
第一次把容量撑起来,之后 `est > capacity()` 恒假);**其余每一笔比值不动**(1.00× 那
四行仍 1.00×,每连接那几行的绝对次数两臂相同)。总量 5.0577 → 4.0555,覆盖 99.7%。

证人用例(`tests/functional/web_http_server_func.cpp:1169`,注册在用例表 `:1263`):
`to_string_into_reuses_buffer` 三条判据 —— ①两形态逐字节相同(`include_body` ∈
{true,false} × plain / chunked / chunked-空体)②脏缓冲(目的地里先塞一段别的,结果不许
带上它)③缓冲没被换掉(手工撑到 4096,调完之后容量不许掉下来)。跑全绿。

★ 变异份量(三条逐条跑过、逐条还原、md5 每次回到 `db81c8ad…`,分辨力互不重叠):

    M1  摘掉 `result.clear()`(写成注释,行数守恒)
        ⇒ 红 **3** 条:本笔那条脏缓冲 + 既有的 `keepalive_sequential_responses` /
          `client_head_request`(keep-alive 第二条请求的报文里带上第一条的头部,
          线上就看得见)⇒ **功能敏感**。
    M2  `result.clear()` → `std::string().swap(result)`(真换缓冲)
        ⇒ 红 **1** 条:只有③(`容量 4096 → 96`)。既有用例**全绿**、报文语义完全正确
          ⇒ **计数敏感、功能钝** —— 这一侧靠分配普查兜(M2 = 撤销本笔的机制)。
    M3  壳里忘了转发 flag(`to_string_into(result, true)`)
        ⇒ 红 1 条:只有①(`plain include_body=0:73 vs 83 字节`)⇒ 挡"以后两者漂开"。
          (①在"两形态共用一份实现"之后是**按构造成立**的,它挡的不是"今天写错"。)

★ **两条设计被探针证伪并改掉**(`n1_streambuf_probe.cpp`,libstdc++ 13,四臂):
  · `result = std::string();` **不丢容量** —— 空临时串的移动赋值退化成 `clear()`,
    实测稳态 reserve 分配 **0**,与 `clear()` 逐项相同(data 不动、容量不动)。所以
    源码注释里原来那句"那是换一块新缓冲、容量就丢了"**是假话,已改**;它**也当不了
    变异**(施加了读不出差别,绿)。真丢容量的是 `std::string().swap(x)` 与
    `clear()+shrink_to_fit()`(两者稳态 reserve 分配都是 **1/每次**)。
  · 只判 `data()` 不变是**瞎子**:真换缓冲那两种写法会把旧块还给分配器,下一次
    `reserve` 往往拿到**同一个地址**(探针 C/D 两臂都是 `data same` 而
    `reserve 1`)⇒ 判据③因此改成"手工撑到 4096、调完容量不许掉下来",才有分辨力
    (M2 现场印证:4096 → 96)。

★ 行号锁**一字未动**:全部改动都是"1 行换 1 行",两处插入(`uvcpp_http_response.h`
末条引用 `:168` 之后、用例文件在锁里没有任何引用)都在各自文件**最后一条引用之后**。
判据 = 门禁 `check_doc_lines.py --root .` 5 条判据全过(968 条引用、判据 3 内容一致)。

破坏面(逐条点名):

  · 新增**公开名字 1 个**:`uvcpp_http_response::to_string_into`(非虚、无数据成员)
    ⇒ `sizeof(uvcpp_http_response)` 两臂相同(`n1_sizeof_drive.py` 实测)。
  · `enqueue_write` / `start_write` 是 `uvcpp_http_server` 的**私有**成员函数
    ⇒ 参数由按值改按引用不是公开契约变化。
  · `conn_ctx::wire_recycle` 在**私有嵌套类型**里、`http_server` 通过
    `std::map<…, conn_ctx>` 按值持有 ⇒ 公开类型布局不变;`conn_ctx` 自己
    **+32 字节**(`n1_connctx_probe.cpp`:`#define private public` + 同 TU 自检
    `sizeof(uvcpp_http_server)` 与公开支相同)。
  · 文档:**一处都不用改**(`to_string` 的语义没变;头文件里新加的注释在 doc 里没有
    被引用,行号锁也没有指向它的键)。

诚实边界:

  · **分配次数 ≠ 成本**:本笔省掉的是"造一个 ~96 字节的串",账面 −1.00;**µs 未量**
    (这一轮没跑配对 µs/请求)⇒ 按本仓算法这笔**只算账面好看**。
  · 入队那条路**多了一次拷贝**(`qw.bytes = wire`):本仓负载是严格 ping-pong、队列
    几乎总是空(普查里 `queued_write` 那两笔只有每连接 200 次),但**真在队列上堆积
    时这条路的头部字节拷贝从 0 变 1** —— 这是本笔唯一"可能变贵"的地方。
  · 内核态那 40~53% 与本笔无关;五行里的另两笔(`uvcpp_web_response::text` #17、
    `uvcpp_buf::clone_data` #1)本笔**没动**,四循环快档里消失的那两行仍在。
  · 剩余四行未归零,见计划清单;本笔不动它们的语义。
  · **装置侧一并量过(本笔的读数是哪种日志档位下取的)**:本仓的 uvcpp 压测臂
    只调了 `app.set_log_level(log_level::ERR)`,**没关访问日志**,而库默认是
    `access_log(true)` ⇒ 每条请求都过一遍访问日志中间件、输出被等级挡住。三臂
    实测(`t1/n1/n1b_logcfg_*`):关掉它 4.016 vs 现役 4.013 **不可分辨**(用户态
    µs 三对符号还翻转),把等级抬到默认 INFO 让日志真打则 **+2.00 次分配/请求、
    服务端 stdout 54 MB**(正对照,证明装置分辨得出来)⇒ 本次读数**没有吃到日志税**。
    对照组 hical 臂:日志系统在、默认 `hInfo` 档,但**HTTP 请求路径里一条日志调用
    都没有**(它的 `LogMiddleware` 从不默认安装)⇒ 两家在"逐请求日志"上都是零输出。

回归:四棵树 `b/v130` 100、`b/v130h2` 107、`b/wsdl` 104、`b/nobapp` 46 全绿;
`check_docs.py --root .` 3 条判据全过。装置:`t1/n1/n1_patch.py`、`n1_witness.py`、
`n1_mutations.py`、`n1_mut_batch.sh`、`n1_version_bump.py`、`n1_streambuf_probe.cpp`、
`n1_sizeof_probe.cpp`、`n1_connctx_probe.cpp`、`n1_sizeof_drive.py`。
Antruly added a commit that referenced this pull request Oct 12, 2026
判据与量法:`build-rel15`(从 `_local_deps/libuv` v1.51.0 建,真 REUSEPORT 扇出)上跑
新用例 `tests/functional/http3_multiloop_func.cpp`(阶段 2 的 h3 扇出用例,8 条客户端线程
**共用同一个** `uvcpp_ssl_context`),以及同一份用例的 ASAN 树 `build-h3san`:

  改前:build-rel15 **30 次里 7 次**非零退出 —— 全部是 glibc 堆损坏中止
        (`double free or corruption (out)` / `(fasttop)` / `unaligned tcache chunk
        detected` / SIGSEGV),**没有一次是断言失败**;build-h3san **25 次里 6 次**
        `ERROR: AddressSanitizer: attempting double-free`。
  改后:build-rel15 **30/30**,build-h3san **25/25**(`ASAN_OPTIONS=detect_leaks=0` ——
        泄漏是 `~uvcpp_http_client` 有意为之的那条路,与本次无关)。

## 根因

ASAN 的栈是唯一的入口:

  #1 SSL_CTX_set_alpn_protos
  #2 uvcpp::uvcpp_ssl_context::set_alpn_protos    src/ssl/uvcpp_ssl_context.cpp:346
  #3 uvcpp::quic_detail::quic_session::setup_tls  src/quic/uvcpp_quic_session.cpp:439
  ...  ← uvcpp_quic_client::connect ← uvcpp_http_client::connect_quic ← client_run
  0x5020000260b0 is located 0 bytes inside of 3-byte region
  freed by thread T19 here: ... SSL_CTX_set_alpn_protos
  previously allocated by thread T20 here: ... CRYPTO_malloc

三字节 = "h3" 的 ALPN 线格式(长度前缀 0x02 + 'h' + '3')。`SSL_CTX_set_alpn_protos`
不是"写个字段":它 **free 掉 ctx 上旧的 `ext.alpn` 再 malloc 一份新的**。而 `setup_tls()`
在**客户端每一次会话**都无条件调它,落点却是共享的 `SSL_CTX` —— 两条线程各自读到同一个
旧指针、各 free 一次。触发条件正是"一个上下文被多条连接、多个线程共用",也就是阶段 2 的
QUIC 扇出(以及任何多线程客户端)把它变成常态的地方。

本库自己的 TCP / h1 / h2 客户端一直是**对的**:它们走 `uvcpp_ssl::set_alpn_protos()`
(`src/net/uvcpp_tcp_client.cpp:2420,2501`、`src/web/uvcpp_http_client.cpp:1066`),装的是
**本条连接**的 `SSL`。QUIC 会话是库里唯一一个往共享 ctx 上装的调用点 —— 所以这不是"用法
问题",是这一处偏离了库自己的既有做法。

服务端那一侧还有一个**同源**的竞争(这次没崩,只因为它写的是我们自己的 `std::string`、
不是 OpenSSL 的缓冲):闸门 `if (!ssl_ctx->has_alpn_select())` 与 `set_alpn_select_protos()`
里的 `alpn_select_wire_ = …` 并发就是边写边读一个 `std::string`,而"谁先装"恰好发生在
多条连接同时建起来的那一刻 —— 扇出之后必然发生。

## 改动

- `src/ssl/uvcpp_ssl_context.{h,cpp}`:新增 `apply_alpn_to_ssl(SSL*, protos)` —— 逐连接,
  编好线格式后调 `SSL_set_alpn_protos`;ctx 级的 `set_alpn_protos()` 与
  `set_alpn_select_protos()` 改到一把进程内的锁里;后者对同一份名单**幂等**(同一份不再
  碰 OpenSSL,于是并发的第一波安装只剩一个线程真正改 ctx);`has_alpn_select()` 从内联挪进
  `.cpp` 并进同一把锁。锁不做成成员:`uvcpp_ssl_context` 是公开类,加一个 `std::mutex`
  成员就改了它的布局;这把锁只覆盖"改名单"那几行,不进热路径。
- `src/quic/uvcpp_quic_session.cpp`:客户端那条腿挪到 `SSL_new` **之后**、装到 `ssl_` 上;
  服务端那条腿仍装 ctx(OpenSSL 只有 ctx 级的选择回调口),注释写清两条腿为什么不同、
  以及客户端为什么不能装共享 ctx。
- `CHANGELOG.md`:`### TLS 与网络` 末尾一条(`1.6.5-dev`)。

## 门禁与回归

- `build-rel15` 全量 ctest **126/126**(1 条 DB 用例按惯例 skip),含 `web_ssl_alpn_func`
  (ctx 级与逐连接两条 API 都覆盖)、`quic_*` / `http3_*` / `h2_*` / `web_ssl_*_h2` / `tcp_*`。
- `check_doc_versions.py` / `check_docs.py` / `check_ci_layout.py` / `check_capi_symbols.py`
  rc 全 0。`check_doc_lines.py` 这一提交是**故意红**的(本提交让
  `src/ssl/uvcpp_ssl_context.h` 长了一行,`doc/ssl-guide.md:43` 那条引用跟着挪):行号锁
  由本阶段末尾那条 docs 提交重刷,照 `8ed50fb` → `faf1e50` 的先例。
- 头部编码:`src/ssl/uvcpp_ssl_context.h` 仍带 BOM + LF(编辑会静默吃掉 BOM,已复核)。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants