Skip to content

Commit a781776

Browse files
engalarclaude
andcommitted
feat(launcher): per-MPR daemon pool with 5-minute idle timeout
Replace the single shared daemon with a pool of per-MPR daemon processes: - Commands with -p <mpr>: routed to a dedicated per-MPR daemon (socket: ~/.mxcli/daemon/mpr-{mprHash}-{binHash}.sock) - Commands without -p: shared daemon at ~/.mxcli/daemon/mxcli.sock - All daemons exit after 5 minutes idle (--idle-timeout flag) Resource lifecycle: - Socket path embeds both MPR path hash and binary mtime hash; recompiling the binary changes the hash, causing stale daemons to self-invalidate. - cleanupStaleMPRSockets() removes old-binary sockets for the same MPR and dead orphan sockets from other MPRs before each daemon spawn. - runDaemonServer defer removes the socket file on exit, preventing orphans. - go cmd.Wait() in all spawn paths prevents zombie processes. - idleWatcher closes the net.Listener (triggers Accept error → clean exit). Concurrent safety: two launchers racing to start the same MPR daemon both call net.Listen; one wins (socket bound), the other fails and exits; both poll isDaemonRunning and eventually succeed — no explicit lock needed. Tests added: - TestExtractMPRFromArgs_{Short,Long,Equal,None,Dangling} - TestMPRDaemonSocketPath_{Deterministic,DifferentMPR,HasMprPrefix,InDaemonDir} - TestMPRSocketPrefix_{SameForSameMPR,DifferentForDifferentMPR} - TestCleanupStaleMPRSockets_{RemovesSameMPROldSocket,RemovesDeadOrphan,KeepsNonSocketFiles} - TestDaemonServer_IdleTimeout — daemon exits and socket file removed - TestDaemonServer_IdleResetOnRequest — idle timer resets on connection Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 29b7030 commit a781776

8 files changed

Lines changed: 524 additions & 18 deletions

File tree

cmd/mxcli-launcher/daemon.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ func readVersionFile(path string) string {
5151
return strings.TrimSpace(string(b))
5252
}
5353

54-
func (e *Env) ensureDaemon() error {
54+
// ensureDaemonBinary ensures the daemon binary is present, downloading it if needed.
55+
// It also creates the daemon directory. Call this before any daemon operations.
56+
func (e *Env) ensureDaemonBinary() error {
5557
if err := os.MkdirAll(e.daemonDir(), 0755); err != nil {
5658
return fmt.Errorf("create daemon dir: %w", err)
5759
}
@@ -61,6 +63,14 @@ func (e *Env) ensureDaemon() error {
6163
return fmt.Errorf("download daemon: %w", err)
6264
}
6365
}
66+
return nil
67+
}
68+
69+
// ensureDaemon ensures the shared (non-MPR-specific) daemon is running.
70+
func (e *Env) ensureDaemon() error {
71+
if err := e.ensureDaemonBinary(); err != nil {
72+
return err
73+
}
6474
if !isDaemonRunning(e.daemonSocketPath()) {
6575
if err := e.startDaemon(); err != nil {
6676
return fmt.Errorf("start daemon: %w", err)
@@ -70,7 +80,10 @@ func (e *Env) ensureDaemon() error {
7080
}
7181

7282
func (e *Env) startDaemon() error {
73-
cmd := exec.Command(e.daemonBinaryPath(), "--serve", e.daemonSocketPath())
83+
cmd := exec.Command(e.daemonBinaryPath(),
84+
"--serve", e.daemonSocketPath(),
85+
"--idle-timeout", sharedDaemonIdleTimeout.String(),
86+
)
7487
cmd.Stdout = nil
7588
cmd.Stderr = nil
7689
cmd.Stdin = nil
@@ -79,6 +92,7 @@ func (e *Env) startDaemon() error {
7992
return fmt.Errorf("exec daemon: %w", err)
8093
}
8194
os.WriteFile(e.daemonPIDPath(), []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644)
95+
go func() { _ = cmd.Wait() }() // prevent zombie
8296
deadline := time.Now().Add(daemonTimeout)
8397
for time.Now().Before(deadline) {
8498
if isDaemonRunning(e.daemonSocketPath()) {

cmd/mxcli-launcher/main.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
// mxcli is the launcher — a thin cross-platform client that forwards CLI
44
// requests to mxcli-daemon via unix socket. It handles daemon lifecycle,
55
// background version checks, and upgrade/rollback.
6+
//
7+
// Routing:
8+
// - Commands with -p <mpr>: forwarded to a per-MPR daemon (isolated process,
9+
// 5-minute idle timeout, socket path derived from mpr path + binary mtime hash).
10+
// - Commands without -p: forwarded to the shared daemon at ~/.mxcli/daemon/mxcli.sock.
611
package main
712

813
import (
914
"fmt"
1015
"os"
16+
"path/filepath"
1117
)
1218

1319
var (
@@ -31,14 +37,40 @@ func main() {
3137
}
3238
}
3339

34-
if err := e.ensureDaemon(); err != nil {
40+
// Ensure daemon binary is present (download if needed).
41+
if err := e.ensureDaemonBinary(); err != nil {
3542
fmt.Fprintf(os.Stderr, "mxcli: %v\n", err)
3643
os.Exit(1)
3744
}
3845

46+
// Route to per-MPR daemon when -p is present; otherwise use shared daemon.
47+
var sockPath string
48+
if rawMPR := extractMPRFromArgs(args); rawMPR != "" {
49+
absMPR, err := filepath.Abs(rawMPR)
50+
if err != nil {
51+
fmt.Fprintf(os.Stderr, "mxcli: resolve -p path: %v\n", err)
52+
os.Exit(1)
53+
}
54+
absMPR = filepath.ToSlash(absMPR)
55+
sp, err := e.ensureMPRDaemon(absMPR)
56+
if err != nil {
57+
fmt.Fprintf(os.Stderr, "mxcli: %v\n", err)
58+
os.Exit(1)
59+
}
60+
sockPath = sp
61+
} else {
62+
if !isDaemonRunning(e.daemonSocketPath()) {
63+
if err := e.startDaemon(); err != nil {
64+
fmt.Fprintf(os.Stderr, "mxcli: %v\n", err)
65+
os.Exit(1)
66+
}
67+
}
68+
sockPath = e.daemonSocketPath()
69+
}
70+
3971
go e.backgroundVersionCheck()
4072

41-
exitCode := forwardRequest(e.daemonSocketPath(), args, os.Stdout, os.Stderr)
73+
exitCode := forwardRequest(sockPath, args, os.Stdout, os.Stderr)
4274

4375
e.printUpdateNotice()
4476

cmd/mxcli-launcher/mpr_daemon.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
)
13+
14+
const (
15+
mprDaemonIdleTimeout = 5 * time.Minute
16+
sharedDaemonIdleTimeout = 5 * time.Minute
17+
mprDaemonStartTimeout = 30 * time.Second
18+
)
19+
20+
// extractMPRFromArgs scans argv for -p <path> or --project <path> or --project=<path>.
21+
func extractMPRFromArgs(args []string) string {
22+
for i, a := range args {
23+
if (a == "-p" || a == "--project") && i+1 < len(args) {
24+
return args[i+1]
25+
}
26+
if strings.HasPrefix(a, "--project=") {
27+
return a[len("--project="):]
28+
}
29+
}
30+
return ""
31+
}
32+
33+
// ensureMPRDaemon ensures a per-MPR daemon is running for mprAbsPath and returns its socket path.
34+
//
35+
// Lifecycle:
36+
// - Socket path is derived from (mprPath hash, binary mtime hash): changes on recompile → stale daemons self-invalidate.
37+
// - Stale sockets for the same MPR (old binary) are deleted; their idle watchers trigger exit.
38+
// - Daemon is spawned with --idle-timeout 5m; exits and removes socket after 5 min idle.
39+
// - go cmd.Wait() prevents zombie processes.
40+
func (e *Env) ensureMPRDaemon(mprAbsPath string) (string, error) {
41+
sockPath := e.mprDaemonSocketPath(mprAbsPath)
42+
43+
if isDaemonRunning(sockPath) {
44+
return sockPath, nil
45+
}
46+
47+
// Clean stale sockets before spawning to avoid bind conflicts.
48+
e.cleanupStaleMPRSockets(mprAbsPath, sockPath)
49+
50+
cmd := exec.Command(
51+
e.daemonBinaryPath(),
52+
"--serve", sockPath,
53+
"--idle-timeout", mprDaemonIdleTimeout.String(),
54+
)
55+
cmd.Stdout = nil
56+
cmd.Stderr = nil
57+
cmd.Stdin = nil
58+
hideDaemonWindow(cmd)
59+
if err := cmd.Start(); err != nil {
60+
return "", fmt.Errorf("start mpr daemon: %w", err)
61+
}
62+
go func() { _ = cmd.Wait() }() // prevent zombie
63+
64+
deadline := time.Now().Add(mprDaemonStartTimeout)
65+
for time.Now().Before(deadline) {
66+
if isDaemonRunning(sockPath) {
67+
return sockPath, nil
68+
}
69+
time.Sleep(50 * time.Millisecond)
70+
}
71+
_ = cmd.Process.Kill()
72+
return "", fmt.Errorf("mpr daemon did not start within %v", mprDaemonStartTimeout)
73+
}
74+
75+
// cleanupStaleMPRSockets removes:
76+
// 1. Same-MPR old-binary sockets (mpr hash prefix matches, but binary hash differs) — deleted
77+
// unconditionally; removing the socket stops the old daemon from accepting new connections,
78+
// and its idle watcher triggers exit shortly after.
79+
// 2. Dead sockets from any other MPR (orphans where no process is listening).
80+
//
81+
// currentSock is always preserved.
82+
func (e *Env) cleanupStaleMPRSockets(mprAbsPath, currentSock string) {
83+
entries, err := os.ReadDir(e.daemonDir())
84+
if err != nil {
85+
return
86+
}
87+
prefix := e.mprSocketPrefix(mprAbsPath)
88+
currentBase := filepath.Base(currentSock)
89+
90+
for _, entry := range entries {
91+
name := entry.Name()
92+
if !strings.HasSuffix(name, ".sock") || !strings.HasPrefix(name, "mpr-") {
93+
continue
94+
}
95+
if name == currentBase {
96+
continue
97+
}
98+
sockPath := filepath.Join(e.daemonDir(), name)
99+
if strings.HasPrefix(name, prefix) {
100+
// Same MPR, old binary: delete to stop it from accepting new connections.
101+
_ = os.Remove(sockPath)
102+
} else if !isDaemonRunning(sockPath) {
103+
// Another MPR's dead socket: clean up the orphan file.
104+
_ = os.Remove(sockPath)
105+
}
106+
}
107+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package main
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// --- extractMPRFromArgs ---
13+
14+
func TestExtractMPRFromArgs_ShortFlag(t *testing.T) {
15+
got := extractMPRFromArgs([]string{"-p", "app.mpr", "-c", "show entities"})
16+
if got != "app.mpr" {
17+
t.Errorf("got %q, want app.mpr", got)
18+
}
19+
}
20+
21+
func TestExtractMPRFromArgs_LongFlag(t *testing.T) {
22+
got := extractMPRFromArgs([]string{"--project", "app.mpr"})
23+
if got != "app.mpr" {
24+
t.Errorf("got %q, want app.mpr", got)
25+
}
26+
}
27+
28+
func TestExtractMPRFromArgs_EqualForm(t *testing.T) {
29+
got := extractMPRFromArgs([]string{"--project=app.mpr", "-c", "show"})
30+
if got != "app.mpr" {
31+
t.Errorf("got %q, want app.mpr", got)
32+
}
33+
}
34+
35+
func TestExtractMPRFromArgs_None(t *testing.T) {
36+
got := extractMPRFromArgs([]string{"--help"})
37+
if got != "" {
38+
t.Errorf("got %q, want empty", got)
39+
}
40+
}
41+
42+
func TestExtractMPRFromArgs_DanglingFlag(t *testing.T) {
43+
// -p at end of slice (no value follows)
44+
got := extractMPRFromArgs([]string{"-p"})
45+
if got != "" {
46+
t.Errorf("got %q, want empty", got)
47+
}
48+
}
49+
50+
// --- mprDaemonSocketPath ---
51+
52+
func TestMPRDaemonSocketPath_Deterministic(t *testing.T) {
53+
e := newTestEnv(t)
54+
a := e.mprDaemonSocketPath("/home/user/project.mpr")
55+
b := e.mprDaemonSocketPath("/home/user/project.mpr")
56+
if a != b {
57+
t.Errorf("same input produced different paths: %q vs %q", a, b)
58+
}
59+
}
60+
61+
func TestMPRDaemonSocketPath_DifferentMPR(t *testing.T) {
62+
e := newTestEnv(t)
63+
a := e.mprDaemonSocketPath("/home/user/project-a.mpr")
64+
b := e.mprDaemonSocketPath("/home/user/project-b.mpr")
65+
if a == b {
66+
t.Errorf("different MPR paths produced same socket: %q", a)
67+
}
68+
}
69+
70+
func TestMPRDaemonSocketPath_HasMprPrefix(t *testing.T) {
71+
e := newTestEnv(t)
72+
p := e.mprDaemonSocketPath("/some/project.mpr")
73+
base := filepath.Base(p)
74+
if !strings.HasPrefix(base, "mpr-") {
75+
t.Errorf("socket name %q does not start with mpr-", base)
76+
}
77+
if !strings.HasSuffix(base, ".sock") {
78+
t.Errorf("socket name %q does not end with .sock", base)
79+
}
80+
}
81+
82+
func TestMPRDaemonSocketPath_InDaemonDir(t *testing.T) {
83+
e := newTestEnv(t)
84+
p := e.mprDaemonSocketPath("/some/project.mpr")
85+
if !strings.HasPrefix(p, e.daemonDir()) {
86+
t.Errorf("socket path %q is not under daemonDir %q", p, e.daemonDir())
87+
}
88+
}
89+
90+
// --- mprSocketPrefix ---
91+
92+
func TestMPRSocketPrefix_SameForSameMPR(t *testing.T) {
93+
e := newTestEnv(t)
94+
a := e.mprSocketPrefix("/home/user/project.mpr")
95+
b := e.mprSocketPrefix("/home/user/project.mpr")
96+
if a != b {
97+
t.Errorf("same MPR produced different prefix: %q vs %q", a, b)
98+
}
99+
}
100+
101+
func TestMPRSocketPrefix_DifferentForDifferentMPR(t *testing.T) {
102+
e := newTestEnv(t)
103+
a := e.mprSocketPrefix("/home/user/a.mpr")
104+
b := e.mprSocketPrefix("/home/user/b.mpr")
105+
if a == b {
106+
t.Errorf("different MPR produced same prefix: %q", a)
107+
}
108+
}
109+
110+
// --- cleanupStaleMPRSockets ---
111+
112+
func TestCleanupStaleMPRSockets_RemovesSameMPROldSocket(t *testing.T) {
113+
e := newTestEnv(t)
114+
if err := os.MkdirAll(e.daemonDir(), 0755); err != nil {
115+
t.Fatal(err)
116+
}
117+
mprPath := "/home/user/project.mpr"
118+
119+
// Create a fake stale socket with the same MPR prefix but different binary hash suffix.
120+
prefix := e.mprSocketPrefix(mprPath)
121+
stale := filepath.Join(e.daemonDir(), prefix+"aabbcc.sock")
122+
if err := os.WriteFile(stale, []byte{}, 0644); err != nil {
123+
t.Fatal(err)
124+
}
125+
126+
// current socket should NOT be deleted.
127+
current := e.mprDaemonSocketPath(mprPath)
128+
if err := os.WriteFile(current, []byte{}, 0644); err != nil {
129+
t.Fatal(err)
130+
}
131+
132+
e.cleanupStaleMPRSockets(mprPath, current)
133+
134+
if _, err := os.Stat(stale); !os.IsNotExist(err) {
135+
t.Error("stale same-MPR socket should have been removed")
136+
}
137+
if _, err := os.Stat(current); os.IsNotExist(err) {
138+
t.Error("current socket should NOT have been removed")
139+
}
140+
}
141+
142+
func TestCleanupStaleMPRSockets_RemovesDeadOrphanSocket(t *testing.T) {
143+
e := newTestEnv(t)
144+
if err := os.MkdirAll(e.daemonDir(), 0755); err != nil {
145+
t.Fatal(err)
146+
}
147+
148+
// Orphan socket from a different MPR (different prefix) that is NOT alive.
149+
orphan := filepath.Join(e.daemonDir(), "mpr-deadbeef-0a0b.sock")
150+
if err := os.WriteFile(orphan, []byte{}, 0644); err != nil {
151+
t.Fatal(err)
152+
}
153+
154+
current := e.mprDaemonSocketPath("/home/user/project.mpr")
155+
e.cleanupStaleMPRSockets("/home/user/project.mpr", current)
156+
157+
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
158+
t.Error("dead orphan socket should have been removed")
159+
}
160+
}
161+
162+
func TestCleanupStaleMPRSockets_KeepsNonSocketFiles(t *testing.T) {
163+
e := newTestEnv(t)
164+
if err := os.MkdirAll(e.daemonDir(), 0755); err != nil {
165+
t.Fatal(err)
166+
}
167+
168+
// Non-.sock file in daemon dir should be untouched.
169+
other := filepath.Join(e.daemonDir(), "version")
170+
if err := os.WriteFile(other, []byte("v1"), 0644); err != nil {
171+
t.Fatal(err)
172+
}
173+
174+
current := e.mprDaemonSocketPath("/some/project.mpr")
175+
e.cleanupStaleMPRSockets("/some/project.mpr", current)
176+
177+
if _, err := os.Stat(other); os.IsNotExist(err) {
178+
t.Error("non-socket file should not have been removed")
179+
}
180+
}

0 commit comments

Comments
 (0)