返回 DeepSeek-Reasonix
stall_test.go
根目录 / internal / provider / openai / stall_test.go
1 package openai
2
3 import (
4 "context"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/provider"
13 )
14
15 // TestStreamStallTimesOut covers issue #3374: a half-open connection (a proxy
16 // switched mid-stream) sends the SSE head then goes silent without an RST, so
17 // scanner.Scan() would block forever and Ctrl+C-less sessions hang until kill -9.
18 // The idle watchdog must surface a stall error instead of hanging.
19 func TestStreamStallTimesOut(t *testing.T) {
20 release := make(chan struct{})
21 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
22 w.Header().Set("Content-Type", "text/event-stream")
23 w.WriteHeader(http.StatusOK)
24 flush(w)
25 _, _ = io.WriteString(w, ": keep-alive\n\n") // one comment, resets the watchdog once
26 flush(w)
27 <-release // then stall: never send data, never close — half-open connection
28 }))
29 defer srv.Close()
30 defer close(release)
31
32 p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
33 if err != nil {
34 t.Fatalf("New: %v", err)
35 }
36 p.(*client).idleTimeout = 150 * time.Millisecond
37 ch, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
38 if err != nil {
39 t.Fatalf("Stream: %v", err)
40 }
41
42 deadline := time.After(5 * time.Second)
43 for {
44 select {
45 case chunk, ok := <-ch:
46 if !ok {
47 t.Fatal("stream closed without surfacing a stall error")
48 }
49 if chunk.Type == provider.ChunkError {
50 if !strings.Contains(chunk.Err.Error(), "stalled") {
51 t.Fatalf("error = %v, want a 'stalled' error", chunk.Err)
52 }
53 return
54 }
55 case <-deadline:
56 t.Fatal("stream did not time out on a stalled connection — it hung")
57 }
58 }
59 }
60
61 func TestReadStreamSendUnblocksOnContextCancel(t *testing.T) {
62 ctx, cancel := context.WithCancel(context.Background())
63 resp := &http.Response{Body: io.NopCloser(strings.NewReader("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n"))}
64 out := make(chan provider.Chunk)
65 done := make(chan struct{})
66
67 go func() {
68 _, _ = (&client{name: "openai"}).readStream(ctx, resp, out)
69 close(done)
70 }()
71
72 time.Sleep(50 * time.Millisecond)
73 cancel()
74
75 select {
76 case <-done:
77 case <-time.After(500 * time.Millisecond):
78 t.Fatal("readStream remained blocked sending to an abandoned reader after context cancellation")
79 }
80 }
81
82 func flush(w http.ResponseWriter) {
83 if f, ok := w.(http.Flusher); ok {
84 f.Flush()
85 }
86 }
87
87 lines GO