返回 DeepSeek-Reasonix
transport_sse_test.go
根目录 / internal / plugin / transport_sse_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "reasonix/internal/tool"
16 )
17
18 func TestLegacySSETransportSupportsRootsToolsAndProgress(t *testing.T) {
19 workspaceRoot := t.TempDir()
20 events := make(chan string, 16)
21 serverErr := make(chan error, 4)
22 var state struct {
23 sync.Mutex
24 initializeID int
25 }
26
27 mux := http.NewServeMux()
28 mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
29 if r.Header.Get("Authorization") != "Bearer secret" {
30 http.Error(w, "missing auth", http.StatusUnauthorized)
31 return
32 }
33 flusher, ok := w.(http.Flusher)
34 if !ok {
35 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
36 return
37 }
38 w.Header().Set("Content-Type", "text/event-stream")
39 w.WriteHeader(http.StatusOK)
40 _, _ = fmt.Fprint(w, "event: endpoint\ndata: /messages?session=test\n\n")
41 flusher.Flush()
42 for {
43 select {
44 case <-r.Context().Done():
45 return
46 case event := <-events:
47 _, _ = fmt.Fprint(w, event)
48 flusher.Flush()
49 }
50 }
51 })
52 mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
53 if r.Header.Get("Authorization") != "Bearer secret" || r.URL.Query().Get("session") != "test" {
54 http.Error(w, "missing auth or session", http.StatusUnauthorized)
55 return
56 }
57 var message struct {
58 ID json.RawMessage `json:"id"`
59 Method string `json:"method"`
60 Params json.RawMessage `json:"params"`
61 Result json.RawMessage `json:"result"`
62 }
63 if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
64 http.Error(w, err.Error(), http.StatusBadRequest)
65 return
66 }
67 emit := func(payload any) {
68 body, _ := json.Marshal(payload)
69 events <- "event: message\ndata: " + string(body) + "\n\n"
70 }
71 switch message.Method {
72 case "initialize":
73 var params struct {
74 Capabilities map[string]json.RawMessage `json:"capabilities"`
75 }
76 _ = json.Unmarshal(message.Params, &params)
77 if _, ok := params.Capabilities["roots"]; !ok {
78 serverErr <- fmt.Errorf("initialize capabilities = %v, want roots", params.Capabilities)
79 }
80 var initializeID int
81 _ = json.Unmarshal(message.ID, &initializeID)
82 state.Lock()
83 state.initializeID = initializeID
84 state.Unlock()
85 emit(map[string]any{"jsonrpc": "2.0", "id": "server-roots", "method": "roots/list"})
86 case "notifications/initialized":
87 case "tools/list":
88 var id int
89 _ = json.Unmarshal(message.ID, &id)
90 emit(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
91 "tools": []any{map[string]any{
92 "name": "work", "description": "Do work", "inputSchema": map[string]any{"type": "object"},
93 }},
94 }})
95 case "tools/call":
96 var id int
97 _ = json.Unmarshal(message.ID, &id)
98 var params struct {
99 Meta map[string]any `json:"_meta"`
100 }
101 _ = json.Unmarshal(message.Params, &params)
102 token, _ := params.Meta["progressToken"].(string)
103 if token == "" {
104 serverErr <- fmt.Errorf("tools/call missing progressToken: %s", message.Params)
105 }
106 emit(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
107 "progressToken": token, "progress": 1, "total": 2, "message": "Working",
108 }})
109 emit(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
110 "content": []any{map[string]any{"type": "text", "text": "done"}},
111 }})
112 case "":
113 if strings.TrimSpace(string(message.ID)) != `"server-roots"` {
114 serverErr <- fmt.Errorf("unexpected server response id %s", message.ID)
115 break
116 }
117 var result struct {
118 Roots []mcpRoot `json:"roots"`
119 }
120 _ = json.Unmarshal(message.Result, &result)
121 want := mcpRoots(workspaceRoot)
122 if len(result.Roots) != 1 || result.Roots[0] != want[0] {
123 serverErr <- fmt.Errorf("roots/list result = %+v, want %+v", result.Roots, want)
124 }
125 state.Lock()
126 initializeID := state.initializeID
127 state.Unlock()
128 emit(map[string]any{"jsonrpc": "2.0", "id": initializeID, "result": map[string]any{
129 "protocolVersion": protocolVersion,
130 "serverInfo": map[string]any{"name": "legacy", "version": "1"},
131 "capabilities": map[string]any{"tools": map[string]any{}},
132 }})
133 default:
134 serverErr <- fmt.Errorf("unexpected method %q", message.Method)
135 }
136 w.WriteHeader(http.StatusAccepted)
137 })
138
139 server := httptest.NewServer(mux)
140 defer server.Close()
141 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
142 defer cancel()
143 host, tools, err := StartAll(ctx, []Spec{{
144 Name: "legacy",
145 Type: "sse",
146 URL: server.URL + "/sse",
147 Headers: map[string]string{"Authorization": "Bearer secret"},
148 WorkspaceRoot: workspaceRoot,
149 }})
150 if err != nil {
151 t.Fatalf("StartAll legacy SSE: %v", err)
152 }
153 defer host.Close()
154 if len(tools) != 1 || tools[0].Name() != "mcp__legacy__work" {
155 t.Fatalf("tools = %v", names(tools))
156 }
157
158 progress := make(chan string, 1)
159 toolCtx := tool.WithProgress(ctx, func(chunk string) { progress <- chunk })
160 result, err := tools[0].Execute(toolCtx, json.RawMessage(`{}`))
161 if err != nil || result != "done" {
162 t.Fatalf("Execute = %q, %v", result, err)
163 }
164 select {
165 case got := <-progress:
166 if got != "Working (1/2)\n" {
167 t.Fatalf("progress = %q", got)
168 }
169 case <-time.After(time.Second):
170 t.Fatal("legacy SSE progress was not routed")
171 }
172 select {
173 case err := <-serverErr:
174 t.Fatal(err)
175 default:
176 }
177 }
178
179 func TestLegacySSERejectsCrossOriginEndpoint(t *testing.T) {
180 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
181 w.Header().Set("Content-Type", "text/event-stream")
182 _, _ = fmt.Fprint(w, "event: endpoint\ndata: https://other.example/messages\n\n")
183 }))
184 defer server.Close()
185 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
186 defer cancel()
187 transport, err := newSSETransport(ctx, Spec{Name: "unsafe", URL: server.URL})
188 if err != nil {
189 t.Fatal(err)
190 }
191 defer transport.close()
192 _, err = transport.call(ctx, "initialize", map[string]any{})
193 if err == nil || !strings.Contains(err.Error(), "cross-origin endpoint") {
194 t.Fatalf("cross-origin endpoint error = %v", err)
195 }
196 }
197
198 func TestLegacySSEBoundsConcurrentServerRequestReplies(t *testing.T) {
199 events := make(chan string, 2*sseReplyQueueBound+2)
200 releasePosts := make(chan struct{})
201 postStarted := make(chan struct{})
202 var firstPost sync.Once
203 var activePosts atomic.Int32
204 var maxPosts atomic.Int32
205
206 mux := http.NewServeMux()
207 mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
208 flusher, ok := w.(http.Flusher)
209 if !ok {
210 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
211 return
212 }
213 w.Header().Set("Content-Type", "text/event-stream")
214 _, _ = fmt.Fprint(w, "event: endpoint\ndata: /messages\n\n")
215 flusher.Flush()
216 for {
217 select {
218 case <-r.Context().Done():
219 return
220 case event := <-events:
221 _, _ = fmt.Fprint(w, event)
222 flusher.Flush()
223 }
224 }
225 })
226 mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
227 active := activePosts.Add(1)
228 defer activePosts.Add(-1)
229 for {
230 seen := maxPosts.Load()
231 if active <= seen || maxPosts.CompareAndSwap(seen, active) {
232 break
233 }
234 }
235 firstPost.Do(func() { close(postStarted) })
236 select {
237 case <-releasePosts:
238 w.WriteHeader(http.StatusAccepted)
239 case <-r.Context().Done():
240 }
241 })
242
243 server := httptest.NewServer(mux)
244 defer server.Close()
245 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
246 defer cancel()
247 transport, err := newSSETransport(ctx, Spec{Name: "bounded", Type: "sse", URL: server.URL + "/sse"})
248 if err != nil {
249 t.Fatal(err)
250 }
251 defer transport.close()
252 defer close(releasePosts)
253 if err := transport.waitEndpoint(ctx); err != nil {
254 t.Fatal(err)
255 }
256
257 waiting := make(chan rpcResponse, 1)
258 transport.mu.Lock()
259 transport.pending[7] = waiting
260 transport.mu.Unlock()
261 for i := 0; i < 2*sseReplyQueueBound; i++ {
262 events <- fmt.Sprintf("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":\"srv-%d\",\"method\":\"ping\"}\n\n", i)
263 }
264 events <- "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}\n\n"
265
266 select {
267 case response := <-waiting:
268 if response.ID != 7 {
269 t.Fatalf("routed response id = %d, want 7", response.ID)
270 }
271 case <-time.After(2 * time.Second):
272 t.Fatal("SSE reader stopped routing responses while a reply POST was blocked")
273 }
274 select {
275 case <-postStarted:
276 case <-time.After(2 * time.Second):
277 t.Fatal("SSE reply worker did not start its first POST")
278 }
279 if got := maxPosts.Load(); got != 1 {
280 t.Fatalf("concurrent reply POSTs = %d, want 1", got)
281 }
282 }
283
283 lines GO