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