返回 DeepSeek-Reasonix
backend_helper.go
根目录 / internal / skill / skillwatch / backend_helper.go
1 package skillwatch
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "maps"
9 "os"
10 "os/exec"
11 "sync"
12 "time"
13
14 "reasonix/internal/proc"
15 )
16
17 // helperClient speaks the pipe protocol to a watcher helper process. Control
18 // operations get helperControlTimeout; a helper that misses it is killed and
19 // rebuilt at most helperRestartLimit times per helperRestartWindow before the
20 // client reports unavailable and every root degrades to scan fallback.
21 //
22 // Cancel semantics: the service drops the logical subscription before cancel
23 // is sent, so late events for a cancelled registration are recognized by
24 // (id, rootGen) and dropped host-side even if the helper never saw the cancel.
25 type helperClient struct {
26 start func(ctx context.Context) (helperProcess, error)
27 svc *Service
28
29 mu sync.Mutex
30 proc helperProcess
31 active map[uint64]activeRegistration
32 restarts []time.Time
33 unusable bool
34 closed bool
35 restarting bool
36
37 writeMu sync.Mutex
38 confirm map[uint64]chan helperConfirm
39 }
40
41 type activeRegistration struct {
42 rootGen uint64
43 dirs []string
44 }
45
46 type helperConfirm struct {
47 id uint64
48 err error
49 }
50
51 func newHelperClient(start func(ctx context.Context) (helperProcess, error), svc *Service) *helperClient {
52 c := &helperClient{
53 start: start,
54 svc: svc,
55 active: map[uint64]activeRegistration{},
56 confirm: map[uint64]chan helperConfirm{},
57 }
58 c.spawn()
59 return c
60 }
61
62 // defaultHelperCommand re-enters this executable through the internal helper
63 // entry. The helper is the same binary, so the protocol can never drift
64 // between host and helper.
65 func defaultHelperCommand(ctx context.Context) (helperProcess, error) {
66 exe, err := os.Executable()
67 if err != nil {
68 return nil, err
69 }
70 // The helper is a console-less child of a GUI process, so it must be
71 // spawned through internal/proc: a bare exec.Command leaves Windows to
72 // allocate a console for it and flashes a window on every start.
73 cmd := proc.CommandContext(ctx, exe)
74 cmd.Env = append(os.Environ(), watchHelperEnv+"=1")
75 stdin, err := cmd.StdinPipe()
76 if err != nil {
77 return nil, err
78 }
79 stdout, err := cmd.StdoutPipe()
80 if err != nil {
81 return nil, err
82 }
83 cmd.Stderr = os.Stderr
84 if err := cmd.Start(); err != nil {
85 return nil, err
86 }
87 return &execHelper{cmd: cmd, stdin: stdin, stdout: stdout}, nil
88 }
89
90 const watchHelperEnv = "REASONIX_SKILL_WATCH_HELPER"
91
92 type execHelper struct {
93 cmd *exec.Cmd
94 stdin io.WriteCloser
95 stdout io.ReadCloser
96 }
97
98 func (h *execHelper) Stdin() io.Writer { return h.stdin }
99 func (h *execHelper) Stdout() io.Reader { return h.stdout }
100 func (h *execHelper) Wait() error { return h.cmd.Wait() }
101 func (h *execHelper) Kill() error { return h.cmd.Process.Kill() }
102
103 func (c *helperClient) spawn() {
104 ctx := context.Background()
105 proc, err := c.start(ctx)
106 if err != nil {
107 c.svc.warnf("skillwatch: helper start failed: %v", err)
108 c.markUnavailable()
109 return
110 }
111 c.mu.Lock()
112 c.proc = proc
113 c.mu.Unlock()
114 go c.readLoop(proc)
115 }
116
117 // readLoop dispatches helper frames until the pipe closes.
118 func (c *helperClient) readLoop(proc helperProcess) {
119 for {
120 f, err := readFrame(proc.Stdout())
121 if err != nil {
122 c.processDied()
123 return
124 }
125 switch f.Kind {
126 case wireRegistered:
127 c.resolve(f.ID, nil)
128 case wireError:
129 c.resolve(f.ID, errors.New(f.Msg))
130 case wireEvent:
131 c.svc.eventArrived(f.ID, f.RootGen, f.Op)
132 case wireReady, wirePong, wireRegister, wireCancel, wireShutdown, wirePing:
133 // Unsolicited control frames are ignored.
134 }
135 }
136 }
137
138 func (c *helperClient) resolve(id uint64, err error) {
139 c.mu.Lock()
140 ch := c.confirm[id]
141 delete(c.confirm, id)
142 c.mu.Unlock()
143 if ch != nil {
144 ch <- helperConfirm{id: id, err: err}
145 }
146 }
147
148 func (c *helperClient) send(f frame) error {
149 c.mu.Lock()
150 proc := c.proc
151 c.mu.Unlock()
152 if proc == nil {
153 return errHelperStopped
154 }
155 c.writeMu.Lock()
156 defer c.writeMu.Unlock()
157 return writeFrame(proc.Stdin(), f)
158 }
159
160 func (c *helperClient) register(id, rootGen uint64, _ string, dirs []string) error {
161 c.mu.Lock()
162 if c.unusable || c.closed {
163 c.mu.Unlock()
164 return errHelperStopped
165 }
166 ch := make(chan helperConfirm, 1)
167 c.confirm[id] = ch
168 c.mu.Unlock()
169
170 err := c.send(frame{Kind: wireRegister, ID: id, RootGen: rootGen, Dirs: dirs})
171 if err != nil {
172 c.processDied()
173 return err
174 }
175 timer := time.NewTimer(helperControlTimeout)
176 defer timer.Stop()
177 select {
178 case confirm := <-ch:
179 if confirm.err == nil {
180 // Record only confirmed registrations: physicalWatches must not
181 // report watches before the helper has armed them.
182 c.mu.Lock()
183 c.active[id] = activeRegistration{rootGen: rootGen, dirs: dirs}
184 c.mu.Unlock()
185 }
186 return confirm.err
187 case <-timer.C:
188 go c.restart()
189 return fmt.Errorf("helper registration timed out after %s", helperControlTimeout)
190 }
191 }
192
193 func (c *helperClient) cancel(id uint64) {
194 // The logical subscription already died host-side; this only reclaims the
195 // helper's descriptors and must never wait on the pipe.
196 c.mu.Lock()
197 delete(c.active, id)
198 proc := c.proc
199 c.mu.Unlock()
200 if proc == nil {
201 return
202 }
203 c.writeMu.Lock()
204 _ = writeFrame(proc.Stdin(), frame{Kind: wireCancel, ID: id})
205 c.writeMu.Unlock()
206 }
207
208 // restart rebuilds the helper within the restart budget and replays the active
209 // registrations so existing roots resume native watching without service churn.
210 // Concurrent triggers (read-loop EOF and a control timeout racing) collapse
211 // into one cycle via the restarting flag.
212 func (c *helperClient) restart() {
213 c.mu.Lock()
214 if c.closed || c.unusable || c.restarting {
215 c.mu.Unlock()
216 return
217 }
218 now := time.Now()
219 kept := c.restarts[:0]
220 for _, ts := range c.restarts {
221 if now.Sub(ts) <= helperRestartWindow {
222 kept = append(kept, ts)
223 }
224 }
225 c.restarts = kept
226 if len(c.restarts) >= helperRestartLimit {
227 c.mu.Unlock()
228 c.markUnavailable()
229 return
230 }
231 c.restarts = append(c.restarts, now)
232 c.restarting = true
233 if c.proc != nil {
234 _ = c.proc.Kill()
235 c.proc = nil
236 }
237 c.mu.Unlock()
238 c.svc.helperRestarted()
239
240 c.spawn()
241 c.replay()
242
243 c.mu.Lock()
244 c.restarting = false
245 // A death that arrived mid-restart collapsed into this cycle; if the
246 // process is still gone (budget unspent), run one more cycle.
247 needRestart := c.proc == nil && !c.unusable && !c.closed
248 c.mu.Unlock()
249 if needRestart {
250 go c.restart()
251 }
252 }
253
254 // replay re-registers every active root on the fresh helper. Failures leave
255 // the registration in place; the service-side control timeout and event fence
256 // treat the root as failed on its next interaction.
257 func (c *helperClient) replay() {
258 c.mu.Lock()
259 regs := make(map[uint64]activeRegistration, len(c.active))
260 maps.Copy(regs, c.active)
261 unusable := c.unusable
262 c.mu.Unlock()
263 if unusable {
264 c.svc.helperDied()
265 return
266 }
267 deadline := time.Now().Add(helperControlTimeout)
268 for id, reg := range regs {
269 if time.Now().After(deadline) {
270 c.svc.helperDied()
271 return
272 }
273 if err := c.register(id, reg.rootGen, "", reg.dirs); err != nil {
274 c.svc.helperDied()
275 return
276 }
277 }
278 }
279
280 func (c *helperClient) processDied() {
281 c.mu.Lock()
282 c.proc = nil
283 pending := make([]chan helperConfirm, 0, len(c.confirm))
284 for _, ch := range c.confirm {
285 pending = append(pending, ch)
286 }
287 c.confirm = map[uint64]chan helperConfirm{}
288 c.mu.Unlock()
289 for _, ch := range pending {
290 ch <- helperConfirm{err: errHelperStopped}
291 }
292 c.restart()
293 }
294
295 func (c *helperClient) markUnavailable() {
296 c.mu.Lock()
297 if c.unusable {
298 c.mu.Unlock()
299 return
300 }
301 c.unusable = true
302 c.mu.Unlock()
303 c.svc.helperDied()
304 }
305
306 // physicalWatches reports the directories covered by live registrations. The
307 // helper owns the authoritative count; this is the host-side view used for
308 // diagnostics.
309 func (c *helperClient) physicalWatches() uint64 {
310 c.mu.Lock()
311 defer c.mu.Unlock()
312 var n uint64
313 for _, reg := range c.active {
314 n += uint64(len(reg.dirs))
315 }
316 return n
317 }
318
319 var _ backend = (*helperClient)(nil)
320
321 func (c *helperClient) close() error {
322 c.mu.Lock()
323 if c.closed {
324 c.mu.Unlock()
325 return nil
326 }
327 c.closed = true
328 proc := c.proc
329 c.proc = nil
330 c.mu.Unlock()
331 if proc != nil {
332 c.writeMu.Lock()
333 _ = writeFrame(proc.Stdin(), frame{Kind: wireShutdown})
334 c.writeMu.Unlock()
335 // Grace period for a clean exit, then reclaim. Application exit must
336 // never hang on the helper.
337 done := make(chan struct{})
338 go func() { _ = proc.Wait(); close(done) }()
339 select {
340 case <-done:
341 case <-time.After(time.Second):
342 _ = proc.Kill()
343 <-done
344 }
345 }
346 return nil
347 }
348
348 lines GO