返回 DeepSeek-Reasonix
helper_test.go
根目录 / internal / skill / skillwatch / helper_test.go
1 package skillwatch
2
3 import (
4 "context"
5 "io"
6 "os"
7 "path/filepath"
8 "sync"
9 "testing"
10 "time"
11 )
12
13 // pipeHelperProcess adapts an in-goroutine RunHelper to the helperProcess
14 // interface, so the full pipe protocol is exercised without re-exec.
15 type pipeHelperProcess struct {
16 stdin io.Writer
17 stdout io.Reader
18 done chan struct{}
19 once sync.Once
20 fail func()
21 }
22
23 func (p *pipeHelperProcess) Stdin() io.Writer { return p.stdin }
24 func (p *pipeHelperProcess) Stdout() io.Reader { return p.stdout }
25 func (p *pipeHelperProcess) Wait() error {
26 <-p.done
27 return nil
28 }
29 func (p *pipeHelperProcess) Kill() error {
30 p.once.Do(func() {
31 if p.fail != nil {
32 p.fail()
33 }
34 })
35 return nil
36 }
37
38 // startRunHelper pipes RunHelper directly. Killing the "process" closes the
39 // pipes, which unblocks RunHelper's reader and tears the helper down.
40 func startRunHelper(ctx context.Context) (helperProcess, error) {
41 // io.Pipe() returns (reader, writer).
42 hostRead, helperToHost := io.Pipe() // helper writes -> host reads
43 helperFromHost, hostWrite := io.Pipe() // host writes -> helper reads
44 done := make(chan struct{})
45 h := &pipeHelperProcess{stdin: hostWrite, stdout: hostRead, done: done}
46 go func() {
47 defer close(done)
48 _ = RunHelper(helperFromHost, helperToHost)
49 }()
50 h.fail = func() {
51 _ = hostWrite.Close()
52 _ = helperFromHost.Close()
53 _ = helperToHost.Close()
54 _ = hostRead.Close()
55 }
56 return h, nil
57 }
58
59 func TestHelperProtocolEvents(t *testing.T) {
60 dir := t.TempDir()
61 svc := NewService(Options{Stderr: io.Discard, ForceHelper: true, HelperCommand: startRunHelper})
62 defer svc.Close()
63
64 var hits int
65 var mu sync.Mutex
66 svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits++; mu.Unlock() })
67
68 // Subscribe waits (bounded) for the helper's registered confirmation; a
69 // write after that must arrive as an event and coalesce into one notify.
70 waitFor(t, "helper registration", func() bool {
71 return svc.helper != nil && svc.helper.physicalWatches() > 0
72 })
73 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("x"), 0o644); err != nil {
74 t.Fatal(err)
75 }
76 waitFor(t, "helper event notification", func() bool {
77 mu.Lock()
78 defer mu.Unlock()
79 return hits >= 1
80 })
81 }
82
83 func TestHelperRestartBudgetDegradesThenScans(t *testing.T) {
84 dir := t.TempDir()
85 var procMu sync.Mutex
86 var current *pipeHelperProcess
87 svc := NewService(Options{Stderr: io.Discard, ForceHelper: true, HelperCommand: func(ctx context.Context) (helperProcess, error) {
88 h, err := startRunHelper(ctx)
89 if err != nil {
90 return nil, err
91 }
92 procMu.Lock()
93 current = h.(*pipeHelperProcess)
94 procMu.Unlock()
95 return h, nil
96 }})
97 defer svc.Close()
98
99 var hits int
100 var mu sync.Mutex
101 svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits++; mu.Unlock() })
102 waitFor(t, "initial registration", func() bool {
103 return svc.helper.physicalWatches() > 0
104 })
105
106 // Kill the helper repeatedly: restart 1, restart 2, then the budget is
107 // spent and the service must degrade this root to scan fallback.
108 for i := range 3 {
109 procMu.Lock()
110 proc := current
111 procMu.Unlock()
112 if proc != nil {
113 proc.Kill()
114 }
115 if i < 2 {
116 waitFor(t, "restart to re-register", func() bool {
117 return svc.helper.physicalWatches() > 0 && svc.Diagnostics().HelperRestarts == uint64(i+1)
118 })
119 }
120 }
121 waitFor(t, "degraded root after restart budget", func() bool {
122 return svc.Diagnostics().DegradedRoots == 1
123 })
124
125 // Degraded mode still reports changes through the bounded scans.
126 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("after degradation"), 0o644); err != nil {
127 t.Fatal(err)
128 }
129 waitFor(t, "scan fallback notification after degradation", func() bool {
130 mu.Lock()
131 defer mu.Unlock()
132 return hits >= 1
133 })
134 // Close must reclaim the degraded scan loop and the dead helper cleanly.
135 if err := svc.Close(); err != nil {
136 t.Fatalf("Close: %v", err)
137 }
138 }
139
140 func TestSubscribeOnDegradedHelperStillServesScans(t *testing.T) {
141 dir := t.TempDir()
142 svc := NewService(Options{Stderr: io.Discard, ForceHelper: true, HelperCommand: func(ctx context.Context) (helperProcess, error) {
143 return nil, errHelperStopped
144 }})
145 defer svc.Close()
146
147 var hits int
148 var mu sync.Mutex
149 svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits++; mu.Unlock() })
150 if diag := svc.Diagnostics(); diag.PhysicalWatches != 0 {
151 t.Fatalf("physical watches without helper = %d, want 0", diag.PhysicalWatches)
152 }
153 if err := os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644); err != nil {
154 t.Fatal(err)
155 }
156 waitFor(t, "degraded scan fallback", func() bool {
157 mu.Lock()
158 defer mu.Unlock()
159 return hits >= 1
160 })
161 }
162
163 func TestLateEventsForReleasedSubscriptionDropped(t *testing.T) {
164 dir := t.TempDir()
165 svc := NewService(Options{Stderr: io.Discard})
166 defer svc.Close()
167
168 var hits int
169 var mu sync.Mutex
170 sub := svc.Subscribe(dir, 2, countingScope, flatHash, func(string) { mu.Lock(); hits++; mu.Unlock() })
171 sub.Release()
172
173 // Feed an event that references the (now gone) registration; the service
174 // must not notify and must not panic.
175 svc.eventArrived(9999, 1, OpWrite)
176 time.Sleep(50 * time.Millisecond)
177 mu.Lock()
178 defer mu.Unlock()
179 if hits != 0 {
180 t.Fatalf("released subscription notified %d times", hits)
181 }
182 }
183
183 lines GO