返回 DeepSeek-Reasonix
catalog_watcher.go
根目录 / internal / skill / catalog_watcher.go
1 package skill
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "sort"
8
9 "github.com/fsnotify/fsnotify"
10 )
11
12 type watcherLifecycle struct {
13 cancel context.CancelFunc
14 active bool
15 }
16
17 func (s *Store) Close() error {
18 if s == nil {
19 return nil
20 }
21 s.watcherMu.Lock()
22 if s.hostWatch.active {
23 // Service-backed watches: Release is logical and never blocks on
24 // backend IO, so Close cannot get stuck on an uninterruptible
25 // registration path.
26 subs := s.hostWatch.subs
27 s.hostWatch.subs = nil
28 s.hostWatch.active = false
29 s.closed = true
30 s.watcherMu.Unlock()
31 for _, sub := range subs {
32 sub.Release()
33 }
34 s.catalogMu.Lock()
35 if s.catalogFlight != nil && s.catalogFlight.cancel != nil {
36 s.catalogFlight.cancel()
37 }
38 s.catalogMu.Unlock()
39 return nil
40 }
41 if s.closed {
42 done := s.watcherDone
43 s.watcherMu.Unlock()
44 if done != nil {
45 <-done
46 }
47 return nil
48 }
49 s.closed = true
50 s.watcherGeneration++
51 done, cancel := s.watcherDone, s.watcherLifecycle.cancel
52 s.watcher, s.watcherLifecycle.cancel = nil, nil
53 s.watcherLifecycle.active = false
54 s.watcherMu.Unlock()
55 if cancel != nil {
56 cancel()
57 }
58 s.catalogMu.Lock()
59 if s.catalogFlight != nil && s.catalogFlight.cancel != nil {
60 s.catalogFlight.cancel()
61 }
62 s.catalogMu.Unlock()
63 if done != nil {
64 <-done
65 }
66 return nil
67 }
68
69 func (s *Store) ensureWatcher() {
70 if s == nil || s.disableDiscovery {
71 return
72 }
73 if s.hostWatch.service != nil {
74 s.subscribeHostWatch()
75 return
76 }
77 s.watcherMu.Lock()
78 if s.closed || s.watcherLifecycle.active {
79 s.watcherMu.Unlock()
80 return
81 }
82 watcher, err := fsnotify.NewWatcher()
83 if err != nil {
84 s.watcherMu.Unlock()
85 return
86 }
87 s.watcherGeneration++
88 generation := s.watcherGeneration
89 done := make(chan struct{})
90 ctx, cancel := context.WithCancel(context.Background())
91 s.watcher, s.watcherDone, s.watcherLifecycle.active = watcher, done, true
92 s.watcherLifecycle.cancel = cancel
93 s.watcherMu.Unlock()
94
95 ready := make(chan struct{})
96 go s.watchCatalog(ctx, watcher, generation, done, ready)
97 <-ready
98 }
99
100 func (s *Store) watchCatalog(ctx context.Context, watcher *fsnotify.Watcher, generation uint64, done, ready chan struct{}) {
101 defer close(done)
102 defer func() {
103 s.watcherMu.Lock()
104 if s.watcher == watcher && s.watcherGeneration == generation {
105 s.watcher = nil
106 s.watcherDone = nil
107 s.watcherLifecycle.active = false
108 }
109 s.watcherMu.Unlock()
110 }()
111 runCatalogWatch(ctx, watcher.Events, watcher.Errors, ready,
112 func() { s.refreshWatcherPaths(ctx, watcher, generation) }, watcher.Close,
113 func(reason string) {
114 if s.watcherCurrent(watcher, generation) {
115 s.Invalidate(reason)
116 }
117 })
118 }
119
120 func runCatalogWatch(ctx context.Context, events <-chan fsnotify.Event, errors <-chan error, ready chan struct{}, register func(), closeWatcher func() error, invalidate func(string)) {
121 ctx, cancel := context.WithCancel(ctx)
122 refresh := make(chan struct{}, 1)
123 workerDone := make(chan struct{})
124 go func() {
125 defer close(workerDone)
126 register()
127 close(ready)
128 for {
129 select {
130 case <-ctx.Done():
131 _ = closeWatcher()
132 return
133 case <-refresh:
134 register()
135 }
136 }
137 }()
138 defer func() { cancel(); <-workerDone }()
139 // Add and Close may wait for fsnotify's error sender. Keep both channels
140 // draining while the registration worker mutates or closes the watcher.
141 for events != nil || errors != nil {
142 select {
143 case event, ok := <-events:
144 if !ok {
145 events = nil
146 continue
147 }
148 if ctx.Err() != nil {
149 continue
150 }
151 if event.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename|fsnotify.Write|fsnotify.Chmod) == 0 {
152 continue
153 }
154 invalidate("filesystem changed")
155 // A create/rename can introduce a directory, symlink target, or a
156 // previously missing root. Rebuild the subscriptions from the roots.
157 select {
158 case refresh <- struct{}{}:
159 default:
160 }
161 case _, ok := <-errors:
162 if !ok {
163 errors = nil
164 continue
165 }
166 if ctx.Err() == nil {
167 invalidate("filesystem watcher failed")
168 }
169 }
170 }
171 }
172
173 func (s *Store) watcherCurrent(watcher *fsnotify.Watcher, generation uint64) bool {
174 s.watcherMu.Lock()
175 defer s.watcherMu.Unlock()
176 return !s.closed && s.watcher == watcher && s.watcherGeneration == generation
177 }
178
179 func (s *Store) refreshWatcherPaths(ctx context.Context, watcher *fsnotify.Watcher, generation uint64) {
180 if !s.watcherCurrent(watcher, generation) {
181 return
182 }
183 for _, root := range s.roots() {
184 directories, _ := watchDirectoriesContext(ctx, root.Dir, s.maxDepth)
185 for _, dir := range directories {
186 if ctx.Err() != nil {
187 return
188 }
189 _ = watcher.Add(dir)
190 }
191 }
192 }
193
194 // watchDirectories includes every existing directory that discovery can visit.
195 // For a missing root it subscribes to the nearest existing ancestor, allowing
196 // later creation to invalidate the snapshot. Symlink targets are traversed once.
197 func watchDirectoriesContext(ctx context.Context, root string, maxDepth int) ([]string, bool) {
198 root = filepath.Clean(root)
199 probe := root
200 for {
201 if ctx.Err() != nil {
202 return nil, false
203 }
204 info, err := os.Stat(probe)
205 if err == nil && info.IsDir() {
206 break
207 }
208 parent := filepath.Dir(probe)
209 if parent == probe {
210 return nil, true
211 }
212 probe = parent
213 }
214 if probe != root {
215 return []string{probe}, true
216 }
217 type pendingDir struct {
218 path string
219 depth int
220 }
221 pending := []pendingDir{{path: root, depth: 0}}
222 seen := map[string]bool{}
223 var out []string
224 for len(pending) > 0 {
225 if ctx.Err() != nil {
226 return nil, false
227 }
228 current := pending[0]
229 pending = pending[1:]
230 resolved := current.path
231 if target, err := filepath.EvalSymlinks(current.path); err == nil {
232 resolved = filepath.Clean(target)
233 }
234 if seen[resolved] {
235 continue
236 }
237 seen[resolved] = true
238 info, err := os.Stat(current.path)
239 if err != nil || !info.IsDir() {
240 continue
241 }
242 out = append(out, current.path)
243 if resolved != current.path {
244 out = append(out, resolved)
245 }
246 if current.depth >= maxDepth {
247 continue
248 }
249 entries, err := os.ReadDir(current.path)
250 if err != nil {
251 continue
252 }
253 for _, entry := range entries {
254 if ctx.Err() != nil {
255 return nil, false
256 }
257 child := filepath.Join(current.path, entry.Name())
258 if entry.IsDir() {
259 pending = append(pending, pendingDir{path: child, depth: current.depth + 1})
260 continue
261 }
262 if entry.Type()&os.ModeSymlink != 0 {
263 if target, err := os.Stat(child); err == nil && target.IsDir() {
264 pending = append(pending, pendingDir{path: child, depth: current.depth + 1})
265 }
266 }
267 }
268 }
269 sort.Strings(out)
270 return out, true
271 }
272
273 // Invalidate advances the catalog generation. The last complete snapshot stays
274 // available to cancelled callers until a replacement scan completes.
275
275 lines GO