codex@macbookpro
·
2026-03-27
BUG-011-writeHttpResponse-drain-handler-hangs.md
1# BUG-011: writeHttpResponse drain handler 可永久挂起
2
3## 状态
4
5- 已修复(2026-03-27,代码核对 + 自动化验证)
6
7## 当前代码结论
8
9- `apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
10- `payload.body` 写入和 `streamBody` 循环写入两条背压路径都改成同时处理 `drain` / `close` / `error`
11- 当连接在背压期间关闭时,`writeHttpResponse()` 会停止继续写入并清理 listener,不再永久 pending
12- `apps/conductor-daemon/src/index.test.js` 已补 3 条专项测试:
13 - body 背压后 close
14 - stream 背压后 close
15 - drain 触发后继续写完并正常结束
16- 验证结果:
17 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
18 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`31/31`)
19
20## 修复前现象
21
22当慢客户端或代理中间人在 HTTP 响应写入过程中断连时,conductor-daemon 的请求处理协程永远不返回,表现为请求计数只增不减、内存缓慢增长。
23
24- 哪个模块:`apps/conductor-daemon/src/index.ts`,`writeHttpResponse()` 函数
25- 返回了什么:无返回——协程挂死
26- 预期:连接断开后协程应正常退出
27- 复现条件:需要客户端在 write 返回 false(背压)后、drain 事件触发前断开连接
28
29## 修复前触发路径
30
31```text
32HTTP client -> conductor local API (any endpoint with large body or SSE stream)
33-> writeHttpResponse()
34-> response.write(chunk) returns false (backpressure)
35-> await new Promise(resolve => response.on("drain", resolve))
36-> client disconnects before drain fires
37-> Promise never resolves
38-> coroutine hangs forever
39```
40
41## 修复前根因
42
43`writeHttpResponse()` 中有两处 drain 等待逻辑:
44
451. 第 487 行附近:写 `payload.body` 时
462. 第 504 行附近:streamBody 循环中写每个 chunk 时
47
48两处都只监听了 `drain` 事件,没有监听 `close` / `error` 事件。Node.js HTTP response 在客户端断连后不会再触发 drain,只会触发 close。
49
50```typescript
51// 修复前代码
52if (!writableResponse.write(payload.body)) {
53 await new Promise<void>((resolve) => {
54 writableResponse.on?.("drain", resolve);
55 });
56}
57```
58
59## 复现步骤
60
61```bash
62# 1. 启动一个返回大 body 的请求(如 /v1/status/ui 的 HTML 页面)
63# 2. 用 nc 模拟慢客户端,收到 headers 后立即断开
64{
65 echo -e "GET /v1/status/ui HTTP/1.1\r\nHost: localhost\r\n\r\n";
66 sleep 0.1;
67} | nc 100.71.210.78 4317 | head -c 100
68# 3. 如果 response body > socket buffer,write 会返回 false
69# 4. nc 已断开,drain 永远不触发
70# 5. 对应的 handler 协程永远不退出
71```
72
73注意:在正常网络条件下不容易触发,需要 body 大到触发背压。SSE stream 路径更容易触发。
74
75## 修复前影响
76
77- 主流程不受影响(需要特定条件触发)
78- SSE stream(browser proxy 返回 claude.ai 的 SSE 流)是最可能的触发场景
79- 在 nginx 反代后面运行时,nginx 主动关闭超时连接可触发
80- 长期运行会导致协程泄漏和内存缓慢增长
81
82## 修复方案(已落地)
83
84### 方案 A(推荐)
85
86同时监听 drain 和 close/error,任一触发即 resolve:
87
88```typescript
89async function awaitDrain(response: ServerResponse): Promise<void> {
90 if (response.destroyed) return;
91 await new Promise<void>((resolve) => {
92 const onDrain = () => { cleanup(); resolve(); };
93 const onClose = () => { cleanup(); resolve(); };
94 const onError = () => { cleanup(); resolve(); };
95 const cleanup = () => {
96 response.off("drain", onDrain);
97 response.off("close", onClose);
98 response.off("error", onError);
99 };
100 response.on("drain", onDrain);
101 response.on("close", onClose);
102 response.on("error", onError);
103 });
104}
105```
106
107将 `writeHttpResponse()` 中两处 drain 等待替换为调用此函数。
108
109### 方案 B
110
111给 drain 等待加超时(例如 30s),超时后直接 resolve 并终止写入。简单但不够精确。
112
113## 严重程度
114
115Medium-High
116
117- 生产环境中 nginx 超时断连、客户端网络不稳定时会触发
118- SSE stream 路径最容易触发
119- 泄漏的协程和连接对象不会被 GC,长期运行有 OOM 风险
120
121## 发现时间
122
1232026-03-26 by Claude (code review)
124
125## 备注
126
127- 当前文档保留为问题归档;实际代码已完成修复
128- 两处 drain 等待点都已同步修复,没有只修一处