baa-conductor


baa-conductor / bugs / archive
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 等待点都已同步修复,没有只修一处