返回 DeepSeek-Reasonix
stall_test.go
根目录 / internal / provider / responses / stall_test.go
1 package responses
2
3 import (
4 "context"
5 "crypto/tls"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "reasonix/internal/provider"
16 )
17
18 func TestDefaultStreamIdleTimeoutIsFiveMinutes(t *testing.T) {
19 if defaultStreamIdleTimeout != 300*time.Second {
20 t.Fatalf("default stream idle timeout = %s, want 5m", defaultStreamIdleTimeout)
21 }
22 }
23
24 func flush(w http.ResponseWriter) {
25 if f, ok := w.(http.Flusher); ok {
26 f.Flush()
27 }
28 }
29
30 // startHTTP2TLSServer returns an httptest TLS server with HTTP/2 enabled and a
31 // client that trusts its certificate. The handler must keep the connection
32 // open long enough for the client to negotiate h2 (verified via sawHTTP2).
33 func startHTTP2TLSServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *http.Client, *atomic.Bool) {
34 t.Helper()
35 var sawHTTP2 atomic.Bool
36 srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37 if r.ProtoMajor == 2 {
38 sawHTTP2.Store(true)
39 }
40 handler(w, r)
41 }))
42 srv.EnableHTTP2 = true
43 srv.StartTLS()
44 t.Cleanup(srv.Close)
45
46 client := srv.Client()
47 // Force HTTP/2 negotiation on the TLS transport (httptest enables h2 on the
48 // server; the client must also advertise it).
49 if tr, ok := client.Transport.(*http.Transport); ok {
50 tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only self-signed cert
51 if err := http2ConfigureTransport(tr); err != nil {
52 t.Fatalf("configure HTTP/2 transport: %v", err)
53 }
54 }
55 return srv, client, &sawHTTP2
56 }
57
58 // http2ConfigureTransport enables HTTP/2 on tr without importing golang.org/x/net/http2
59 // when the stdlib transport already supports it via ForceAttemptHTTP2.
60 func http2ConfigureTransport(tr *http.Transport) error {
61 tr.ForceAttemptHTTP2 = true
62 return nil
63 }
64
65 // TestStreamStallTimesOutHTTP2 covers a half-open HTTP/2 body (headers received,
66 // then silence without RST). The idle watchdog must close the body and surface
67 // an idle_timeout StreamInterrupt so the Controller can emit a single TurnDone
68 // (#7811, HTTP/2 path).
69 func TestStreamStallTimesOutHTTP2(t *testing.T) {
70 release := make(chan struct{})
71 srv, httpClient, sawHTTP2 := startHTTP2TLSServer(t, func(w http.ResponseWriter, _ *http.Request) {
72 w.Header().Set("Content-Type", "text/event-stream")
73 w.WriteHeader(http.StatusOK)
74 flush(w)
75 // One comment keeps the connection "alive" once, then stall forever.
76 _, _ = io.WriteString(w, ": keep-alive\n\n")
77 flush(w)
78 <-release
79 })
80 defer close(release)
81
82 p := New(Config{Name: "responses", BaseURL: srv.URL, Model: "model", APIKey: "k"}).(*client)
83 p.http = httpClient
84 p.idleTimeout = 150 * time.Millisecond
85
86 ch, err := p.Stream(context.Background(), provider.Request{
87 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
88 })
89 if err != nil {
90 t.Fatalf("Stream: %v", err)
91 }
92
93 deadline := time.After(5 * time.Second)
94 for {
95 select {
96 case chunk, ok := <-ch:
97 if !ok {
98 t.Fatal("stream closed without surfacing a stall error")
99 }
100 if chunk.Type == provider.ChunkError {
101 if !sawHTTP2.Load() {
102 t.Fatal("stall path did not run over HTTP/2 (ProtoMajor != 2)")
103 }
104 if !strings.Contains(chunk.Err.Error(), "idle timeout") {
105 t.Fatalf("error = %v, want idle timeout", chunk.Err)
106 }
107 if provider.StreamInterruptReason(chunk.Err) != provider.StreamInterruptIdleTimeout {
108 t.Fatalf("reason = %q, want %q", provider.StreamInterruptReason(chunk.Err), provider.StreamInterruptIdleTimeout)
109 }
110 return
111 }
112 case <-deadline:
113 t.Fatal("stream did not time out on a stalled HTTP/2 body")
114 }
115 }
116 }
117
118 // TestMissingTerminalEventSurfacesPrematureEOF ensures connection close before
119 // response.completed/failed/incomplete is not treated as a successful turn.
120 func TestMissingTerminalEventSurfacesPrematureEOF(t *testing.T) {
121 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
122 w.Header().Set("Content-Type", "text/event-stream")
123 w.WriteHeader(http.StatusOK)
124 // Partial text delta, then close the body without a terminal event.
125 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n")
126 flush(w)
127 }))
128 defer srv.Close()
129
130 p := New(Config{Name: "responses", BaseURL: srv.URL, Model: "model", APIKey: "k"}).(*client)
131 p.idleTimeout = time.Second
132
133 ch, err := p.Stream(context.Background(), provider.Request{
134 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
135 })
136 if err != nil {
137 t.Fatalf("Stream: %v", err)
138 }
139
140 var sawError bool
141 for chunk := range ch {
142 if chunk.Type == provider.ChunkError {
143 sawError = true
144 if provider.StreamInterruptReason(chunk.Err) != provider.StreamInterruptPrematureEOF {
145 t.Fatalf("reason = %q, want %q (err=%v)",
146 provider.StreamInterruptReason(chunk.Err), provider.StreamInterruptPrematureEOF, chunk.Err)
147 }
148 }
149 }
150 if !sawError {
151 t.Fatal("expected premature-EOF error when terminal event is missing")
152 }
153 }
154
155 // TestSendChunkUnblocksOnContextCancel covers the path where the consumer stops
156 // reading and the stream must not hang forever inside sendChunk. Timing uses a
157 // blocking-hook channel — no fixed sleep.
158 func TestSendChunkUnblocksOnContextCancel(t *testing.T) {
159 ctx, cancel := context.WithCancel(context.Background())
160 // Unbuffered channel so sendChunk blocks on the second select branch.
161 out := make(chan provider.Chunk)
162 done := make(chan struct{})
163 enteredBlocking := make(chan struct{})
164
165 prev := sendChunkEnterBlocking
166 sendChunkEnterBlocking = func() { close(enteredBlocking) }
167 t.Cleanup(func() { sendChunkEnterBlocking = prev })
168
169 go func() {
170 ok := sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: "blocked"})
171 if ok {
172 t.Error("sendChunk returned true after cancel")
173 }
174 close(done)
175 }()
176
177 select {
178 case <-enteredBlocking:
179 case <-time.After(2 * time.Second):
180 t.Fatal("sendChunk never entered the blocking select")
181 }
182 cancel()
183
184 select {
185 case <-done:
186 case <-time.After(2 * time.Second):
187 t.Fatal("sendChunk remained blocked after context cancellation")
188 }
189 }
190
191 // TestReadStreamContextCancelClosesBody ensures Cancel mid-stream unblocks the
192 // scanner and closes the response body without leaking the goroutine. Timing is
193 // driven by the first delivered chunk — no fixed sleep.
194 func TestReadStreamContextCancelClosesBody(t *testing.T) {
195 var bodyClosed sync.WaitGroup
196 bodyClosed.Add(1)
197 pr, pw := io.Pipe()
198 resp := &http.Response{Body: &closeNotifyBody{ReadCloser: pr, onClose: bodyClosed.Done}}
199
200 ctx, cancel := context.WithCancel(context.Background())
201 out := make(chan provider.Chunk, 4)
202 done := make(chan struct{})
203 go func() {
204 (&client{idleTimeout: time.Minute}).readStream(ctx, resp, out, nil)
205 close(done)
206 }()
207
208 // Write a non-terminal event and wait until it is delivered — proves the
209 // scanner is live before we cancel.
210 _, _ = io.WriteString(pw, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"x\"}\n\n")
211 select {
212 case chunk := <-out:
213 if chunk.Type != provider.ChunkText || chunk.Text != "x" {
214 t.Fatalf("first chunk = %+v, want text delta x", chunk)
215 }
216 case <-time.After(2 * time.Second):
217 t.Fatal("timed out waiting for first stream chunk before cancel")
218 }
219 cancel()
220
221 select {
222 case <-done:
223 case <-time.After(2 * time.Second):
224 t.Fatal("readStream did not exit after context cancel")
225 }
226
227 closed := make(chan struct{})
228 go func() {
229 bodyClosed.Wait()
230 close(closed)
231 }()
232 select {
233 case <-closed:
234 case <-time.After(2 * time.Second):
235 t.Fatal("response body was not closed after context cancel")
236 }
237 _ = pw.Close()
238 }
239
240 type closeNotifyBody struct {
241 io.ReadCloser
242 onClose func()
243 once sync.Once
244 }
245
246 func (b *closeNotifyBody) Close() error {
247 err := b.ReadCloser.Close()
248 b.once.Do(b.onClose)
249 return err
250 }
251
251 lines GO