Files
HomeAgent/internal
JianFeeeee 7457b052a7 fix(webui): streamOpenAI 改为 w 单写者模型,修 gzipResponseWriter 真数据竞争
根因(-race 实测,非推测):
streamOpenAI 的 handler goroutine 在收尾处直接 fmt.Fprintf(w, finish chunk)、
fmt.Fprintf(w, "data: [DONE]")、flusher.Flush(),与增量 flush goroutine
(16ms 批量合并)**并发写同一个 http.ResponseWriter**。经 gzip 中间件时,
gzipResponseWriter 的 mode / wroteHeader / buf 三个字段无锁被两方同时读写:

  Read  gzipResponseWriter.Write    gzip.go:139(读 g.mode)
        └─ streamOpenAI handler goroutine        handler_openai.go:256
  Write gzipResponseWriter.commit   gzip.go:206(写 g.mode/wroteHeader)
        └─ streamOpenAI 写 goroutine             handler_openai.go:165

修法选择:改写并发模型(单写者),不给 gzipResponseWriter 加锁。
理由两条:
1. 加锁只哄住 race detector,是治标 —— 并发写同一个 ResponseWriter 这件事
   本身就该被结构禁止,而不是靠锁逐字段兜。
2. 持锁要跨 Write / Flush 这类网络 IO,一旦客户端慢,整条 SSE 会串行卡在
   锁上(锁内做 IO)。单写者模型从结构上消灭竞争,且零锁开销。

改动:
- 新增 finishCh(chan finishItem,buf=1)+ writerExit。终止帧(finish chunk
  或超时错误 + [DONE] + Flush)也交给那个唯一的写 goroutine 发。
- 新增 sendFinish(usage, timedOut):**先投递再等 writerExit**。
  顺序不能反 —— writer goroutine 只在收到 finishCh 或 done 关闭时退出,而
  done 在 defer 里关(函数返回之后),先等后发即死锁。select 里的 <-done
  是防御路径:done 已关时 writer 可能已不在,不该再等一个永不到来的接收者。
- 超时路径与正常收尾路径都改走 sendFinish,handler goroutine 不再碰 w。
- writerExit 用 safeclose.Safe 关闭,遵守本仓 close 纪律
  (internal/safeclose/TestNoBareCloseOutsideOutsideSafeclose 禁裸 close(chan))。
- 顺带修掉一个既有逻辑缺陷:旧写法下 [DONE] 可能先于 deltaCh 里尚未发完的
  尾部 delta 到达客户端;现在先 flushPending 再发终止帧。

验证(全部 -count=1,除注明者):
- 基线(f408448 原版)TestOpenAIStreamIsActuallyStreaming + -race:
  单跑 5/5 次各 8 条 DATA RACE 且 FAIL;-count=5 时 10 / 12 / 10 条。
- 修复后同一用例 + -race:单跑 6/6 次 races=0 PASS。
- 修复后整包 go test -race ./internal/plugins/webui/:3/3 次 races=0 PASS
  (63s / 69s / 64s)。
- go build ./... 通过;go vet ./... 通过;go test ./... 58 包 ok、0 FAIL、
  0 WARNING: DATA RACE。

判据:cr-HomeAgent-6c0d1e07(stress),带变异 M1「sendFinish 改回直写 w
(并吃掉 <-writerExit,避免只靠死锁超时报红)」——实测被咬住(判据报红)。
2026-10-24 12:35:31 +08:00
..