- commit
- c87af13
- parent
- 7ba57c9
- author
- jiaozhiwang
- date
- 2026-03-26 20:22:30 +0800 CST
docs: add BUG-011/012/013 and code review discuss
4 files changed,
+631,
-0
1@@ -0,0 +1,112 @@
2+# BUG-011: writeHttpResponse drain handler 可永久挂起
3+
4+## 现象
5+
6+当慢客户端或代理中间人在 HTTP 响应写入过程中断连时,conductor-daemon 的请求处理协程永远不返回,表现为请求计数只增不减、内存缓慢增长。
7+
8+- 哪个模块:`apps/conductor-daemon/src/index.ts`,`writeHttpResponse()` 函数
9+- 返回了什么:无返回——协程挂死
10+- 预期:连接断开后协程应正常退出
11+- 复现条件:需要客户端在 write 返回 false(背压)后、drain 事件触发前断开连接
12+
13+## 触发路径
14+
15+```text
16+HTTP client -> conductor local API (any endpoint with large body or SSE stream)
17+-> writeHttpResponse()
18+-> response.write(chunk) returns false (backpressure)
19+-> await new Promise(resolve => response.on("drain", resolve))
20+-> client disconnects before drain fires
21+-> Promise never resolves
22+-> coroutine hangs forever
23+```
24+
25+## 根因
26+
27+`writeHttpResponse()` 中有两处 drain 等待逻辑:
28+
29+1. 第 487 行附近:写 `payload.body` 时
30+2. 第 504 行附近:streamBody 循环中写每个 chunk 时
31+
32+两处都只监听了 `drain` 事件,没有监听 `close` / `error` 事件。Node.js HTTP response 在客户端断连后不会再触发 drain,只会触发 close。
33+
34+```typescript
35+// 当前代码
36+if (!writableResponse.write(payload.body)) {
37+ await new Promise<void>((resolve) => {
38+ writableResponse.on?.("drain", resolve);
39+ });
40+}
41+```
42+
43+## 复现步骤
44+
45+```bash
46+# 1. 启动一个返回大 body 的请求(如 /v1/status/ui 的 HTML 页面)
47+# 2. 用 nc 模拟慢客户端,收到 headers 后立即断开
48+{
49+ echo -e "GET /v1/status/ui HTTP/1.1\r\nHost: localhost\r\n\r\n";
50+ sleep 0.1;
51+} | nc 100.71.210.78 4317 | head -c 100
52+# 3. 如果 response body > socket buffer,write 会返回 false
53+# 4. nc 已断开,drain 永远不触发
54+# 5. 对应的 handler 协程永远不退出
55+```
56+
57+注意:在正常网络条件下不容易触发,需要 body 大到触发背压。SSE stream 路径更容易触发。
58+
59+## 当前影响
60+
61+- 主流程不受影响(需要特定条件触发)
62+- SSE stream(browser proxy 返回 claude.ai 的 SSE 流)是最可能的触发场景
63+- 在 nginx 反代后面运行时,nginx 主动关闭超时连接可触发
64+- 长期运行会导致协程泄漏和内存缓慢增长
65+
66+## 修复建议
67+
68+### 方案 A(推荐)
69+
70+同时监听 drain 和 close/error,任一触发即 resolve:
71+
72+```typescript
73+async function awaitDrain(response: ServerResponse): Promise<void> {
74+ if (response.destroyed) return;
75+ await new Promise<void>((resolve) => {
76+ const onDrain = () => { cleanup(); resolve(); };
77+ const onClose = () => { cleanup(); resolve(); };
78+ const onError = () => { cleanup(); resolve(); };
79+ const cleanup = () => {
80+ response.off("drain", onDrain);
81+ response.off("close", onClose);
82+ response.off("error", onError);
83+ };
84+ response.on("drain", onDrain);
85+ response.on("close", onClose);
86+ response.on("error", onError);
87+ });
88+}
89+```
90+
91+将 `writeHttpResponse()` 中两处 drain 等待替换为调用此函数。
92+
93+### 方案 B
94+
95+给 drain 等待加超时(例如 30s),超时后直接 resolve 并终止写入。简单但不够精确。
96+
97+## 严重程度
98+
99+Medium-High
100+
101+- 生产环境中 nginx 超时断连、客户端网络不稳定时会触发
102+- SSE stream 路径最容易触发
103+- 泄漏的协程和连接对象不会被 GC,长期运行有 OOM 风险
104+
105+## 发现时间
106+
107+2026-03-26 by Claude (code review)
108+
109+## 备注
110+
111+- 当前未在线上明确观察到,但 nginx 502 超时场景下可能已经触发过
112+- streamBody 循环中的同一模式也需要同步修复
113+- 修复后建议加单元测试:mock 一个 write 返回 false 后立即 destroy 的 response 对象
1@@ -0,0 +1,134 @@
2+# BUG-012: browser-request-policy waiter 无超时,slot 泄漏导致目标永久死锁
3+
4+## 现象
5+
6+当某个 browser request 的 policy lease 没有被正常 `complete()` 时(如 Firefox 断连、conductor 内部异常),对同一 `(clientId, platform)` 目标的所有后续请求永久挂起,表现为 `/v1/browser/request` 或 `/v1/browser/claude/send` 无响应。
7+
8+- 哪个模块:`apps/conductor-daemon/src/browser-request-policy.ts`
9+- 返回了什么:无返回——请求永久等待
10+- 预期:超时后应该返回错误或自动释放 slot
11+- 是否稳定复现:需要特定异常路径触发,但一旦触发即永久生效
12+
13+## 触发路径
14+
15+```text
16+POST /v1/browser/request (或 /v1/browser/claude/send)
17+-> beginBrowserRequestLease()
18+-> BrowserRequestPolicyController.beginRequest()
19+-> acquireTargetSlot() — 获得 slot,inFlight 变为 1
20+-> admitRequest() — 等待限流/抖动
21+-> 开始执行浏览器代发请求
22+-> Firefox 插件 WS 断连 / conductor 内部 throw 未被 catch
23+-> lease.complete() 从未被调用
24+-> slot 永久被占用(inFlight 始终为 1)
25+-> 后续请求进入 acquireTargetSlot() -> waiters.push(resolve) -> 永不 resolve
26+```
27+
28+## 根因
29+
30+两个独立问题共同导致:
31+
32+### 问题 1:acquireTargetSlot waiter 无超时
33+
34+```typescript
35+private async acquireTargetSlot(state: BrowserRequestTargetState): Promise<void> {
36+ if (state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
37+ && state.waiters.length === 0) {
38+ state.inFlight += 1;
39+ return;
40+ }
41+ // 这个 Promise 永远没有 reject/timeout 机制
42+ await new Promise<void>((resolve) => {
43+ state.waiters.push(resolve);
44+ });
45+}
46+```
47+
48+### 问题 2:acquirePlatformAdmission 同样无超时
49+
50+```typescript
51+private async acquirePlatformAdmission(state: BrowserRequestPlatformState): Promise<void> {
52+ if (!state.busy) {
53+ state.busy = true;
54+ return;
55+ }
56+ await new Promise<void>((resolve) => {
57+ state.waiters.push(resolve);
58+ });
59+}
60+```
61+
62+### 问题 3:lease 的 complete() 不保证被调用
63+
64+`BrowserRequestPolicyLeaseImpl.complete()` 由调用方手动调用,但如果调用方 throw 了且没有 finally 保护,complete 就不会被调用。
65+
66+## 复现步骤
67+
68+```bash
69+TOKEN="<BAA_SHARED_TOKEN>"
70+
71+# 1. 发起一个 browser request
72+curl -s https://conductor.makefile.so/v1/browser/claude/send \
73+ -X POST -H "Content-Type: application/json" \
74+ -H "Authorization: Bearer $TOKEN" \
75+ -d '{"message":"test"}'
76+
77+# 2. 在请求执行过程中,断开 Firefox 插件的 WS 连接(关闭 Firefox 或重启插件)
78+
79+# 3. 如果 bridge error 的处理路径没有调 lease.complete(),slot 泄漏
80+
81+# 4. 后续对 claude 平台的所有 browser request 永久挂起
82+curl -s https://conductor.makefile.so/v1/browser/claude/send \
83+ -X POST -H "Content-Type: application/json" \
84+ -H "Authorization: Bearer $TOKEN" \
85+ -d '{"message":"this will hang forever"}'
86+```
87+
88+注意:需要在精确的时间窗口内断开 Firefox 才能触发。但当前 `maxInFlightPerClientPlatform = 1`,意味着只需一次泄漏就会死锁。
89+
90+## 当前影响
91+
92+- 当前是潜在风险,尚未在线上明确观察到
93+- `maxInFlightPerClientPlatform = 1` 意味着一次泄漏 = 该 target 永久不可用
94+- task scheduler 上线后,并发调用 browser request 的概率增大,触发风险提高
95+- 重启 conductor-daemon 可恢复(内存状态清空)
96+
97+## 修复建议
98+
99+### 方案 A(推荐:多层防御)
100+
101+同时修三个问题:
102+
103+**A1. waiter 加超时(120s)**
104+
105+给 `acquireTargetSlot` 和 `acquirePlatformAdmission` 的 waiter Promise 加超时,超时后从 waiters 数组中移除自己,reject 为 `BrowserRequestPolicyError("slot_wait_timeout")`。
106+
107+**A2. lease 加 safety net**
108+
109+在 `executeBrowserRequest`(local-api.ts)中确保所有退出路径都调 `lease.complete()`,使用 try/finally 包裹。
110+
111+**A3. slot 泄漏自愈检测**
112+
113+定期(例如每 60s)扫描 targets Map,如果某个 target 的 `inFlight > 0` 但最后一次 admit 已超过 5 分钟,强制重置 `inFlight = 0` 并释放一个 waiter。
114+
115+### 方案 B
116+
117+只做 A2(保证 finally 调 complete),不改 waiter 超时。更简单但防御层次少。
118+
119+## 严重程度
120+
121+Medium
122+
123+- 当前不常触发,但一旦触发即永久死锁(直到重启)
124+- task scheduler 上线后风险升高
125+- `maxInFlightPerClientPlatform = 1` 放大了影响——零容错
126+
127+## 发现时间
128+
129+2026-03-26 by Claude (code review)
130+
131+## 备注
132+
133+- 可以通过 `/v1/browser/status` 返回的 policy snapshot 中的 `targets[].inFlight` 和 `targets[].waiting` 观测是否已经发生泄漏
134+- 临时绕过方式:重启 conductor-daemon
135+- 修复后建议补测试:mock 一个 beginRequest 后不调 complete 的场景,验证超时后 waiter 被正确释放
1@@ -0,0 +1,112 @@
2+# BUG-013: firefox-bridge stream session 关闭时未清除 openTimer/idleTimer
3+
4+## 现象
5+
6+stream session 关闭后,先前设置的 `openTimer` 和 `idleTimer` 仍在运行。timer 触发时由于 `closed` flag 保护不会产生功能异常,但 session 对象在 timer 到期前无法被 GC。
7+
8+- 哪个模块:`apps/conductor-daemon/src/firefox-bridge.ts`,`FirefoxBridgeApiStreamSession` 类
9+- 返回了什么:功能正常,但存在不必要的内存驻留
10+- 预期:session 关闭时应立即清除所有 timer
11+- 是否稳定复现:每次 stream session 正常结束或异常关闭都会触发
12+
13+## 触发路径
14+
15+```text
16+POST /v1/browser/request (responseMode: "sse")
17+-> 创建 FirefoxBridgeApiStreamSession
18+-> resetOpenTimer() — 设置 openTimeoutMs timer
19+-> 收到 stream_open → resetIdleTimer() — 设置 idleTimeoutMs timer
20+-> 收到 stream_end → finishWithEvent()
21+-> closed = true, onClose() 被调用, waiters 被清空
22+-> 但 idleTimer 仍在运行
23+-> idleTimer 触发 → fail() → markError() → closed 检查返回 false
24+-> session 对象直到 timer 触发才能被 GC
25+```
26+
27+## 根因
28+
29+`finishWithEvent()` 方法中只做了三件事:
30+
31+1. 设置 `closed = true`
32+2. 入队最后一个事件或 resolve waiter
33+3. 调用 `onClose(requestId)`
34+
35+没有清除 `openTimer` 和 `idleTimer`。
36+
37+相关代码位于 `FirefoxBridgeApiStreamSession` 的私有方法中:
38+
39+- `resetOpenTimer()` 设置 `this.openTimer = setTimeoutImpl(...)`
40+- `resetIdleTimer()` 设置 `this.idleTimer = setTimeoutImpl(...)`
41+- `finishWithEvent()` 没有调用 `clearTimeoutImpl()` 清除这两个 timer
42+
43+## 复现步骤
44+
45+```bash
46+TOKEN="<BAA_SHARED_TOKEN>"
47+
48+# 发起一个 SSE 模式的 browser request
49+curl -s https://conductor.makefile.so/v1/browser/request \
50+ -X POST -H "Content-Type: application/json" \
51+ -H "Authorization: Bearer $TOKEN" \
52+ -d '{
53+ "platform": "claude",
54+ "path": "/api/organizations",
55+ "method": "GET",
56+ "responseMode": "sse"
57+ }'
58+
59+# 请求正常完成后,session 的 idleTimer(默认 30s)仍在运行
60+# 30s 后 timer 触发,尝试 fail() 但被 closed 保护
61+# 这 30s 内 session 对象无法被 GC
62+```
63+
64+## 当前影响
65+
66+- 不影响功能正确性(`closed` flag 做了保护)
67+- 每个 stream session 关闭后最多多占用 30s(idleTimeoutMs)内存
68+- 对于 openTimeout(10s),如果 stream 在 open 之前就关闭了,会多占 10s
69+- 在高频 SSE 请求场景下,内存占用会有可观察的波动
70+
71+## 修复建议
72+
73+### 方案 A(推荐)
74+
75+在 `finishWithEvent()` 方法的开头清除所有 timer:
76+
77+```typescript
78+private finishWithEvent(event: FirefoxBridgeStreamEvent): boolean {
79+ // 清除所有待触发的 timer
80+ if (this.openTimer != null) {
81+ this.clearTimeoutImpl(this.openTimer);
82+ this.openTimer = null;
83+ }
84+ if (this.idleTimer != null) {
85+ this.clearTimeoutImpl(this.idleTimer);
86+ this.idleTimer = null;
87+ }
88+
89+ // 原有逻辑
90+ this.closed = true;
91+ // ...
92+}
93+```
94+
95+同时在 `cancel()` 方法中也加同样的清理(cancel 最终会调 fail -> markError -> finishWithEvent,所以只要 finishWithEvent 里做了就够,但显式清理更安全)。
96+
97+## 严重程度
98+
99+Low
100+
101+- 有 `closed` flag 保护,不会导致功能错误
102+- 影响仅为短暂内存延迟释放(最多 30s)
103+- 不会累积(timer 触发后对象即可被 GC)
104+
105+## 发现时间
106+
107+2026-03-26 by Claude (code review)
108+
109+## 备注
110+
111+- 修复非常简单,4 行代码
112+- 建议修复后补单元测试:创建 stream session → markEnd() → 验证 openTimer 和 idleTimer 都被清除
113+- 可以和 BUG-011、BUG-012 一起修,同一个 PR
+273,
-0
1@@ -0,0 +1,273 @@
2+# DISCUSS: 代码审查 — Bug、风险与优化建议
3+
4+日期:2026-03-26
5+来源:Claude 审查 conductor-daemon、codexd、browser-request-policy、firefox-bridge、firefox-ws 全部 TypeScript 源码
6+
7+---
8+
9+## 进度评估
10+
11+代码成熟度相当高。T-S001~T-S020 的质量在代码层面是可验证的:
12+
13+- conductor-daemon 的 leader lease / heartbeat / scheduler 三件套完整,单节点自选举可用
14+- codexd 的 app-server 模式(stdio transport)完整,session/turn 生命周期管理到位
15+- firefox-bridge 的 SSE stream session 有 open/idle timeout、buffer overflow 保护、seq tracking,设计远超 MVP
16+- browser-request-policy 的限流/抖动/退避/熔断全部实现且已参数化
17+- 手写 WebSocket 帧编解码(firefox-ws.ts)避免了 ws 库依赖,轻量且正确
18+
19+总体评价:**可上线质量的 MVP**,以下发现的问题多数是边缘场景。
20+
21+---
22+
23+## Bug(需修复)
24+
25+### BUG-A: writeHttpResponse drain handler 可能永远不触发
26+
27+**文件**:`apps/conductor-daemon/src/index.ts`,`writeHttpResponse()` 函数
28+
29+**问题**:
30+
31+```typescript
32+if (!writableResponse.write(payload.body)) {
33+ await new Promise<void>((resolve) => {
34+ writableResponse.on?.("drain", resolve);
35+ });
36+}
37+```
38+
39+如果连接在 `write` 返回 false 之后、drain 触发之前被关闭(客户端断开),这个 Promise 永远不 resolve。结果:
40+
41+- 请求处理协程挂死
42+- 对应的 HTTP server connection 对象无法被 GC
43+- 如果并发请求多,会逐渐耗尽内存
44+
45+同样的模式在 streamBody 循环里也出现了一次。
46+
47+**修复建议**:
48+
49+```typescript
50+await new Promise<void>((resolve) => {
51+ const onDrain = () => { cleanup(); resolve(); };
52+ const onClose = () => { cleanup(); resolve(); };
53+ const cleanup = () => {
54+ writableResponse.off?.("drain", onDrain);
55+ writableResponse.off?.("close", onClose);
56+ };
57+ writableResponse.on?.("drain", onDrain);
58+ writableResponse.on?.("close", onClose);
59+});
60+```
61+
62+**严重度**:Medium-High(生产环境慢客户端/代理断连时会触发)
63+
64+---
65+
66+### BUG-B: browser-request-policy waiter 无超时,可永久挂起
67+
68+**文件**:`apps/conductor-daemon/src/browser-request-policy.ts`
69+
70+**问题**:
71+
72+```typescript
73+private async acquireTargetSlot(state: BrowserRequestTargetState): Promise<void> {
74+ if (state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
75+ && state.waiters.length === 0) {
76+ state.inFlight += 1;
77+ return;
78+ }
79+ await new Promise<void>((resolve) => {
80+ state.waiters.push(resolve);
81+ });
82+}
83+```
84+
85+如果持有 slot 的请求因为 Firefox 断连或 conductor 内部错误而没有调用 `lease.complete()`,这个 waiter Promise 永远不被 resolve。后续所有对同一 target 的请求都会卡死。
86+
87+`acquirePlatformAdmission` 有同样的问题。
88+
89+**修复建议**:
90+
91+1. 给 waiter 加超时(例如 120s),超时后 reject 并从 waiters 数组中移除
92+2. 或者在 `BrowserRequestPolicyLeaseImpl` 的析构/finalize 路径中确保 `complete()` 一定被调用
93+3. 在 `completeRequest` 的 `releaseTargetSlot` 中加防御:如果 `inFlight` 已经是 0 但 waiters 非空,说明 slot 泄漏,应主动释放一个 waiter
94+
95+**严重度**:Medium(当前 maxInFlightPerClientPlatform=1,一次泄漏就会永久卡死该 target)
96+
97+---
98+
99+### BUG-C: stream session openTimer/idleTimer 在 closed 后可能触发
100+
101+**文件**:`apps/conductor-daemon/src/firefox-bridge.ts`,`FirefoxBridgeApiStreamSession`
102+
103+**问题**:
104+
105+`resetOpenTimer` 和 `resetIdleTimer` 设置了 setTimeout,但 `finishWithEvent`(关闭 session)只把 `closed = true`,没有清除 openTimer 和 idleTimer。如果关闭后 timer 触发,会调用 `fail()` → `markError()` → 检查 `closed` 返回 false,看起来安全。但 `fail` 内部会再调 `markError` → `finishWithEvent`,其中 `onClose(requestId)` 会被重复调用(如果 timer 在 close 之后的事件循环 tick 中触发)。
106+
107+**实际影响**:较小,因为 `closed` flag 做了保护。但 timer 没清会导致 session 对象无法被 GC 直到 timer 触发。
108+
109+**修复建议**:在 `finishWithEvent` 中加:
110+
111+```typescript
112+if (this.openTimer) { this.clearTimeoutImpl(this.openTimer); this.openTimer = null; }
113+if (this.idleTimer) { this.clearTimeoutImpl(this.idleTimer); this.idleTimer = null; }
114+```
115+
116+**严重度**:Low(有 closed 保护,影响仅是短暂内存延迟释放)
117+
118+---
119+
120+## 风险(非 Bug 但需关注)
121+
122+### RISK-1: local-api.ts 5434 行,单文件过大
123+
124+**问题**:
125+
126+一个文件承载了全部 HTTP 路由处理(describe、browser、codex、host-ops、tasks、status),任何改动都需要在 5000+ 行中定位,merge conflict 概率高。
127+
128+**建议**:按 route group 拆分为:
129+
130+| 文件 | 职责 | 预估行数 |
131+|---|---|---|
132+| `local-api.ts` | 路由分发 + 共享工具 | ~500 |
133+| `routes/describe.ts` | describe 读面 | ~400 |
134+| `routes/browser.ts` | browser 系列 | ~1500 |
135+| `routes/codex.ts` | codex 系列 | ~300 |
136+| `routes/host-ops.ts` | exec/files | ~200 |
137+| `routes/tasks.ts` | tasks/runs | ~300 |
138+| `routes/system.ts` | system state/pause/resume | ~200 |
139+
140+**优先级**:Medium(不影响功能,但影响开发效率和 codex 任务分配)
141+
142+---
143+
144+### RISK-2: normalizeOptionalString 重复 7 次
145+
146+**文件**:分布在 conductor-daemon (4处)、codexd (1处)、status-api (1处)、codex-exec (1处)
147+
148+**问题**:
149+
150+同一个函数被复制了 7 次,且签名不完全一致:
151+- 4 处接受 `string | null | undefined`
152+- 2 处接受 `unknown`
153+- 1 处返回 failure 对象
154+
155+**建议**:提取到 `packages/shared-utils/` 或直接放在已有的 `packages/logging/` 中导出。
156+
157+**优先级**:Low(不影响功能,但增加维护负担)
158+
159+---
160+
161+### RISK-3: @ts-ignore 导入 status-api 构建产物
162+
163+**文件**:`apps/conductor-daemon/src/local-api.ts` 顶部
164+
165+```typescript
166+// @ts-ignore conductor reuses the built status-api snapshot normalizer directly.
167+import { createStatusSnapshotFromControlApiPayload } from "../../status-api/dist/...";
168+// @ts-ignore conductor reuses the built status-api HTML renderer directly.
169+import { renderStatusPage } from "../../status-api/dist/...";
170+```
171+
172+**问题**:
173+
174+- `@ts-ignore` 跳过了类型检查,如果 status-api 的导出接口变了,编译不会报错,运行时才崩
175+- conductor-daemon 构建依赖 status-api 已完成构建,增加了构建顺序耦合
176+- 这是 STATUS_SUMMARY 里提到的已知技术债,但还没有跟踪卡
177+
178+**建议**:
179+
180+1. 近期:把 `createStatusSnapshotFromControlApiPayload` 和 `renderStatusPage` 提取到 `packages/status-renderer/`
181+2. 远期:status-api 瘦身后直接合入 conductor-daemon
182+
183+**优先级**:Medium(已在 STATUS_SUMMARY 低优先级 backlog 中,但缺正式任务卡)
184+
185+---
186+
187+### RISK-4: codexd ensureAppServerClient 并发初始化窗口
188+
189+**文件**:`apps/codexd/src/daemon.ts`,`ensureAppServerClient()`
190+
191+**问题**:
192+
193+初始化失败后 `finally` 中设 `appServerInitializationPromise = null`,如果多个 caller 同时等待,失败后它们都会看到 null 并各自发起新的初始化。虽然功能上是 retry,但可能导致并发创建多个 transport 连接。
194+
195+**实际影响**:较小(当前 codexd 是串行使用的),但 task scheduler 上线后可能并发调用。
196+
197+**建议**:加一个 `initializationInProgress` lock 或 cool-down 计时器。
198+
199+**优先级**:Low(task scheduler 上线前不会触发)
200+
201+---
202+
203+### RISK-5: writeHttpResponse streamBody 循环无 abort 检查
204+
205+**文件**:`apps/conductor-daemon/src/index.ts`,`writeHttpResponse()`
206+
207+**问题**:
208+
209+```typescript
210+for await (const chunk of payload.streamBody) {
211+ if (writableResponse.destroyed === true) { break; }
212+ ...
213+}
214+```
215+
216+只检查了 `destroyed`,没检查传入的 `request.signal`。如果客户端取消请求但 socket 还没 destroy(比如 HTTP/2 RST_STREAM),async generator 会继续迭代直到自然结束。
217+
218+**实际影响**:较小(当前 SSE stream 有 idle timeout 保护)。
219+
220+**优先级**:Low
221+
222+---
223+
224+## 优化建议
225+
226+### OPT-1: browser-request-policy 的 Map 永远不清理
227+
228+`platforms` 和 `targets` 两个 Map 只增不删。如果长时间运行且 Firefox 重连时 clientId 变化,旧的 targetState 会累积。
229+
230+建议加一个定期清理(比如每小时清除 lastSuccessAt 超过 24h 且 inFlight=0 且 waiters=[] 的 target)。
231+
232+### OPT-2: conductor-daemon index.ts 也有 1300+ 行
233+
234+`ConductorDaemon` 和 `ConductorRuntime` 类 + CLI 解析 + 辅助函数都在一个文件里。可以拆分为:
235+
236+- `daemon.ts` — ConductorDaemon 类
237+- `runtime.ts` — ConductorRuntime 类
238+- `cli.ts` — CLI 解析和 main 入口
239+- `config.ts` — 配置解析和校验
240+
241+### OPT-3: 手写 WebSocket 帧编解码没有处理分片帧
242+
243+`firefox-ws.ts` 的帧解析只处理 FIN=1 的完整帧。如果 Firefox 插件发送分片帧(FIN=0),会被当作无效帧丢弃。当前可能不会触发(浏览器扩展通常不分片),但不符合 RFC 6455。
244+
245+---
246+
247+## 测试覆盖评估
248+
249+- conductor-daemon 有 `index.test.js`,覆盖了 daemon 生命周期和 fixture 清理
250+- codexd 有 `index.test.js`,覆盖了 session/turn 流程
251+- browser-request-policy 的限流/熔断逻辑**没有专门测试文件**
252+- firefox-bridge 的 stream session(最复杂的组件之一)**没有专门测试文件**
253+- firefox-ws 的帧编解码**没有专门测试文件**
254+
255+建议优先补 browser-request-policy 和 firefox-bridge 的单元测试。
256+
257+---
258+
259+## 总结
260+
261+| # | 类型 | 问题 | 严重度 | 行动 |
262+|---|---|---|---|---|
263+| BUG-A | Bug | drain handler 永久挂起 | Medium-High | 加 close 监听 |
264+| BUG-B | Bug | policy waiter 无超时 | Medium | 加超时或保证 complete |
265+| BUG-C | Bug | stream timer 未清除 | Low | finishWithEvent 里清 timer |
266+| RISK-1 | 风险 | local-api.ts 5434 行 | Medium | 拆分路由模块 |
267+| RISK-2 | 风险 | normalizeOptionalString x7 | Low | 提共享包 |
268+| RISK-3 | 风险 | @ts-ignore 导入 | Medium | 提共享包 |
269+| RISK-4 | 风险 | ensureAppServerClient 并发 | Low | 加 lock |
270+| RISK-5 | 风险 | stream 无 abort 检查 | Low | 检查 signal |
271+| OPT-1 | 优化 | policy Map 不清理 | Low | 定期清理 |
272+| OPT-2 | 优化 | index.ts 1300+ 行 | Low | 拆文件 |
273+| OPT-3 | 优化 | WS 不处理分片帧 | Low | 按需处理 |
274+| TEST | 测试 | policy/bridge/ws 缺测试 | Medium | 补单元测试 |