返回 DeepSeek-Reasonix
shared_host.go
根目录 / desktop / shared_host.go
1 package main
2
3 import (
4 "log/slog"
5 "os"
6 "os/exec"
7 "strconv"
8 "strings"
9
10 "reasonix/internal/plugin"
11 )
12
13 // sharedPluginHost is a reference-counted plugin.Host shared across tabs
14 // that share the same workspace root. Multiple controllers (one per tab)
15 // use the same Host so MCP subprocesses (CodeGraph, etc.) are spawned once.
16 type sharedPluginHost struct {
17 host *plugin.Host
18 refs int
19 }
20
21 // acquireSharedHost returns a shared *plugin.Host for the given workspace root.
22 // The first call creates the host; subsequent calls increment a refcount and
23 // return the same host. The caller must call releaseSharedHost when the tab
24 // no longer needs the host.
25 func (a *App) acquireSharedHost(root string) *plugin.Host {
26 a.sharedHostsMu.Lock()
27 defer a.sharedHostsMu.Unlock()
28
29 if a.sharedHosts == nil {
30 a.sharedHosts = make(map[string]*sharedPluginHost)
31 }
32
33 entry, ok := a.sharedHosts[root]
34 if ok {
35 entry.refs++
36 slog.Debug("shared host acquired (reused)", "root", root, "refs", entry.refs)
37 return entry.host
38 }
39
40 host := plugin.NewHost()
41 a.sharedHosts[root] = &sharedPluginHost{host: host, refs: 1}
42 slog.Debug("shared host acquired (new)", "root", root)
43 return host
44 }
45
46 // lookupSharedHost returns an existing shared host for the given root, or nil.
47 // Unlike acquireSharedHost, it does NOT increment the refcount — use this when
48 // rebuilding a controller for an existing tab that already holds a reference.
49 func (a *App) lookupSharedHost(root string) *plugin.Host {
50 a.sharedHostsMu.Lock()
51 defer a.sharedHostsMu.Unlock()
52 if a.sharedHosts == nil {
53 return nil
54 }
55 entry, ok := a.sharedHosts[root]
56 if !ok {
57 return nil
58 }
59 return entry.host
60 }
61
62 // reapOrphanCodeGraph kills any codegraph MCP subprocess that is not a
63 // direct child of the current Reasonix process. This cleans up orphaned
64 // processes from a previous crash or from older versions that leaked them,
65 // preventing accumulation across restarts.
66 func (a *App) reapOrphanCodeGraph() {
67 myPID := os.Getpid()
68
69 // Collect the PIDs of our direct children (the ones we own).
70 // pgrep -P exits non-zero when there are no children; treat that as an
71 // empty set and continue scanning for orphans rather than skipping the
72 // entire reaping step.
73 ours := map[int]bool{}
74 out, err := exec.Command("pgrep", "-P", strconv.Itoa(myPID)).Output()
75 if err == nil {
76 for _, f := range strings.Fields(string(out)) {
77 if pid, err := strconv.Atoi(f); err == nil {
78 ours[pid] = true
79 }
80 }
81 }
82
83 // Find every codegraph MCP process.
84 out, err = exec.Command("pgrep", "-f", "codegraph\\.js serve --mcp").Output()
85 if err != nil {
86 return
87 }
88 for _, f := range strings.Fields(string(out)) {
89 pid, err := strconv.Atoi(f)
90 if err != nil || pid == myPID || ours[pid] {
91 continue
92 }
93 // Verify the process is truly orphaned before killing it:
94 // check its parent PID — if the parent is alive and isn't ours,
95 // this codegraph belongs to another active Reasonix session.
96 ppidOut, err := exec.Command("ps", "-o", "ppid=", "-p", strconv.Itoa(pid)).Output()
97 if err != nil {
98 continue
99 }
100 ppid, err := strconv.Atoi(strings.TrimSpace(string(ppidOut)))
101 if err != nil || ppid == 0 {
102 continue
103 }
104 // ppid==1 means the parent died and init reparented it — truly orphaned.
105 if ppid != 1 {
106 continue
107 }
108 if p, err := os.FindProcess(pid); err == nil {
109 _ = p.Kill()
110 slog.Debug("reaped orphan codegraph", "pid", pid)
111 }
112 }
113 }
114
115 // releaseSharedHost decrements the refcount for the workspace root and closes
116 // the shared host when no tabs reference it any more. Safe to call even when
117 // no acquire was made (no-op).
118 func (a *App) releaseSharedHost(root string) {
119 a.sharedHostsMu.Lock()
120 defer a.sharedHostsMu.Unlock()
121
122 entry, ok := a.sharedHosts[root]
123 if !ok {
124 return
125 }
126 entry.refs--
127 if entry.refs > 0 {
128 slog.Debug("shared host released (still in use)", "root", root, "refs", entry.refs)
129 return
130 }
131
132 delete(a.sharedHosts, root)
133 entry.host.Close()
134 slog.Debug("shared host closed", "root", root)
135 }
136
137 func (a *App) releaseTabSharedHost(tab *WorkspaceTab) {
138 if tab == nil {
139 return
140 }
141 // SharedHostKey is a.mu-guarded (the build goroutine publishes it under
142 // the lock); do the take under the lock and the slow host release after.
143 // Callers must not hold a.mu.
144 a.mu.Lock()
145 key := takeTabSharedHostKey(tab)
146 a.mu.Unlock()
147 if key == "" {
148 return
149 }
150 a.releaseSharedHost(key)
151 }
152
153 // takeTabSharedHostKey clears the tab's shared-host key and returns it so the
154 // caller can release it later. Use from inside a.mu critical sections:
155 // releaseSharedHost may close the host and reap MCP subprocesses, which is far
156 // too slow to run under the app lock — call a.releaseSharedHost(key) after
157 // unlocking.
158 func takeTabSharedHostKey(tab *WorkspaceTab) string {
159 if tab == nil || tab.SharedHostKey == "" {
160 return ""
161 }
162 key := tab.SharedHostKey
163 tab.SharedHostKey = ""
164 return key
165 }
166
167 // closeAllSharedHosts closes every shared host. Called during app shutdown.
168 func (a *App) closeAllSharedHosts() {
169 a.sharedHostsMu.Lock()
170 defer a.sharedHostsMu.Unlock()
171
172 for root, entry := range a.sharedHosts {
173 delete(a.sharedHosts, root)
174 entry.host.Close()
175 slog.Debug("shared host closed (shutdown)", "root", root)
176 }
177 }
178
178 lines GO