Files
AutoZero/llm/streamguard_test.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

175 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package llm
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// stallServer 吐一个增量之后彻底静默——正是"网关半开连接/服务端卡在生成阶段"的形态:
// TCP 不断、HTTP 头已回、之后再无字节。没有看门狗时 bufio.Scanner 会永久阻塞在 Read。
func stallServer(t *testing.T, hold chan struct{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
<-hold // 永不返回
}))
}
func TestStreamIdleWatchdogUnblocks(t *testing.T) {
old := streamIdleTimeout
streamIdleTimeout = 150 * time.Millisecond
defer func() { streamIdleTimeout = old }()
hold := make(chan struct{})
srv := stallServer(t, hold)
defer func() {
close(hold)
srv.Close()
}()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
done := make(chan error, 1)
go func() {
_, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
done <- err
}()
select {
case err := <-done:
var se *StalledError
if !errors.As(err, &se) {
t.Fatalf("要的是 StalledError,got %v", err)
}
if !strings.Contains(err.Error(), "stalled") {
t.Errorf("错误必须说清是静默掐断,got=%q", err)
}
case <-time.After(10 * time.Second):
t.Fatal("看门狗没生效:Stream 仍被静默的连接卡着")
}
}
// TestStreamIdleWatchdogSparesHealthyStream:不能错杀慢但活的流——
// 边睡边吐字节的流必须完整读完。
func TestStreamIdleWatchdogSparesHealthyStream(t *testing.T) {
old := streamIdleTimeout
streamIdleTimeout = 400 * time.Millisecond
defer func() { streamIdleTimeout = old }()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
f, _ := w.(http.Flusher)
// 三段,每段间隔 250ms:单段静默都低于 400ms 阈值。
for _, s := range []string{"he", "ll", "o"} {
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"" + s + "\"}}]}\n\n"))
if f != nil {
f.Flush()
}
time.Sleep(250 * time.Millisecond)
}
w.Write([]byte("data: [DONE]\n\n"))
if f != nil {
f.Flush()
}
}))
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatalf("健康流被误杀:%v", err)
}
if msg.Content != "hello" {
t.Errorf("Content = %q, want hello", msg.Content)
}
}
// trickle 是一个可控的"滴流"响应体:按给定节奏每次吐一个字节,
// 滴完之后彻底静默(阻塞在读上),用来精确复现"服务端连上不吐字节"。
// 和真响应体一样:Close 会让阻塞中的 Read 立刻返回错误。
type trickle struct {
gaps []time.Duration
i int
closed chan struct{}
}
func (r *trickle) Read(p []byte) (int, error) {
if r.i >= len(r.gaps) {
<-r.closed
return 0, io.ErrClosedPipe
}
time.Sleep(r.gaps[r.i])
r.i++
p[0] = 'x'
return 1, nil
}
func (r *trickle) Close() error { close(r.closed); return nil }
func (r *trickle) closedNow() bool {
select {
case <-r.closed:
return true
default:
return false
}
}
// TestIdleGuardMarksStall:看护的两条基本契约——有字节就重置计时,
// 真静默了才掐断,并且能把原因报出来。
func TestIdleGuardMarksStall(t *testing.T) {
const idle = 300 * time.Millisecond
src := &trickle{gaps: []time.Duration{60 * time.Millisecond, 60, 60, 60}, closed: make(chan struct{})}
g := guardStream(src, idle)
defer g.Stop()
buf := make([]byte, 1)
for i := 0; i < len(src.gaps); i++ {
if _, err := g.Read(buf); err != nil {
t.Fatalf("Read 透传失败:%v", err)
}
if err := g.Err(); err != nil {
t.Fatalf("活跃流被误判为静默:%v", err)
}
}
// 此后彻底没有数据:Read 阻塞在源上,只有看门狗关掉它能解阻塞。
readErr := make(chan error, 1)
go func() { _, err := g.Read(buf); readErr <- err }()
deadline := time.Now().Add(10 * time.Second)
for g.Err() == nil {
if time.Now().After(deadline) {
t.Fatal("静默没有被判定")
}
time.Sleep(20 * time.Millisecond)
}
if !strings.Contains(g.Err().Error(), idle.String()) {
t.Errorf("错误里应带阈值,got=%q", g.Err())
}
select {
case err := <-readErr:
if err == nil {
t.Error("body 被掐断后 Read 必须返回错误(否则调用方仍会阻塞)")
}
case <-time.After(2 * time.Second):
t.Fatal("掐断后 Read 仍卡着——调用方解不了阻塞,看门狗等于没做")
}
if !src.closedNow() {
t.Error("看门狗没有关闭响应体")
}
}