mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
根因(-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,避免只靠死锁超时报红)」——实测被咬住(判据报红)。