返回 DeepSeek-Reasonix
transport_stdio_cache_test.go
根目录 / internal / plugin / transport_stdio_cache_test.go
1 package plugin
2
3 import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8 )
9
10 func TestCachedShellPATHMemoizesNonEmpty(t *testing.T) {
11 calls := 0
12 probe := cachedShellPATH(func(context.Context) string {
13 calls++
14 return "/opt/homebrew/bin"
15 })
16 for range 3 {
17 if got := probe(context.Background()); got != "/opt/homebrew/bin" {
18 t.Fatalf("probe() = %q", got)
19 }
20 }
21 if calls != 1 {
22 t.Errorf("probe ran %d times, want 1 (memoized)", calls)
23 }
24 }
25
26 func TestCachedShellPATHMemoizesCompletedEmptyProbe(t *testing.T) {
27 calls := 0
28 probe := cachedShellPATH(func(context.Context) string {
29 calls++
30 return ""
31 })
32 // A probe that ran to completion and found nothing is still an answer: a
33 // host without a usable login shell must not re-run the (up to 6s) probe on
34 // every stdio spawn.
35 for range 3 {
36 if got := probe(context.Background()); got != "" {
37 t.Fatalf("probe() = %q, want empty", got)
38 }
39 }
40 if calls != 1 {
41 t.Errorf("probe ran %d times, want 1 (empty result memoized)", calls)
42 }
43 }
44
45 func TestCachedShellPATHRetriesAfterCancelledProbe(t *testing.T) {
46 calls := 0
47 probe := cachedShellPATH(func(ctx context.Context) string {
48 calls++
49 if ctx.Err() != nil {
50 return ""
51 }
52 return "/usr/local/bin"
53 })
54
55 cancelled, cancel := context.WithCancel(context.Background())
56 cancel()
57 if got := probe(cancelled); got != "" {
58 t.Fatalf("probe(cancelled) = %q, want empty", got)
59 }
60 // The empty result came from the aborted caller, not the host, so it must
61 // not have been cached: a fresh context probes again.
62 if got := probe(context.Background()); got != "/usr/local/bin" {
63 t.Fatalf("probe() after cancelled attempt = %q, want /usr/local/bin", got)
64 }
65 if calls != 2 {
66 t.Errorf("probe ran %d times, want 2", calls)
67 }
68 }
69
70 func TestCachedShellPATHConcurrentSpawnsShareOneProbe(t *testing.T) {
71 var (
72 mu sync.Mutex
73 calls int
74 started = make(chan struct{})
75 release = make(chan struct{})
76 )
77 probe := cachedShellPATH(func(context.Context) string {
78 mu.Lock()
79 calls++
80 if calls == 1 {
81 close(started)
82 }
83 mu.Unlock()
84 <-release
85 return "/opt/homebrew/bin"
86 })
87
88 const n = 8
89 results := make(chan string, n)
90 for range n {
91 go func() { results <- probe(context.Background()) }()
92 }
93
94 select {
95 case <-started:
96 case <-time.After(5 * time.Second):
97 t.Fatal("no probe started")
98 }
99 // Give the remaining goroutines a moment to reach the cache; they must park
100 // on the in-flight probe rather than each spawning login shells of their own.
101 time.Sleep(50 * time.Millisecond)
102 close(release)
103
104 for i := range n {
105 select {
106 case got := <-results:
107 if got != "/opt/homebrew/bin" {
108 t.Fatalf("result %d = %q", i, got)
109 }
110 case <-time.After(5 * time.Second):
111 t.Fatal("probe call did not return; waiters stuck behind the in-flight probe")
112 }
113 }
114 mu.Lock()
115 defer mu.Unlock()
116 if calls != 1 {
117 t.Errorf("probe ran %d times, want 1 (shared in-flight probe)", calls)
118 }
119 }
120
121 func TestCachedShellPATHWaiterUnblocksOnItsOwnCancel(t *testing.T) {
122 started := make(chan struct{}, 2)
123 release := make(chan struct{})
124 t.Cleanup(func() { close(release) })
125 probe := cachedShellPATH(func(context.Context) string {
126 started <- struct{}{}
127 <-release
128 return ""
129 })
130
131 go probe(context.Background())
132 select {
133 case <-started: // the background call now owns the in-flight probe
134 case <-time.After(5 * time.Second):
135 t.Fatal("probe never started")
136 }
137
138 waiterCtx, cancel := context.WithCancel(context.Background())
139 done := make(chan string, 1)
140 go func() { done <- probe(waiterCtx) }()
141
142 time.Sleep(50 * time.Millisecond) // let the waiter park on the in-flight probe
143 cancel()
144
145 select {
146 case got := <-done:
147 if got != "" {
148 t.Fatalf("cancelled waiter = %q, want empty", got)
149 }
150 case <-time.After(5 * time.Second):
151 t.Fatal("cancelled waiter stayed blocked on another spawn's probe")
152 }
153 }
154
154 lines GO