返回 DeepSeek-Reasonix
lazy_test.go
根目录 / internal / plugin / lazy_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/mcplaunch"
15 "reasonix/internal/tool"
16 )
17
18 type destructiveLazyTarget struct {
19 name string
20 calls int
21 }
22
23 type mutableLazyTarget struct {
24 name string
25 calls int
26 }
27
28 func (t *mutableLazyTarget) Name() string { return t.name }
29 func (t *mutableLazyTarget) Description() string { return "writer test target" }
30 func (t *mutableLazyTarget) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
31 func (t *mutableLazyTarget) ReadOnly() bool { return false }
32 func (t *mutableLazyTarget) Execute(context.Context, json.RawMessage) (string, error) {
33 t.calls++
34 return "executed", nil
35 }
36
37 func (t *destructiveLazyTarget) Name() string { return t.name }
38 func (t *destructiveLazyTarget) Description() string { return "destructive test target" }
39 func (t *destructiveLazyTarget) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
40 func (t *destructiveLazyTarget) ReadOnly() bool { return true }
41 func (t *destructiveLazyTarget) MCPDestructiveHint() bool { return true }
42 func (t *destructiveLazyTarget) Execute(context.Context, json.RawMessage) (string, error) {
43 t.calls++
44 return "executed", nil
45 }
46
47 // helperSpec returns a Spec that re-invokes this test binary as a minimal MCP
48 // stdio server (see TestHelperProcess in plugin_test.go). Reused across every
49 // lazy_test case so the helper-process contract — "echo: <msg>" responder with
50 // tools/list exposing echo and zed — stays the single source of truth.
51 func helperSpec() Spec {
52 return Spec{
53 Name: "mock",
54 Command: os.Args[0],
55 Args: []string{"-test.run=TestHelperProcess", "--"},
56 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
57 }
58 }
59
60 // writeMockCache primes the on-disk cache for spec with the two tools the
61 // helper subprocess exposes (echo, zed). We mirror the real schemas so a
62 // cache-hit lazyTool surfaces the same Schema() bytes that a freshly handshaked
63 // remoteTool would — the test for "model sees real schema before any Execute"
64 // depends on this equivalence.
65 func writeMockCache(t *testing.T, spec Spec) {
66 t.Helper()
67 cs := CachedSchema{
68 CacheKey: SchemaCacheKey(spec),
69 Capabilities: map[string]bool{"prompts": false, "resources": false},
70 Tools: []CachedTool{
71 {
72 Name: "echo",
73 Description: "Echo back the message.",
74 Schema: json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}},"required":["z","msg"]}`),
75 },
76 {
77 Name: "zed",
78 Description: "Sorted after echo.",
79 Schema: json.RawMessage(`{"type":"object"}`),
80 },
81 },
82 }
83 if err := SaveCachedSchema(spec.Name, cs); err != nil {
84 t.Fatalf("SaveCachedSchema: %v", err)
85 }
86 }
87
88 // waitForServer polls host.ServerNames() until name appears or timeout
89 // elapses. The lazy path spawns via a goroutine, so tests need a bounded poll
90 // rather than a fixed sleep — five seconds covers a slow CI subprocess fork
91 // while still aborting clearly on a real hang.
92 func waitForServer(t *testing.T, host *Host, name string, timeout time.Duration) {
93 t.Helper()
94 deadline := time.Now().Add(timeout)
95 for time.Now().Before(deadline) {
96 for _, n := range host.ServerNames() {
97 if n == name {
98 return
99 }
100 }
101 time.Sleep(10 * time.Millisecond)
102 }
103 t.Fatalf("server %q never appeared in host.ServerNames() within %v (got %v)", name, timeout, host.ServerNames())
104 }
105
106 func waitForCachedSchema(t *testing.T, spec Spec, timeout time.Duration) *CachedSchema {
107 t.Helper()
108 deadline := time.Now().Add(timeout)
109 for time.Now().Before(deadline) {
110 if cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec)); ok {
111 return cs
112 }
113 time.Sleep(10 * time.Millisecond)
114 }
115 t.Fatalf("cached schema for %q never appeared within %v", spec.Name, timeout)
116 return nil
117 }
118
119 func TestHostCloseWaitsForLazyBackgroundWrite(t *testing.T) {
120 host := NewHost()
121 started := make(chan struct{})
122 release := make(chan struct{})
123 host.queueBackgroundWrite(func() {
124 close(started)
125 <-release
126 })
127 <-started
128
129 closed := make(chan struct{})
130 go func() {
131 host.Close()
132 close(closed)
133 }()
134 select {
135 case <-closed:
136 t.Fatal("Host.Close returned before the lazy background write finished")
137 case <-time.After(50 * time.Millisecond):
138 }
139
140 close(release)
141 select {
142 case <-closed:
143 case <-time.After(2 * time.Second):
144 t.Fatal("Host.Close did not return after the lazy background write finished")
145 }
146 }
147
148 // TestLazyCacheHitSyncSpawn drives the cache-hit branch end-to-end: cache is
149 // pre-populated, the model can see real schemas before any spawn, and the
150 // first Execute synchronously handshakes, swaps the placeholder for the real
151 // *remoteTool, and forwards through in one turn. This is the "warm start"
152 // payoff — lazy plugins should be indistinguishable from eager once they have
153 // a cache.
154 func TestLazyCacheHitSyncSpawn(t *testing.T) {
155 redirectCache(t)
156 spec := helperSpec()
157 writeMockCache(t, spec)
158
159 cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
160 if !ok {
161 t.Fatal("LoadCachedSchema: miss right after save (sanity)")
162 }
163
164 host := NewHost()
165 defer host.Close()
166 reg := tool.NewRegistry()
167 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
168 defer cancel()
169
170 tools := LazyToolset(spec, cs, host, reg, ctx, false)
171 if len(tools) != 2 {
172 t.Fatalf("LazyToolset returned %d tools, want 2 (echo + zed)", len(tools))
173 }
174 for _, lt := range tools {
175 reg.Add(lt)
176 }
177
178 // Before any Execute: registry exposes real cached schemas (not the empty
179 // {"type":"object"} stub). The model relies on this to call the tool with
180 // real args on the very first turn.
181 echoBefore, ok := reg.Get("mcp__mock__echo")
182 if !ok {
183 t.Fatal("registry missing mcp__mock__echo after LazyToolset")
184 }
185 if _, isLazy := echoBefore.(*lazyTool); !isLazy {
186 t.Fatalf("pre-Execute echo should be a *lazyTool, got %T", echoBefore)
187 }
188 gotSchema := string(echoBefore.Schema())
189 if !strings.Contains(gotSchema, `"msg"`) || !strings.Contains(gotSchema, `"required"`) {
190 t.Fatalf("cached schema not surfaced through lazyTool.Schema(): %s", gotSchema)
191 }
192
193 // First Execute: cache-hit path runs the handshake synchronously and
194 // forwards to the real tool — the user sees "echo: hi" in this same turn.
195 out, err := echoBefore.Execute(ctx, json.RawMessage(`{"msg":"hi"}`))
196 if err != nil {
197 t.Fatalf("Execute: %v", err)
198 }
199 if out != "echo: hi" {
200 t.Fatalf("Execute result = %q, want %q", out, "echo: hi")
201 }
202
203 // The spawn actually happened — host now lists the mock server.
204 names := host.ServerNames()
205 if len(names) != 1 || names[0] != "mock" {
206 t.Fatalf("host.ServerNames() = %v, want [mock]", names)
207 }
208
209 // After Execute, the registry entry must STILL be the placeholder: cache-hit
210 // placeholders are pinned for the whole session so the request's tools
211 // array stays byte-identical even when the live handshake differs from the
212 // cache (see trySwap). Execution keeps forwarding to the real tool through
213 // the shared spawn state.
214 echoAfter, _ := reg.Get("mcp__mock__echo")
215 if _, isLazy := echoAfter.(*lazyTool); !isLazy {
216 t.Fatalf("post-Execute echo should remain the pinned *lazyTool, got %T", echoAfter)
217 }
218 if got := string(echoAfter.Schema()); got != gotSchema {
219 t.Fatalf("registry schema bytes changed across the handshake:\nbefore: %s\nafter: %s", gotSchema, got)
220 }
221 // Second call goes straight through the ready state to the real tool.
222 out2, err := echoAfter.Execute(ctx, json.RawMessage(`{"msg":"again"}`))
223 if err != nil {
224 t.Fatalf("second Execute: %v", err)
225 }
226 if out2 != "echo: again" {
227 t.Fatalf("second Execute result = %q, want %q", out2, "echo: again")
228 }
229 }
230
231 func TestLazyCacheHitReusesExistingSharedHostClient(t *testing.T) {
232 redirectCache(t)
233 spec := helperSpec()
234 writeMockCache(t, spec)
235
236 cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
237 if !ok {
238 t.Fatal("LoadCachedSchema: miss right after save (sanity)")
239 }
240
241 host := NewHost()
242 defer host.Close()
243 reg := tool.NewRegistry()
244 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
245 defer cancel()
246
247 if _, err := host.Add(ctx, spec); err != nil {
248 t.Fatalf("preconnect shared host: %v", err)
249 }
250
251 tools := LazyToolset(spec, cs, host, reg, ctx, false)
252 for _, lt := range tools {
253 reg.Add(lt)
254 }
255 echoBefore, ok := reg.Get("mcp__mock__echo")
256 if !ok {
257 t.Fatal("registry missing mcp__mock__echo after LazyToolset")
258 }
259
260 out, err := echoBefore.Execute(ctx, json.RawMessage(`{"msg":"hi"}`))
261 if err != nil {
262 t.Fatalf("Execute against existing shared host client: %v", err)
263 }
264 if out != "echo: hi" {
265 t.Fatalf("Execute result = %q, want %q", out, "echo: hi")
266 }
267 if got := host.ServerNames(); len(got) != 1 || got[0] != "mock" {
268 t.Fatalf("shared host should still have exactly one mock server, got %v", got)
269 }
270 }
271
272 func TestLazyRemoveCancelsInFlightGenerationWithoutResurrection(t *testing.T) {
273 redirectCache(t)
274 spec := helperSpec()
275 spec.Env["GO_WANT_HELPER_INIT_MS"] = "500"
276 host := NewHost()
277 defer host.Close()
278 reg := tool.NewRegistry()
279 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
280 defer cancel()
281
282 for _, placeholder := range LazyToolset(spec, nil, host, reg, ctx, true) {
283 reg.Add(placeholder)
284 }
285 deadline := time.Now().Add(3 * time.Second)
286 for {
287 host.spawningMu.Lock()
288 spawning := len(host.spawning) > 0
289 host.spawningMu.Unlock()
290 if spawning {
291 break
292 }
293 if time.Now().After(deadline) {
294 t.Fatal("lazy spawn never entered the in-flight state")
295 }
296 time.Sleep(5 * time.Millisecond)
297 }
298
299 prefix, found := host.Remove(spec.Name)
300 if !found {
301 t.Fatal("Host.Remove did not cancel the in-flight lazy generation")
302 }
303 reg.RemovePrefix(prefix)
304 done := make(chan struct{})
305 go func() {
306 host.deferredWG.Wait()
307 close(done)
308 }()
309 select {
310 case <-done:
311 case <-time.After(5 * time.Second):
312 t.Fatal("cancelled lazy generation did not finish")
313 }
314 if host.HasClient(spec.Name) || len(host.ServerNames()) != 0 {
315 t.Fatalf("removed lazy server was resurrected: %v", host.ServerNames())
316 }
317 if _, ok := reg.Get(ToolPrefix(spec.Name) + "connect"); ok {
318 t.Fatal("removed lazy placeholder was re-registered")
319 }
320 if _, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec)); ok {
321 t.Fatal("cancelled lazy generation wrote a new schema cache")
322 }
323 tools, err := host.Add(ctx, spec)
324 if err != nil {
325 t.Fatalf("re-add after cancelled generation: %v", err)
326 }
327 if len(tools) == 0 || !host.HasClient(spec.Name) {
328 t.Fatalf("new generation did not connect after removal: tools=%d clients=%v", len(tools), host.ServerNames())
329 }
330 }
331
332 func TestAddWithLifecycleCoalescesConcurrentSameServer(t *testing.T) {
333 spec := helperSpec()
334 spec.Env["GO_WANT_HELPER_INIT_MS"] = "200"
335
336 host := NewHost()
337 defer host.Close()
338 lifeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
339 defer cancel()
340
341 start := make(chan struct{})
342 errs := make([]error, 2)
343 toolCounts := make([]int, 2)
344 var wg sync.WaitGroup
345 for i := range errs {
346 wg.Add(1)
347 go func(i int) {
348 defer wg.Done()
349 <-start
350 callCtx, cancelCall := context.WithTimeout(lifeCtx, 5*time.Second)
351 defer cancelCall()
352 tools, err := host.AddWithLifecycle(lifeCtx, callCtx, spec)
353 errs[i] = err
354 toolCounts[i] = len(tools)
355 }(i)
356 }
357 close(start)
358 wg.Wait()
359
360 for i, err := range errs {
361 if err != nil {
362 t.Fatalf("AddWithLifecycle call %d failed: %v (all errors: %v)", i, err, errs)
363 }
364 if toolCounts[i] != 2 {
365 t.Fatalf("AddWithLifecycle call %d returned %d tools, want 2", i, toolCounts[i])
366 }
367 }
368 if got := host.ServerNames(); len(got) != 1 || got[0] != "mock" {
369 t.Fatalf("host should contain exactly one connected server, got %v", got)
370 }
371 }
372
373 func TestLazyCacheHitSlowStartupContinuesInBackground(t *testing.T) {
374 redirectCache(t)
375 spec := helperSpec()
376 spec.StartupTimeout = 2 * time.Second
377 spec.Env["GO_WANT_HELPER_INIT_MS"] = "200"
378 writeMockCache(t, spec)
379
380 cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
381 if !ok {
382 t.Fatal("LoadCachedSchema: miss right after save (sanity)")
383 }
384
385 host := NewHost()
386 defer host.Close()
387 reg := tool.NewRegistry()
388 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
389 defer cancel()
390
391 tools := LazyToolset(spec, cs, host, reg, ctx, false)
392 for _, lt := range tools {
393 reg.Add(lt)
394 }
395 echo, ok := reg.Get("mcp__mock__echo")
396 if !ok {
397 t.Fatal("registry missing mcp__mock__echo after LazyToolset")
398 }
399 lazyEcho, ok := echo.(*lazyTool)
400 if !ok {
401 t.Fatalf("pre-Execute echo should be a *lazyTool, got %T", echo)
402 }
403 lazyEcho.shared.waitBudget = 25 * time.Millisecond
404 beforeName := echo.Name()
405 beforeDescription := echo.Description()
406 beforeSchema := string(echo.Schema())
407
408 if _, err := echo.Execute(ctx, json.RawMessage(`{"msg":"slow"}`)); err == nil || !strings.Contains(err.Error(), "continues in background") {
409 t.Fatalf("first Execute error = %v, want background startup notice", err)
410 }
411 waitForServer(t, host, spec.Name, 2*time.Second)
412 out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"retry"}`))
413 if err != nil {
414 t.Fatalf("second Execute after background startup should succeed: %v", err)
415 }
416 if out != "echo: retry" {
417 t.Fatalf("Execute result = %q, want %q", out, "echo: retry")
418 }
419 if echo.Name() != beforeName || echo.Description() != beforeDescription || string(echo.Schema()) != beforeSchema {
420 t.Fatalf("provider-visible cached tool changed across background startup")
421 }
422 }
423
424 func TestLazyToolsetInheritsInstalledServerReaderAuthorization(t *testing.T) {
425 redirectCache(t)
426 spec := helperSpec()
427 spec.LaunchManager = mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir())
428 spec.Authorized = true
429 if err := SaveCachedSchema(spec.Name, CachedSchema{
430 CacheKey: SchemaCacheKey(spec),
431 Tools: []CachedTool{{
432 Name: "echo", Description: "Echo back the message.",
433 Schema: json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}}}`), ReadOnly: true,
434 }},
435 }); err != nil {
436 t.Fatal(err)
437 }
438 cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
439 if !ok {
440 t.Fatal("LoadCachedSchema: miss right after save")
441 }
442
443 host := NewHost()
444 defer host.Close()
445 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
446 defer cancel()
447 tools := LazyToolset(spec, cs, host, tool.NewRegistry(), ctx, false)
448 var echo tool.Tool
449 for _, candidate := range tools {
450 if candidate.Name() == "mcp__mock__echo" {
451 echo = candidate
452 break
453 }
454 }
455 if echo == nil || !echo.ReadOnly() {
456 t.Fatalf("installed cached reader missing or not read-only: %T", echo)
457 }
458 if authority, ok := echo.(tool.MCPServerAuthorization); !ok || !authority.MCPServerAuthorized() {
459 t.Fatalf("lazy installed reader did not inherit authorization: %T", echo)
460 }
461 }
462
463 // TestLazyCacheMissAsyncSpawn drives the cache-miss branch: with no cache, a
464 // single "connect" placeholder shows up; first Execute returns a retry hint and
465 // kicks the spawn async; once that spawn finishes, the registry swaps to the
466 // real tools under their real names, and the connect stub is dropped. This is
467 // the "model warm-up" contract — the model must not see stale schemas, so we
468 // refuse to forward the first call and instead ask for one more turn.
469 func TestLazyCacheMissAsyncSpawn(t *testing.T) {
470 redirectCache(t)
471 spec := helperSpec()
472
473 host := NewHost()
474 defer host.Close()
475 reg := tool.NewRegistry()
476 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
477 defer cancel()
478
479 tools := LazyToolset(spec, nil, host, reg, ctx, false)
480 if len(tools) != 1 {
481 t.Fatalf("cache-miss LazyToolset must return 1 connect stub, got %d", len(tools))
482 }
483 for _, lt := range tools {
484 reg.Add(lt)
485 }
486
487 connect, ok := reg.Get("mcp__mock__connect")
488 if !ok {
489 t.Fatalf("registry missing mcp__mock__connect; names=%v", reg.Names())
490 }
491
492 // First Execute must NOT forward — schema is unknown, so the model would
493 // be feeding garbage. It returns a retry hint and triggers spawn async.
494 _, err := connect.Execute(ctx, json.RawMessage(`{}`))
495 if err == nil {
496 t.Fatal("first Execute on cache-miss placeholder should error with a retry hint")
497 }
498 msg := err.Error()
499 if !strings.Contains(msg, "initializing") && !strings.Contains(msg, "next turn") {
500 t.Fatalf("first-Execute error %q should mention 'initializing' or 'next turn'", msg)
501 }
502
503 // Wait for the async spawn to complete (host.Add happens on the run()
504 // goroutine kicked by Execute). The goroutine swaps the registry itself, so
505 // the next model request sees the real schemas without another placeholder
506 // Execute call.
507 waitForServer(t, host, "mock", 5*time.Second)
508
509 if _, found := reg.Get("mcp__mock__connect"); found {
510 t.Errorf("connect stub should be removed after swap, names=%v", reg.Names())
511 }
512 if _, found := reg.Get("mcp__mock__echo"); !found {
513 t.Errorf("real mcp__mock__echo missing after swap, names=%v", reg.Names())
514 }
515 if _, found := reg.Get("mcp__mock__zed"); !found {
516 t.Errorf("real mcp__mock__zed missing after swap, names=%v", reg.Names())
517 }
518 }
519
520 func TestLazySwapDoesNotRaceRegistrySchemas(t *testing.T) {
521 redirectCache(t)
522 spec := helperSpec()
523 spec.Env["GO_WANT_HELPER_INIT_MS"] = "50"
524 writeMockCache(t, spec)
525 cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
526
527 host := NewHost()
528 defer host.Close()
529 reg := tool.NewRegistry()
530 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
531 defer cancel()
532
533 tools := LazyToolset(spec, cs, host, reg, ctx, false)
534 for _, lt := range tools {
535 reg.Add(lt)
536 }
537 echo, _ := reg.Get("mcp__mock__echo")
538 if echo == nil {
539 t.Fatal("missing mcp__mock__echo placeholder")
540 }
541
542 done := make(chan struct{})
543 var wg sync.WaitGroup
544 wg.Add(1)
545 go func() {
546 defer wg.Done()
547 for {
548 select {
549 case <-done:
550 return
551 default:
552 _ = reg.Schemas()
553 }
554 }
555 }()
556
557 out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"race"}`))
558 close(done)
559 wg.Wait()
560 if err != nil {
561 t.Fatalf("Execute: %v", err)
562 }
563 if out != "echo: race" {
564 t.Fatalf("Execute result = %q, want %q", out, "echo: race")
565 }
566 }
567
568 // TestLazyBackgroundKick covers the background-tier path: kick=true plus a
569 // cache hit means the spawn races boot, finishes before the model calls, and
570 // the first Execute hits the "already-ready, swap on the way through" branch.
571 // The model never sees a placeholder schema-wise either, since the cache
572 // fed Schema() before kick even started.
573 func TestLazyBackgroundKick(t *testing.T) {
574 redirectCache(t)
575 spec := helperSpec()
576 writeMockCache(t, spec)
577 cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
578
579 host := NewHost()
580 defer host.Close()
581 reg := tool.NewRegistry()
582 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
583 defer cancel()
584
585 tools := LazyToolset(spec, cs, host, reg, ctx, true) // kick=true
586 if len(tools) != 2 {
587 t.Fatalf("LazyToolset(kick=true) returned %d tools, want 2", len(tools))
588 }
589 for _, lt := range tools {
590 reg.Add(lt)
591 }
592
593 // Wait for the background spawn to complete — proof that kick fired off
594 // the handshake without us calling Execute.
595 waitForServer(t, host, "mock", 5*time.Second)
596
597 // Now Execute: the state is already spawnReady, so this should swap +
598 // forward in one shot without a second Add call. The result must still be
599 // correct.
600 echo, _ := reg.Get("mcp__mock__echo")
601 out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"bg"}`))
602 if err != nil {
603 t.Fatalf("Execute after background ready: %v", err)
604 }
605 if out != "echo: bg" {
606 t.Fatalf("Execute result = %q, want %q", out, "echo: bg")
607 }
608
609 // One spawn, not two — kick + Execute must collapse onto the same run.
610 if names := host.ServerNames(); len(names) != 1 {
611 t.Fatalf("host.ServerNames() = %v, want exactly one 'mock'", names)
612 }
613 }
614
615 func TestLazyBackgroundCacheMissPersistsSchemaAndCompletesAdvertisedConnect(t *testing.T) {
616 redirectCache(t)
617 spec := helperSpec()
618
619 host := NewHost()
620 defer host.Close()
621 reg := tool.NewRegistry()
622 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
623 defer cancel()
624
625 tools := LazyToolset(spec, nil, host, reg, ctx, true) // cache miss + background kick
626 if len(tools) != 1 {
627 t.Fatalf("cache-miss LazyToolset returned %d tools, want one connect placeholder", len(tools))
628 }
629 connect, ok := tools[0].(*lazyTool)
630 if !ok {
631 t.Fatalf("cache-miss placeholder type = %T, want *lazyTool", tools[0])
632 }
633 for _, lt := range tools {
634 reg.Add(lt)
635 }
636
637 waitForServer(t, host, "mock", 5*time.Second)
638 cs := waitForCachedSchema(t, spec, 5*time.Second)
639 if len(cs.Tools) != 2 {
640 t.Fatalf("cached schema has %d tools, want 2", len(cs.Tools))
641 }
642 got := map[string]bool{}
643 for _, ct := range cs.Tools {
644 got[ct.Name] = true
645 }
646 if !got["echo"] || !got["zed"] {
647 t.Fatalf("cached tools = %v, want echo and zed", got)
648 }
649 if _, found := reg.Get(connect.Name()); found {
650 t.Fatalf("connect placeholder remained provider-visible after discovery; names=%v", reg.Names())
651 }
652 if out, err := connect.Execute(ctx, json.RawMessage(`{}`)); err != nil || !strings.Contains(out, "real tools are now available") {
653 t.Fatalf("already-advertised connect after discovery = (%q, %v), want controlled connected result", out, err)
654 }
655 }
656
657 func TestLazyBackgroundCloseCancelsInFlightKick(t *testing.T) {
658 redirectCache(t)
659 spec := helperSpec()
660 spec.Name = "slow"
661 spec.Env["GO_WANT_HELPER_INIT_MS"] = "5000"
662 writeMockCache(t, spec)
663 cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
664
665 host := NewHost()
666 reg := tool.NewRegistry()
667
668 tools := LazyToolset(spec, cs, host, reg, context.Background(), true)
669 for _, lt := range tools {
670 reg.Add(lt)
671 }
672
673 done := make(chan struct{})
674 go func() {
675 host.Close()
676 close(done)
677 }()
678 select {
679 case <-done:
680 case <-time.After(2 * time.Second):
681 t.Fatal("Host.Close did not cancel the in-flight background lazy spawn")
682 }
683
684 if names := host.ServerNames(); len(names) != 0 {
685 t.Fatalf("closed host retained connected servers: %v", names)
686 }
687 }
688
689 // TestLazyConcurrentExecuteOnlyOneSpawn pins the de-duplication contract: 10
690 // goroutines racing through Execute on the same lazyTool may only trigger ONE
691 // spawn (and therefore one connected mock server on the host). The state
692 // machine's mu+state gate is what makes this true; this test would catch a
693 // regression where someone moved the state transition outside the lock or
694 // swapped to a TOCTOU check.
695 //
696 // Note: by design (see lazy.go), only the winner of the race forwards
697 // synchronously; the losers observe spawnInFlight and return a "retry next
698 // turn" hint rather than blocking. We assert that contract too: at least one
699 // goroutine got "echo: r<i>", and the racers that didn't win got the
700 // initializing hint — never a spurious error and never a stale or partial
701 // result. After all goroutines complete, a fresh Execute hits spawnReady and
702 // forwards normally.
703 func TestLazyConcurrentExecuteOnlyOneSpawn(t *testing.T) {
704 redirectCache(t)
705 spec := helperSpec()
706 writeMockCache(t, spec)
707 cs, _ := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
708
709 host := NewHost()
710 defer host.Close()
711 reg := tool.NewRegistry()
712 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
713 defer cancel()
714
715 tools := LazyToolset(spec, cs, host, reg, ctx, false)
716 for _, lt := range tools {
717 reg.Add(lt)
718 }
719 echo, _ := reg.Get("mcp__mock__echo")
720
721 const goroutines = 10
722 var wg sync.WaitGroup
723 results := make([]string, goroutines)
724 errs := make([]error, goroutines)
725 wg.Add(goroutines)
726 for i := 0; i < goroutines; i++ {
727 go func(i int) {
728 defer wg.Done()
729 out, err := echo.Execute(ctx, json.RawMessage(fmt.Sprintf(`{"msg":"r%d"}`, i)))
730 results[i], errs[i] = out, err
731 }(i)
732 }
733 wg.Wait()
734
735 // Every result must be either the real "echo: rN" output or the explicit
736 // initializing hint — nothing else. At least one goroutine (the racing
737 // winner) must succeed, otherwise the state machine deadlocked the win.
738 winners := 0
739 for i, err := range errs {
740 want := fmt.Sprintf("echo: r%d", i)
741 switch {
742 case err == nil && results[i] == want:
743 winners++
744 case err != nil && strings.Contains(err.Error(), "initializing"):
745 // expected loser
746 default:
747 t.Errorf("goroutine %d: result=%q err=%v — must be either %q or an 'initializing' hint", i, results[i], err, want)
748 }
749 }
750 if winners == 0 {
751 t.Fatal("no goroutine succeeded — at least the race winner must forward through")
752 }
753
754 // Exactly one Client landed on the host: the mu+state gate kept the 9
755 // losers off the spawn path. This is the headline invariant of the lazy
756 // design — racing the first call must not fork-bomb the subprocess.
757 mockCount := 0
758 for _, n := range host.ServerNames() {
759 if n == "mock" {
760 mockCount++
761 }
762 }
763 if mockCount != 1 {
764 t.Fatalf("expected 1 'mock' server after concurrent Execute, got %d (names=%v)", mockCount, host.ServerNames())
765 }
766
767 // A follow-up Execute (now in spawnReady) goes through cleanly: the
768 // "retry on next turn" hint was honest, not a permanent error.
769 out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"after"}`))
770 if err != nil {
771 t.Fatalf("post-race Execute: %v", err)
772 }
773 if out != "echo: after" {
774 t.Fatalf("post-race Execute = %q, want %q", out, "echo: after")
775 }
776 }
777
778 // TestLazyHandshakeFailureSurfaced covers the spawnFailed sticky branch: a
779 // bogus command can't start, the first Execute returns an error that mentions
780 // "failed to start", and a second Execute returns the SAME error (the state
781 // machine doesn't retry — we don't want to fork a doomed subprocess every
782 // turn until the user fixes config).
783 func TestLazyHandshakeFailureSurfaced(t *testing.T) {
784 redirectCache(t)
785 // Bogus command: process exec will fail outright.
786 spec := Spec{Name: "missing", Command: "reasonix-nonexistent-binary-for-lazy-test"}
787
788 // Hand-craft a cache so the cache-HIT branch runs (synchronous spawn,
789 // failure surfaces directly to the first caller rather than via a retry
790 // hint). The CacheKey must match — otherwise LoadCachedSchema would miss
791 // and we'd be exercising the async path.
792 cs := &CachedSchema{
793 CacheKey: SchemaCacheKey(spec),
794 Capabilities: map[string]bool{},
795 Tools: []CachedTool{{
796 Name: "doit",
797 Description: "noop",
798 Schema: json.RawMessage(`{"type":"object"}`),
799 }},
800 }
801
802 host := NewHost()
803 defer host.Close()
804 reg := tool.NewRegistry()
805 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
806 defer cancel()
807
808 tools := LazyToolset(spec, cs, host, reg, ctx, false)
809 if len(tools) != 1 {
810 t.Fatalf("LazyToolset returned %d tools, want 1 (doit)", len(tools))
811 }
812 for _, lt := range tools {
813 reg.Add(lt)
814 }
815 doit, _ := reg.Get("mcp__missing__doit")
816
817 _, err1 := doit.Execute(ctx, json.RawMessage(`{}`))
818 if err1 == nil {
819 t.Fatal("Execute on a bogus command should error")
820 }
821 if !strings.Contains(err1.Error(), "failed to start") {
822 t.Fatalf("error %q should mention 'failed to start'", err1.Error())
823 }
824
825 // Second call: same error, no retry. spawnFailed is sticky on purpose —
826 // the operator must fix config and restart, not have us fork-bomb on
827 // every turn.
828 _, err2 := doit.Execute(ctx, json.RawMessage(`{}`))
829 if err2 == nil {
830 t.Fatal("second Execute after spawnFailed should still error")
831 }
832 if !strings.Contains(err2.Error(), "failed to start") {
833 t.Fatalf("second error %q should still mention 'failed to start' (state machine must stay in spawnFailed)", err2.Error())
834 }
835 }
836
837 // TestLazyToolsetCacheHitSchemaVisible is the model-facing visibility test:
838 // immediately after LazyToolset returns and BEFORE any Execute, lazyTool.Schema()
839 // must equal the canonicalized cached schema. The whole point of the cache is
840 // that the model sees real schemas at turn-start; if Schema() returned the
841 // "{}" stub here, the model would call with empty args and the cache-hit
842 // path would never get a useful first call.
843 func TestLazyToolsetCacheHitSchemaVisible(t *testing.T) {
844 redirectCache(t)
845 spec := helperSpec()
846
847 rawSchema := json.RawMessage(`{"properties":{"msg":{"type":"string"}},"type":"object","required":["msg"]}`)
848 cs := &CachedSchema{
849 CacheKey: SchemaCacheKey(spec),
850 Capabilities: map[string]bool{},
851 Tools: []CachedTool{{
852 Name: "echo",
853 Description: "Echo back.",
854 Schema: rawSchema,
855 }},
856 }
857
858 host := NewHost()
859 defer host.Close()
860 reg := tool.NewRegistry()
861 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
862 defer cancel()
863
864 tools := LazyToolset(spec, cs, host, reg, ctx, false)
865 if len(tools) != 1 {
866 t.Fatalf("LazyToolset returned %d tools, want 1", len(tools))
867 }
868 got := string(tools[0].Schema())
869 want := string(canonicalizeSchema(rawSchema))
870 if got != want {
871 t.Fatalf("lazyTool.Schema() = %s,\nwant canonicalized cached schema = %s", got, want)
872 }
873
874 // And we never spawned: Schema() must be free, otherwise the cache
875 // optimisation is moot.
876 if names := host.ServerNames(); len(names) != 0 {
877 t.Fatalf("Schema() must not spawn; host.ServerNames() = %v", names)
878 }
879 }
880
881 // registrySchemaBytes marshals the registry's full tool schemas — the exact
882 // surface that feeds the provider request's tools array.
883 func registrySchemaBytes(t *testing.T, reg *tool.Registry) string {
884 t.Helper()
885 b, err := json.Marshal(reg.Schemas())
886 if err != nil {
887 t.Fatalf("marshal schemas: %v", err)
888 }
889 return string(b)
890 }
891
892 // TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake is the session
893 // byte-stability guard: the cached snapshot deliberately DIFFERS from what the
894 // live handshake will report (stale description/schema, and it omits one tool
895 // the live server exposes). After the background spawn completes, the
896 // registry's schema bytes must be identical to what the model saw at boot —
897 // the divergence surfaces in the refreshed disk cache (next session), never
898 // mid-session in the tools array.
899 func TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake(t *testing.T) {
900 redirectCache(t)
901 spec := helperSpec()
902 stale := CachedSchema{
903 CacheKey: SchemaCacheKey(spec),
904 Capabilities: map[string]bool{},
905 Tools: []CachedTool{{
906 Name: "echo",
907 Description: "STALE description from a previous session.",
908 Schema: json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}}}`),
909 // live handshake also exposes "zed" — absent here on purpose.
910 }},
911 }
912 if err := SaveCachedSchema(spec.Name, stale); err != nil {
913 t.Fatalf("SaveCachedSchema: %v", err)
914 }
915 cs, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
916 if !ok {
917 t.Fatal("LoadCachedSchema miss after save")
918 }
919
920 host := NewHost()
921 defer host.Close()
922 reg := tool.NewRegistry()
923 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
924 defer cancel()
925
926 for _, lt := range LazyToolset(spec, cs, host, reg, ctx, true) {
927 reg.Add(lt)
928 }
929 bootBytes := registrySchemaBytes(t, reg)
930
931 // Let the background handshake finish and give trySwap every chance to run.
932 waitForServer(t, host, "mock", 5*time.Second)
933 echo, _ := reg.Get("mcp__mock__echo")
934 if out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"pin"}`)); err != nil || out != "echo: pin" {
935 t.Fatalf("Execute after schema drift = %q, %v; want live execution", out, err)
936 }
937
938 if got := registrySchemaBytes(t, reg); got != bootBytes {
939 t.Fatalf("tools array bytes changed mid-session after a divergent handshake:\nboot: %s\nnow: %s", bootBytes, got)
940 }
941 if _, found := reg.Get("mcp__mock__zed"); found {
942 t.Fatal("live-only tool joined the registry mid-session; it must wait for the next session")
943 }
944
945 // The refreshed cache carries the live truth for the NEXT session. The
946 // stale cache this test wrote is itself loadable, so poll until the
947 // refresh actually lands (the background save races Execute's return on
948 // slow machines) rather than accepting the first loadable snapshot.
949 deadline := time.Now().Add(5 * time.Second)
950 for {
951 refreshed, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec))
952 if ok {
953 names := map[string]bool{}
954 for _, ct := range refreshed.Tools {
955 names[ct.Name] = true
956 }
957 if names["echo"] && names["zed"] {
958 break
959 }
960 if time.Now().After(deadline) {
961 t.Fatalf("refreshed cache tools = %v, want live set {echo, zed}", refreshed.Tools)
962 }
963 } else if time.Now().After(deadline) {
964 t.Fatal("cached schema never became loadable")
965 }
966 time.Sleep(10 * time.Millisecond)
967 }
968 }
969
970 func TestLazyToolPromotesLiveDestructiveHintBeforeExecution(t *testing.T) {
971 const name = "mcp__srv__wipe"
972 target := &destructiveLazyTarget{name: name}
973 shared := &lazySpawn{
974 spec: Spec{Name: "srv"},
975 state: spawnReady,
976 real: map[string]tool.Tool{name: target},
977 swapped: true,
978 }
979 lazy := &lazyTool{
980 shared: shared,
981 name: name,
982 rawName: "wipe",
983 readOnly: true,
984 hasCache: true,
985 }
986
987 if out, err := lazy.Execute(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "retry") || out != "" {
988 t.Fatalf("first Execute = (%q,%v), want retry before destructive execution", out, err)
989 }
990 if target.calls != 0 || !lazy.MCPDestructiveHint() {
991 t.Fatalf("after promotion calls=%d destructive=%v, want 0/true", target.calls, lazy.MCPDestructiveHint())
992 }
993
994 out, err := lazy.Execute(context.Background(), nil)
995 if err != nil || out != "executed" || target.calls != 1 {
996 t.Fatalf("second Execute = (%q,%v), calls=%d, want execution after metadata refresh retry", out, err, target.calls)
997 }
998 }
999
1000 func TestLazyToolDemotesStaleReaderBeforeExecution(t *testing.T) {
1001 const name = "mcp__srv__mutate"
1002 target := &mutableLazyTarget{name: name}
1003 shared := &lazySpawn{
1004 spec: Spec{Name: "srv"},
1005 state: spawnReady,
1006 real: map[string]tool.Tool{name: target},
1007 swapped: true,
1008 }
1009 lazy := &lazyTool{
1010 shared: shared, name: name, rawName: "mutate", readOnly: true, hasCache: true,
1011 }
1012
1013 if out, err := lazy.Execute(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "Plan/read-only safety boundary") || out != "" {
1014 t.Fatalf("first Execute = (%q,%v), want retry before writer execution", out, err)
1015 }
1016 if target.calls != 0 || lazy.ReadOnly() {
1017 t.Fatalf("after demotion calls=%d readOnly=%v, want 0/false", target.calls, lazy.ReadOnly())
1018 }
1019
1020 out, err := lazy.Execute(context.Background(), nil)
1021 if err != nil || out != "executed" || target.calls != 1 {
1022 t.Fatalf("second Execute = (%q,%v), calls=%d", out, err, target.calls)
1023 }
1024 }
1025
1026 // TestLazyEmptyCachedToolsFallsBackToConnectStub: a snapshot with zero tools
1027 // presents nothing the model could call, so it must take the cache-miss stub
1028 // path instead of letting live tools join the registry mid-session unnamed.
1029 func TestLazyEmptyCachedToolsFallsBackToConnectStub(t *testing.T) {
1030 redirectCache(t)
1031 spec := helperSpec()
1032 cs := &CachedSchema{CacheKey: SchemaCacheKey(spec), Tools: nil}
1033
1034 host := NewHost()
1035 defer host.Close()
1036 reg := tool.NewRegistry()
1037 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1038 defer cancel()
1039
1040 tools := LazyToolset(spec, cs, host, reg, ctx, false)
1041 if len(tools) != 1 || tools[0].Name() != "mcp__mock__connect" {
1042 t.Fatalf("empty-cache toolset = %v, want single connect stub", tools)
1043 }
1044 }
1045
1046 // TestAddWithLifecycleSurvivesHandshakeCtxCancel proves the on-demand proxy
1047 // pattern: connect with a short handshake budget, cancel it immediately after
1048 // connect, and the stdio child must stay alive (its lifetime is lifeCtx) so
1049 // the tool call that triggered the connect can still execute.
1050 func TestAddWithLifecycleSurvivesHandshakeCtxCancel(t *testing.T) {
1051 spec := helperSpec()
1052 host := NewHost()
1053 defer host.Close()
1054
1055 lifeCtx, cancelLife := context.WithCancel(context.Background())
1056 defer cancelLife()
1057 handshakeCtx, cancelHandshake := context.WithTimeout(context.Background(), 5*time.Second)
1058 tools, err := host.AddWithLifecycle(lifeCtx, handshakeCtx, spec)
1059 cancelHandshake() // the proxy's deferred cancel fires right after connect
1060 if err != nil {
1061 t.Fatalf("AddWithLifecycle: %v", err)
1062 }
1063 var echo tool.Tool
1064 for _, tl := range tools {
1065 if strings.HasSuffix(tl.Name(), "__echo") {
1066 echo = tl
1067 }
1068 }
1069 if echo == nil {
1070 t.Fatalf("no echo tool in %d tools", len(tools))
1071 }
1072 out, err := echo.Execute(context.Background(), json.RawMessage(`{"msg":"hi"}`))
1073 if err != nil {
1074 t.Fatalf("Execute after handshake ctx cancel: %v — the child died with the handshake context", err)
1075 }
1076 if out != "echo: hi" {
1077 t.Fatalf("Execute result = %q, want %q", out, "echo: hi")
1078 }
1079 }
1080
1080 lines GO