Files
cc-connect/daemon/launchd.go
T
cg33andClaude a456ab3bd4 fix(daemon): macOS launchd do not respawn on clean exit (#153) (#304)
LaunchAgent used KeepAlive=true, so launchd restarted cc-connect after
every process exit, including graceful SIGTERM shutdown. That looked like
"stop then immediate restart" and could make tooling that waits for the
Unix API socket to disappear hang if the service respawned quickly.

Use KeepAlive with SuccessfulExit=true so only unclean exits are restarted,
matching systemd Restart=on-failure behavior.

Existing installs: re-run `cc-connect daemon install --force` to refresh
the plist.

Fixes #153

Made-with: Cursor

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 14:15:51 +08:00

201 lines
5.2 KiB
Go

//go:build darwin
package daemon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
launchdLabel = "com.cc-connect.service"
)
type launchdManager struct{}
func newPlatformManager() (Manager, error) {
return &launchdManager{}, nil
}
func (*launchdManager) Platform() string { return "launchd" }
func (m *launchdManager) Install(cfg Config) error {
plistPath := launchdPlistPath()
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
return fmt.Errorf("create LaunchAgents dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil {
return fmt.Errorf("create log dir: %w", err)
}
// Unload existing service first (ignore errors)
_ = exec.Command("launchctl", "bootout", fmt.Sprintf("gui/%d/%s", os.Getuid(), launchdLabel)).Run()
plist := buildPlist(cfg)
if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
return fmt.Errorf("write plist: %w", err)
}
domain := fmt.Sprintf("gui/%d", os.Getuid())
if out, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
return fmt.Errorf("launchctl bootstrap: %s (%w)", out, err)
}
if _, err := runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel)); err != nil {
return fmt.Errorf("launchctl kickstart: %w", err)
}
return nil
}
func (m *launchdManager) Uninstall() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
_, _ = runLaunchctl("bootout", fmt.Sprintf("%s/%s", domain, launchdLabel))
plistPath := launchdPlistPath()
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove plist: %w", err)
}
return nil
}
func (*launchdManager) Start() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
plistPath := launchdPlistPath()
var out string
if _, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
// already bootstrapped — try kickstart
out, err = runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel))
if err != nil {
return fmt.Errorf("start: %s (%w)", out, err)
}
}
return nil
}
func (*launchdManager) Stop() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
out, err := runLaunchctl("bootout", fmt.Sprintf("%s/%s", domain, launchdLabel))
if err != nil {
return fmt.Errorf("stop: %s (%w)", out, err)
}
return nil
}
func (*launchdManager) Restart() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
target := fmt.Sprintf("%s/%s", domain, launchdLabel)
_, _ = runLaunchctl("bootout", target)
plistPath := launchdPlistPath()
// launchd bootout is asynchronous; retry bootstrap with backoff
// to avoid "Bootstrap failed: 5" race condition.
var out string
var err error
for i := 0; i < 3; i++ {
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
out, err = runLaunchctl("bootstrap", domain, plistPath)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("restart: %s (%w)", out, err)
}
if _, err := runLaunchctl("kickstart", "-kp", target); err != nil {
return fmt.Errorf("restart kickstart: %w", err)
}
return nil
}
func (*launchdManager) Status() (*Status, error) {
st := &Status{Platform: "launchd"}
plistPath := launchdPlistPath()
if _, err := os.Stat(plistPath); err != nil {
return st, nil
}
st.Installed = true
domain := fmt.Sprintf("gui/%d", os.Getuid())
target := fmt.Sprintf("%s/%s", domain, launchdLabel)
out, _ := runLaunchctl("print", target)
for _, line := range strings.Split(out, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "pid = ") {
if pid, err := strconv.Atoi(strings.TrimPrefix(trimmed, "pid = ")); err == nil && pid > 0 {
st.PID = pid
st.Running = true
}
}
if strings.Contains(trimmed, "state = running") {
st.Running = true
}
}
return st, nil
}
// ── helpers ─────────────────────────────────────────────────
func launchdPlistPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist")
}
func buildPlist(cfg Config) string {
envPATH := cfg.EnvPATH
if envPATH == "" {
envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
</array>
<key>WorkingDirectory</key>
<string>%s</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<true/>
</dict>
<key>EnvironmentVariables</key>
<dict>
<key>CC_LOG_FILE</key>
<string>%s</string>
<key>CC_LOG_MAX_SIZE</key>
<string>%d</string>
<key>PATH</key>
<string>%s</string>
</dict>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/dev/null</string>
</dict>
</plist>
`, launchdLabel, cfg.BinaryPath, cfg.WorkDir, cfg.LogFile, cfg.LogMaxSize, envPATH)
}
func runLaunchctl(args ...string) (string, error) {
cmd := exec.Command("launchctl", args...)
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}