返回 DeepSeek-Reasonix
mcp_concurrency_test.go
根目录 / internal / agent / mcp_concurrency_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "sync"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "reasonix/internal/config"
12 "reasonix/internal/plugin"
13 )
14
15 func TestMCPServerPolicyDefaultsToParallelExceptKnownStateful(t *testing.T) {
16 for name, tc := range map[string]struct {
17 entry config.PluginEntry
18 want bool
19 }{
20 "ordinary server": {config.PluginEntry{Name: "github"}, false},
21 "filesystem server": {config.PluginEntry{Name: "filesystem"}, false},
22 "browser server": {config.PluginEntry{Name: "browser"}, true},
23 "vendor-prefixed browser": {config.PluginEntry{Name: "acme-playwright-mcp"}, true},
24 "explicit serial": {config.PluginEntry{Name: "github", Concurrency: "serial"}, true},
25 "explicit parallel wins": {config.PluginEntry{Name: "browser", Concurrency: "parallel"}, false},
26 } {
27 if got := mcpServerIsSerial(tc.entry); got != tc.want {
28 t.Errorf("%s: serial = %v, want %v", name, got, tc.want)
29 }
30 }
31 }
32
33 func TestMCPRuntimeConfigurationPreservesConcurrencyPolicy(t *testing.T) {
34 runtime := &MCPCapabilityRuntime{
35 servers: map[string]mcpRuntimeServer{},
36 state: &mcpProxySharedState{connected: map[string]bool{}},
37 }
38 runtime.ConfigureServers(
39 []config.PluginEntry{{Name: "github", Concurrency: " SERIAL "}},
40 []plugin.Spec{{Name: "github"}},
41 map[string]bool{"github": true},
42 )
43 if !runtime.serverIsSerial("github") {
44 t.Fatal("ConfigureServers dropped the explicit serial policy")
45 }
46 configured := runtime.configuredServers()
47 if len(configured) != 1 || configured[0].entry.Concurrency != MCPConcurrencySerial {
48 t.Fatalf("configured concurrency = %+v, want serial", configured)
49 }
50
51 runtime.UpsertServer(
52 config.PluginEntry{Name: "browser", Concurrency: " PARALLEL "},
53 plugin.Spec{Name: "browser"},
54 true,
55 )
56 if runtime.serverIsSerial("browser") {
57 t.Fatal("UpsertServer dropped the explicit parallel override")
58 }
59 configured = runtime.configuredServers()
60 foundBrowser := false
61 for _, server := range configured {
62 if server.entry.Name != "browser" {
63 continue
64 }
65 foundBrowser = true
66 if server.entry.Concurrency != MCPConcurrencyParallel {
67 t.Fatalf("upserted concurrency = %q, want parallel", server.entry.Concurrency)
68 }
69 }
70 if !foundBrowser {
71 t.Fatal("UpsertServer did not publish the browser entry")
72 }
73 }
74
75 func runtimeWithServers(entries ...config.PluginEntry) *MCPCapabilityRuntime {
76 r := &MCPCapabilityRuntime{servers: map[string]mcpRuntimeServer{}}
77 for _, e := range entries {
78 r.servers[e.Name] = mcpRuntimeServer{entry: e, enabled: true}
79 }
80 return r
81 }
82
83 // Two children sharing one stdio process must not interleave on a server that
84 // carries session state, even though nothing it does looks like a write.
85 func TestMCPSerialServerNeverRunsTwoCallsAtOnce(t *testing.T) {
86 r := runtimeWithServers(config.PluginEntry{Name: "browser"})
87 var inFlight, peak atomic.Int32
88 var wg sync.WaitGroup
89 for range 8 {
90 wg.Go(func() {
91 err := r.withServerGate(context.Background(), "browser", func() error {
92 current := inFlight.Add(1)
93 for {
94 seen := peak.Load()
95 if current <= seen || peak.CompareAndSwap(seen, current) {
96 break
97 }
98 }
99 time.Sleep(time.Millisecond)
100 inFlight.Add(-1)
101 return nil
102 })
103 if err != nil {
104 t.Errorf("withServerGate: %v", err)
105 }
106 })
107 }
108 wg.Wait()
109 if got := peak.Load(); got != 1 {
110 t.Fatalf("peak concurrent calls = %d, want 1: a stateful server must be serialised", got)
111 }
112 }
113
114 // The shared-Host performance tradeoff must survive: ordinary servers keep
115 // running concurrently, and an unconfigured one is never gated by accident.
116 func TestMCPParallelServersStayConcurrent(t *testing.T) {
117 for _, server := range []string{"github", "unconfigured"} {
118 r := runtimeWithServers(config.PluginEntry{Name: "github"})
119 held := make(chan struct{})
120 released := make(chan struct{})
121 go func() {
122 _ = r.withServerGate(context.Background(), server, func() error {
123 close(held)
124 <-released
125 return nil
126 })
127 }()
128 <-held
129
130 done := make(chan struct{})
131 go func() {
132 _ = r.withServerGate(context.Background(), server, func() error { return nil })
133 close(done)
134 }()
135 select {
136 case <-done:
137 case <-time.After(2 * time.Second):
138 t.Errorf("%s: a parallel server must not gate a second concurrent call", server)
139 }
140 close(released)
141 }
142 }
143
144 // A queued call abandons the gate when its own run is cancelled rather than
145 // pinning the whole session behind a stuck server.
146 func TestMCPSerialGateHonoursCancellation(t *testing.T) {
147 r := runtimeWithServers(config.PluginEntry{Name: "browser"})
148 held := make(chan struct{})
149 released := make(chan struct{})
150 go func() {
151 _ = r.withServerGate(context.Background(), "browser", func() error {
152 close(held)
153 <-released
154 return nil
155 })
156 }()
157 <-held
158 defer close(released)
159
160 ctx, cancel := context.WithCancel(context.Background())
161 cancel()
162 ran := false
163 err := r.withServerGate(ctx, "browser", func() error { ran = true; return nil })
164 if err == nil || !errors.Is(err, context.Canceled) {
165 t.Fatalf("err = %v, want the cancellation surfaced", err)
166 }
167 if ran {
168 t.Fatal("a cancelled call must not execute after failing to take the gate")
169 }
170 }
171
171 lines GO