178 lines
5.1 KiB
Go
178 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"reasonix/internal/plugin"
|
|
)
|
|
|
|
// sharedPluginHost is a reference-counted plugin.Host shared across tabs
|
|
// that share the same workspace root. Multiple controllers (one per tab)
|
|
// use the same Host so MCP subprocesses (CodeGraph, etc.) are spawned once.
|
|
type sharedPluginHost struct {
|
|
host *plugin.Host
|
|
refs int
|
|
}
|
|
|
|
// acquireSharedHost returns a shared *plugin.Host for the given workspace root.
|
|
// The first call creates the host; subsequent calls increment a refcount and
|
|
// return the same host. The caller must call releaseSharedHost when the tab
|
|
// no longer needs the host.
|
|
func (a *App) acquireSharedHost(root string) *plugin.Host {
|
|
a.sharedHostsMu.Lock()
|
|
defer a.sharedHostsMu.Unlock()
|
|
|
|
if a.sharedHosts == nil {
|
|
a.sharedHosts = make(map[string]*sharedPluginHost)
|
|
}
|
|
|
|
entry, ok := a.sharedHosts[root]
|
|
if ok {
|
|
entry.refs++
|
|
slog.Debug("shared host acquired (reused)", "root", root, "refs", entry.refs)
|
|
return entry.host
|
|
}
|
|
|
|
host := plugin.NewHost()
|
|
a.sharedHosts[root] = &sharedPluginHost{host: host, refs: 1}
|
|
slog.Debug("shared host acquired (new)", "root", root)
|
|
return host
|
|
}
|
|
|
|
// lookupSharedHost returns an existing shared host for the given root, or nil.
|
|
// Unlike acquireSharedHost, it does NOT increment the refcount — use this when
|
|
// rebuilding a controller for an existing tab that already holds a reference.
|
|
func (a *App) lookupSharedHost(root string) *plugin.Host {
|
|
a.sharedHostsMu.Lock()
|
|
defer a.sharedHostsMu.Unlock()
|
|
if a.sharedHosts == nil {
|
|
return nil
|
|
}
|
|
entry, ok := a.sharedHosts[root]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return entry.host
|
|
}
|
|
|
|
// reapOrphanCodeGraph kills any codegraph MCP subprocess that is not a
|
|
// direct child of the current Reasonix process. This cleans up orphaned
|
|
// processes from a previous crash or from older versions that leaked them,
|
|
// preventing accumulation across restarts.
|
|
func (a *App) reapOrphanCodeGraph() {
|
|
myPID := os.Getpid()
|
|
|
|
// Collect the PIDs of our direct children (the ones we own).
|
|
// pgrep -P exits non-zero when there are no children; treat that as an
|
|
// empty set and continue scanning for orphans rather than skipping the
|
|
// entire reaping step.
|
|
ours := map[int]bool{}
|
|
out, err := exec.Command("pgrep", "-P", strconv.Itoa(myPID)).Output()
|
|
if err == nil {
|
|
for _, f := range strings.Fields(string(out)) {
|
|
if pid, err := strconv.Atoi(f); err == nil {
|
|
ours[pid] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find every codegraph MCP process.
|
|
out, err = exec.Command("pgrep", "-f", "codegraph\\.js serve --mcp").Output()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, f := range strings.Fields(string(out)) {
|
|
pid, err := strconv.Atoi(f)
|
|
if err != nil || pid == myPID || ours[pid] {
|
|
continue
|
|
}
|
|
// Verify the process is truly orphaned before killing it:
|
|
// check its parent PID — if the parent is alive and isn't ours,
|
|
// this codegraph belongs to another active Reasonix session.
|
|
ppidOut, err := exec.Command("ps", "-o", "ppid=", "-p", strconv.Itoa(pid)).Output()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ppid, err := strconv.Atoi(strings.TrimSpace(string(ppidOut)))
|
|
if err != nil || ppid == 0 {
|
|
continue
|
|
}
|
|
// ppid==1 means the parent died and init reparented it — truly orphaned.
|
|
if ppid != 1 {
|
|
continue
|
|
}
|
|
if p, err := os.FindProcess(pid); err == nil {
|
|
_ = p.Kill()
|
|
slog.Debug("reaped orphan codegraph", "pid", pid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// releaseSharedHost decrements the refcount for the workspace root and closes
|
|
// the shared host when no tabs reference it any more. Safe to call even when
|
|
// no acquire was made (no-op).
|
|
func (a *App) releaseSharedHost(root string) {
|
|
a.sharedHostsMu.Lock()
|
|
defer a.sharedHostsMu.Unlock()
|
|
|
|
entry, ok := a.sharedHosts[root]
|
|
if !ok {
|
|
return
|
|
}
|
|
entry.refs--
|
|
if entry.refs > 0 {
|
|
slog.Debug("shared host released (still in use)", "root", root, "refs", entry.refs)
|
|
return
|
|
}
|
|
|
|
delete(a.sharedHosts, root)
|
|
entry.host.Close()
|
|
slog.Debug("shared host closed", "root", root)
|
|
}
|
|
|
|
func (a *App) releaseTabSharedHost(tab *WorkspaceTab) {
|
|
if tab == nil {
|
|
return
|
|
}
|
|
// SharedHostKey is a.mu-guarded (the build goroutine publishes it under
|
|
// the lock); do the take under the lock and the slow host release after.
|
|
// Callers must not hold a.mu.
|
|
a.mu.Lock()
|
|
key := takeTabSharedHostKey(tab)
|
|
a.mu.Unlock()
|
|
if key == "" {
|
|
return
|
|
}
|
|
a.releaseSharedHost(key)
|
|
}
|
|
|
|
// takeTabSharedHostKey clears the tab's shared-host key and returns it so the
|
|
// caller can release it later. Use from inside a.mu critical sections:
|
|
// releaseSharedHost may close the host and reap MCP subprocesses, which is far
|
|
// too slow to run under the app lock — call a.releaseSharedHost(key) after
|
|
// unlocking.
|
|
func takeTabSharedHostKey(tab *WorkspaceTab) string {
|
|
if tab == nil || tab.SharedHostKey == "" {
|
|
return ""
|
|
}
|
|
key := tab.SharedHostKey
|
|
tab.SharedHostKey = ""
|
|
return key
|
|
}
|
|
|
|
// closeAllSharedHosts closes every shared host. Called during app shutdown.
|
|
func (a *App) closeAllSharedHosts() {
|
|
a.sharedHostsMu.Lock()
|
|
defer a.sharedHostsMu.Unlock()
|
|
|
|
for root, entry := range a.sharedHosts {
|
|
delete(a.sharedHosts, root)
|
|
entry.host.Close()
|
|
slog.Debug("shared host closed (shutdown)", "root", root)
|
|
}
|
|
}
|