| 输入 | fix-plan-issue-15-16.md(19 项)+ openkal 体系适配 |
| 基线 | origin/master @ 2cec1c1(0.2.10) |
| 目标 | 0.3.0 —— 单 PR 全量实现 P0 + P1 + P2 + P3-A/B/D |
| 分支 | fix/pool-invariants-and-sigpipe |
原方案建议拆 4 个 PR(0.2.11 → 0.2.12 → 0.2.13 → 0.3.0)。这里合并成一个,理由是依赖方向而不是省事:
- P1 的十处补丁,和 P3-A 的 RAII guard,是同一件事的两种写法。先合 P1 再合 P3-A,等于把十个
connectionClose = true写进去、下一个 PR 再全部删掉。 - P2-C1/C2/C4(状态行、header 循环、Content-Length)在三个入口各有一份拷贝。P3-B 把三份合成一份之后,这三项只需要改一处;不合并就要改九处再删六处。
- §1.1 的矩阵证明:分批修 = 每批只落到一两份拷贝上。
#14就是这样引入 A1/A2 回归的。一个 PR 是让这张矩阵不再存在的唯一方式。
版本取 0.3.0 而不是 0.2.11:HttpResponse / HttpClientConfig 加了字段(非破坏),严格解析会把过去静默接受的畸形响应变成显式错误(行为变更)。语义化版本下,行为变更进 minor。
无感升级的边界(release note 必须点名):
- 现有代码不需要改一行就能编译:只加字段,不改签名,
ok()语义不变。 - 过去被静默接受的畸形响应现在会报错 —— 这不是新 bug,是过去在静默损坏数据。
| 视角 | 要求 | 落在哪 |
|---|---|---|
| 架构 | 池不变量不能靠十个早退路径各自记得维护 | P3-A PooledConnection(默认丢弃)+ P3-B 三份 reader 合一 |
| 稳定性 | 库不得杀死宿主进程;不得因一个 header 分配 2 GiB | P0(MSG_NOSIGNAL/SO_NOSIGPIPE)、P2-E2(分配上限) |
| 优雅简洁 | 一个概念一处实现 | 删 parse_hex;read_response_head / read_body 各一份;parse_status_line 一份 |
| 用户体验 | 调用方要能知道 body 被截断了;空闲连接被服务端关掉不该变成 No response |
P2-F1 bodyComplete+bodyError、P3-D 陈旧连接重试 |
| 兼容性 | 只加字段,不改签名,ok() 不变 |
HttpResponse / HttpClientConfig 追加字段;TlsSocket::read 保留 |
| 跨平台 | Linux / macOS / Windows / Termux / openkal 全绿 | #ifdef 而非 if constexpr(5e7d66f 的教训);平台事实集中在 platform.cppm |
| 一致性 | send / send_stream / download_to_file 三个入口行为逐条相同 |
P3-B 之后三者共用同一个 head/body reader |
| 无感升级 | 升级不需要改代码 | 见 §0 |
T0 分支 + 计划 ✔
│
├─ T1 P0 SIGPIPE(socket.cppm) 必须最先
│ │ P0-1 send() + MSG_NOSIGNAL
│ │ P0-2 socket() 后 setsockopt(SO_NOSIGPIPE)
│ │ 理由:T5 让 pool_.erase() 在更多路径上执行,
│ │ erase → ~TlsSocket → close_notify → send()。
│ │ T1 没做之前,T5 等于扩大 SIGPIPE 触发面。
│ │
│ ├─ T2 P2-D1/D2(tls.cppm / http.cppm 的 write_all)
│ │ read_some() 三态:Data / WouldBlock / Eof / Error
│ │ write_all 遇 WANT_WRITE 改为 wait_writable + 总时限
│ │ (T3 的 reader 建立在三态之上)
│ │
│ └─ T3 P3-B 统一 reader(http.cppm) ← 吸收 P2-C1/C2/C4 + P1-D1
│ parse_status_line(导出,严格)
│ read_response_head → expected<ResponseHead, string>
│ read_body → BodyOutcome{Complete|Truncated|ClosedByPeer|Stopped}
│ ├ 吸收 P1-B2(parse_chunk_size_line,删 parse_hex)
│ ├ 吸收 P2-C3(块后 CRLF 校验)
│ └ 吸收 P2-E2(分配上限,chunk 分片读)
│ │
│ ├─ T4 P3-A PooledConnection(默认丢弃)← 吸收 P1-A/B/C 全部十项
│ │ │ 递归重定向:递归前必须 keep() 或 drop(),不能让析构跨过递归
│ │ │
│ │ ├─ T5 P2-E1 bounded drain(重定向/非 2xx/文件打不开时省一次握手)
│ │ ├─ T6 P2-F1/F2 bodyComplete + bodyError(statusText 不再被覆盖)
│ │ └─ T7 P3-D 陈旧连接重试(一次,仅在零响应字节时)
│ │
│ └─ T8 纯单测:parse_status_line / parse_chunk_size_line / parse_content_length
│
├─ T9 集成测试:进程内 mbedtls TLS listener(T4 后)
│ tinyhttps 只支持 https,本地明文 listener 用不了 → 必须自带 TLS 服务端
│ T1..T11 用例见 §4
│
├─ T10 openkal 体系(与 T1..T9 并行调研,结论落地在 T1/platform.cppm)
│
└─ T11 文档 / release note / README ← 依赖全部
└─ T12 CI 全绿 → 自审 → PR → release → gtc 补 gitcode → mcpp-index → 生态验证
关键路径:T1 → T3 → T4 → T9 → T12。 可并行:T8、T10 与 T2..T7 无共享状态。
| 原 ID | 落在 | 备注 |
|---|---|---|
| P0-1 / P0-2 | T1 | 逐字采纳 |
| P1-A1/A2/A3、B1、C1/C2/C3/C4/C5 | T4 | 十项全部由 PooledConnection 默认丢弃消除,不逐条打补丁 |
| P1-B2 | T3 | 并入统一 chunk reader;parse_hex 删除 |
| P1-D1 | T3 | ResponseHead::contentLength 只有 int64_t 一种类型 |
| P2-C1 | T3 | 新增导出 parse_status_line |
| P2-C2 | T3 | read_response_head 全程用 read_complete_line |
| P2-C3 | T3 | 块后 CRLF 必须是空行 |
| P2-C4 | T3 | 统一 parse_content_length |
| P2-E1 | T5 | drain_body,上限 64 KiB |
| P2-E2 | T3 | maxResponseBodyBytes(默认 64 MiB);chunk 按固定缓冲分片读,不按块大小分配 |
| P2-D1 | T2 | TlsSocket::read_some 三态;read() 保留 |
| P2-D2 | T2 | write_all 用 wait_writable |
| P2-F1 | T6 | bodyComplete,ok() 不变(方案 a,非破坏) |
| P2-F2 | T6 | statusText 不再被错误信息覆盖;原因进 bodyError |
| P3-A | T4 | 采纳 |
| P3-B | T3 | 采纳(head + body 都合) |
| P3-C 带缓冲的读 | 不做 | 见 §5 |
| P3-D | T7 | 采纳 curl 策略 + retryOnStaleConnection 开关(默认 true) |
- P2-F1
ok()是否要求bodyComplete→ 不要求(方案 a)。目标写明「无感升级」;让ok()变严会让现有代码在升级后行为改变,与之直接冲突。截断可见性由新字段提供。 - P3-D 是否限制到幂等方法 → 不限制(curl 策略),加
retryOnStaleConnection开关。重试窗口是「一个响应字节都没收到」,此时服务端已发 FIN、应用层没看到这个请求;限制到幂等方法等于对 LLM/API 客户端(POST 为主)这个修复基本无效。
parse_status_line / parse_chunk_size_line / parse_content_length / append_within_limit 表驱动。
其中 parse_status_line 必须覆盖 issue #15 的原始输入 "BBBB 999 XHTTP/1.1 200 OK" → 拒绝。
为什么必须自带 TLS 服务端:send/send_stream/download_to_file 三个入口开头都有
if (parsed.scheme != "https") return "Only HTTPS is supported",本地明文 listener 根本进不去。
所以测试自带一个进程内 mbedtls TLS listener(自签证书,verifySsl = false),
MBEDTLS_SSL_SRV_C / MBEDTLS_PEM_PARSE_C 在依赖的 mbedtls 3.6.1 配置里已开启(已核验)。
| # | 用例 | 断言 | 覆盖 |
|---|---|---|---|
| T1 | 服务端发一半 CL body 后挂住 | 请求 2 statusCode == 200 |
P1-A1 |
| T2 | 同上,并断言服务端 accept 次数 == 2 | 唯一能抓住「看起来对了」的断言 | P1-A1 |
| T3 | chunked 版本同上 | 同 T1+T2 | P1-A3 |
| T4 | 服务端发一半后关连接 | 不崩;进程未被信号杀死 | #16 |
| T5 | SIGPIPE 默认处置下跑 T4(子进程) |
WIFSIGNALED == false,退出码 ≠ 141 |
P0 |
| T6 | Content-Length: 4294967296 |
不得当成 0;连接不得回池 | P1-D1 |
| T7 | 302 + CL body + keep-alive → 同 host | 内容正确 + accept 次数正确 | P1-C3 |
| T8 | 404 + error body + keep-alive,同 client 再发一个 | 第二个请求正确 | P1-C4 |
| T9 | chunk 头是 zz / 空行 |
报错,不得当成终止块 | P1-B2 |
| T10 | 状态行 BBBB 999 XHTTP/1.1 200 OK |
Invalid status line,不是 999 |
P2-C1 |
| T11 | 截断的 body | bodyComplete == false |
P2-F1 |
| T12 | 正常 keep-alive 两请求 | accept 次数 == 1(不能因为修得太狠而丢掉复用) | 回归护栏 |
| T13 | 服务端在两请求之间关闭空闲连接 | 请求 2 成功,accept 次数 == 2 | P3-D |
T12 与 T2 是一对:T2 保证脏连接被丢弃,T12 保证干净连接被保留。只有 T2 会让「关掉连接池」这种过度修复也通过。
| 项 | 不做的理由 |
|---|---|
| P3-C 带缓冲的读 | 原方案自陈「不要当成性能修复来卖」,其主要理由是「让『这条连接干净吗』可回答」——而 T4 的 guard + T3 的严格 framing 已经把这个问题回答了:干净 = read_body 返回 Complete。缓冲区是第二种表达,不是必需的一种。单独立项。 |
| openkal 原生 socket 后端 | 见 §6。 |
调研 T10 输出决定落点;先记录已核验的事实:
openkal-musl/port/src/okm_net.c:546-550:MSG_NOSIGNAL被接受并忽略(注释原文:"asks that a signal not be raised. There are no signals here"),rest = flags & ~(MSG_NOSIGNAL | MSG_DONTWAIT),其余 flag 返回-ENOSYS。 ⇒ P0-1 在 openkal 体系上是正确且无副作用的,而且必须写成#ifdef MSG_NOSIGNAL(否则 macOS 分支编不过,见5e7d66f)。SO_NOSIGPIPE是 BSD/macOS 的拼法,musl 头文件不定义 ⇒ P0-2 在 openkal-musl 上自动不编入。
- T1 → T2 → T3 → T4 → T5/T6/T7(实现)
- T8 / T9(测试)
- T10 结论落地
- T11 文档 + release note
- 本地
mcpp build && mcpp test全绿 → 推 PR → CI 全绿 → 自审 → 合并 - tag + GitHub release →
gtc本地补 gitcode 资源(不等 release CI) - mcpp-index 增加 0.3.0 条目 → 生态真实验证