返回 DeepSeek-Reasonix
shared_host.go
根目录 / desktop / shared_host.go
1 package main
2
3 import (
4 "context"
5 "log/slog"
6 "os"
7 "strconv"
8 "strings"
9
10 "reasonix/internal/config"
11 "reasonix/internal/plugin"
12 "reasonix/internal/proc"
13 )
14
15 // bumpExtensionGeneration records that plugin/MCP configuration changed while
16 // controller builds may still be running off the lifecycle lock. In-flight
17 // builds that finish with a stale generation must not publish.
18 func (a *App) bumpExtensionGeneration() {
19 if a == nil {
20 return
21 }
22 a.extensionGeneration.Add(1)
23 a.invalidateAuxiliaryProviderOperations()
24 }
25
26 func (a *App) currentExtensionGeneration() uint64 {
27 if a == nil {
28 return 0
29 }
30 return a.extensionGeneration.Load()
31 }
32
33 // lockMCPMutation serializes shared-Host boot with live MCP mutations without
34 // holding runtimeAdmissionMu while an optimistic controller build finishes its
35 // extension startup. A final generation bump invalidates builds that loaded
36 // configuration while the mutation held the gate.
37 func (a *App) lockMCPMutation(operation string) func() {
38 if hook := a.runtimeMutationBeforeLockHook; hook != nil {
39 hook(operation)
40 }
41 a.runtimeRebuildMu.Lock()
42 a.extensionBuildMu.Lock()
43 a.runtimeAdmissionMu.Lock()
44 return func() {
45 a.bumpExtensionGeneration()
46 a.runtimeAdmissionMu.Unlock()
47 a.extensionBuildMu.Unlock()
48 a.runtimeRebuildMu.Unlock()
49 }
50 }
51
52 type sharedHostMCPRegistration struct {
53 scope *plugin.RegistrationScope
54 finished bool
55 committed bool
56 }
57
58 // beginSharedHostMCPRegistration attributes only context-scoped connections to
59 // this build. Unrelated Host writes never become rollback candidates.
60 func beginSharedHostMCPRegistration(ctx context.Context, host *plugin.Host) (context.Context, *sharedHostMCPRegistration) {
61 registration := &sharedHostMCPRegistration{}
62 if host == nil {
63 return ctx, registration
64 }
65 registration.scope = host.BeginRegistrationScope()
66 return plugin.ContextWithRegistrationScope(ctx, registration.scope), registration
67 }
68
69 func (r *sharedHostMCPRegistration) rollback() {
70 if r == nil || r.finished {
71 return
72 }
73 r.finished = true
74 if r.scope != nil {
75 r.scope.AbortAndRollback()
76 }
77 }
78
79 func (r *sharedHostMCPRegistration) commit() bool {
80 if r == nil {
81 return true
82 }
83 if r.finished {
84 return r.committed
85 }
86 if r.scope != nil && !r.scope.Commit() {
87 r.finished = true
88 return false
89 }
90 r.finished = true
91 r.committed = true
92 return true
93 }
94
95 func (a *App) saveDesktopMCPServerAndBump(root string, entry config.PluginEntry) error {
96 if err := a.saveDesktopMCPServer(root, entry); err != nil {
97 return err
98 }
99 a.bumpExtensionGeneration()
100 return nil
101 }
102
103 // sharedPluginHost is a reference-counted plugin.Host shared across tabs
104 // that share the same workspace root. Multiple controllers (one per tab)
105 // use the same Host so MCP subprocesses (CodeGraph, etc.) are spawned once.
106 type sharedPluginHost struct {
107 host *plugin.Host
108 refs int
109 }
110
111 // acquireSharedHost returns a shared *plugin.Host for the given workspace root.
112 // The first call creates the host; subsequent calls increment a refcount and
113 // return the same host. The caller must call releaseSharedHost when the tab
114 // no longer needs the host.
115 func (a *App) acquireSharedHost(root string) *plugin.Host {
116 a.sharedHostsMu.Lock()
117 defer a.sharedHostsMu.Unlock()
118
119 if a.sharedHosts == nil {
120 a.sharedHosts = make(map[string]*sharedPluginHost)
121 }
122
123 entry, ok := a.sharedHosts[root]
124 if ok {
125 entry.refs++
126 slog.Debug("shared host acquired (reused)", "root", root, "refs", entry.refs)
127 return entry.host
128 }
129
130 // Full Apps surface; a failed sandbox listener degrades here to
131 // interactive-v1 rather than downgrading a negotiated session.
132 host := plugin.NewHostWithProfile(plugin.HostProfileDesktopApps)
133 if !a.mcpAppsSandboxAvailable() {
134 host = plugin.NewHostWithProfile(plugin.HostProfileInteractive)
135 }
136 a.sharedHosts[root] = &sharedPluginHost{host: host, refs: 1}
137 slog.Debug("shared host acquired (new)", "root", root)
138 return host
139 }
140
141 // lookupSharedHost returns an existing shared host for the given root, or nil.
142 // Unlike acquireSharedHost, it does NOT increment the refcount — use this when
143 // rebuilding a controller for an existing tab that already holds a reference.
144 func (a *App) lookupSharedHost(root string) *plugin.Host {
145 a.sharedHostsMu.Lock()
146 defer a.sharedHostsMu.Unlock()
147 if a.sharedHosts == nil {
148 return nil
149 }
150 entry, ok := a.sharedHosts[root]
151 if !ok {
152 return nil
153 }
154 return entry.host
155 }
156
157 // reapOrphanCodeGraph kills any codegraph MCP subprocess that is not a
158 // direct child of the current Reasonix process. This cleans up orphaned
159 // processes from a previous crash or from older versions that leaked them,
160 // preventing accumulation across restarts.
161 func (a *App) reapOrphanCodeGraph() {
162 myPID := os.Getpid()
163
164 // Collect the PIDs of our direct children (the ones we own).
165 // pgrep -P exits non-zero when there are no children; treat that as an
166 // empty set and continue scanning for orphans rather than skipping the
167 // entire reaping step.
168 ours := map[int]bool{}
169 out, err := proc.Command("pgrep", "-P", strconv.Itoa(myPID)).Output()
170 if err == nil {
171 for f := range strings.FieldsSeq(string(out)) {
172 if pid, err := strconv.Atoi(f); err == nil {
173 ours[pid] = true
174 }
175 }
176 }
177
178 // Find every codegraph MCP process.
179 out, err = proc.Command("pgrep", "-f", "codegraph\\.js serve --mcp").Output()
180 if err != nil {
181 return
182 }
183 for f := range strings.FieldsSeq(string(out)) {
184 pid, err := strconv.Atoi(f)
185 if err != nil || pid == myPID || ours[pid] {
186 continue
187 }
188 // Verify the process is truly orphaned before killing it:
189 // check its parent PID — if the parent is alive and isn't ours,
190 // this codegraph belongs to another active Reasonix session.
191 ppidOut, err := proc.Command("ps", "-o", "ppid=", "-p", strconv.Itoa(pid)).Output()
192 if err != nil {
193 continue
194 }
195 ppid, err := strconv.Atoi(strings.TrimSpace(string(ppidOut)))
196 if err != nil || ppid == 0 {
197 continue
198 }
199 // ppid==1 means the parent died and init reparented it — truly orphaned.
200 if ppid != 1 {
201 continue
202 }
203 if p, err := os.FindProcess(pid); err == nil {
204 _ = p.Kill()
205 slog.Debug("reaped orphan codegraph", "pid", pid)
206 }
207 }
208 }
209
210 // releaseSharedHost decrements the refcount for the workspace root and closes
211 // the shared host when no tabs reference it any more. Safe to call even when
212 // no acquire was made (no-op).
213 func (a *App) releaseSharedHost(root string) {
214 a.sharedHostsMu.Lock()
215 defer a.sharedHostsMu.Unlock()
216
217 entry, ok := a.sharedHosts[root]
218 if !ok {
219 return
220 }
221 entry.refs--
222 if entry.refs > 0 {
223 slog.Debug("shared host released (still in use)", "root", root, "refs", entry.refs)
224 return
225 }
226
227 delete(a.sharedHosts, root)
228 entry.host.Close()
229 slog.Debug("shared host closed", "root", root)
230 }
231
232 func (a *App) releaseTabSharedHost(tab *WorkspaceTab) {
233 if tab == nil {
234 return
235 }
236 // SharedHostKey is a.mu-guarded (the build goroutine publishes it under
237 // the lock); do the take under the lock and the slow host release after.
238 // Callers must not hold a.mu.
239 a.mu.Lock()
240 key := takeTabSharedHostKey(tab)
241 a.mu.Unlock()
242 if key == "" {
243 return
244 }
245 a.releaseSharedHost(key)
246 }
247
248 // takeTabSharedHostKey clears the tab's shared-host key and returns it so the
249 // caller can release it later. Use from inside a.mu critical sections:
250 // releaseSharedHost may close the host and reap MCP subprocesses, which is far
251 // too slow to run under the app lock — call a.releaseSharedHost(key) after
252 // unlocking.
253 func takeTabSharedHostKey(tab *WorkspaceTab) string {
254 if tab == nil || tab.SharedHostKey == "" {
255 return ""
256 }
257 key := tab.SharedHostKey
258 tab.SharedHostKey = ""
259 return key
260 }
261
262 // closeAllSharedHosts closes every shared host. Called during app shutdown.
263 func (a *App) closeAllSharedHosts() {
264 a.sharedHostsMu.Lock()
265 defer a.sharedHostsMu.Unlock()
266
267 for root, entry := range a.sharedHosts {
268 delete(a.sharedHosts, root)
269 entry.host.Close()
270 slog.Debug("shared host closed (shutdown)", "root", root)
271 }
272 }
273
273 lines GO