banger/internal/cli/daemon_lifecycle.go
Thales Maciel 3f6ecb4376
cli: split banger.go god file into focused files
Pure code motion — banger.go 3508→240 LOC, same-package
decomposition keeps all identifiers visible without export changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:34:32 -03:00

138 lines
3.5 KiB
Go

package cli
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
"banger/internal/api"
"banger/internal/config"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
)
// ensureDaemon pings the socket; on miss it auto-starts bangerd, on
// version mismatch it restarts. Every CLI command that needs to talk
// to the daemon routes through here.
func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
layout, err := paths.Resolve()
if err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
cfg, err := config.Load(layout)
if err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
if ping, err := daemonPingFunc(ctx, layout.SocketPath); err == nil {
if daemonOutdated(ping.PID) {
if err := restartDaemon(ctx, layout, ping.PID); err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
return layout, cfg, nil
}
return layout, cfg, nil
}
if err := startDaemon(ctx, layout); err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
return layout, cfg, nil
}
// daemonOutdated reports whether the running daemon binary differs
// from the one on disk — useful after `make install` when the user's
// session still holds a handle to an old daemon. os.SameFile compares
// inode + dev, so a fresh binary at the same path registers as
// different.
func daemonOutdated(pid int) bool {
if pid <= 0 {
return false
}
daemonBin, err := bangerdPathFunc()
if err != nil {
return false
}
currentInfo, err := os.Stat(daemonBin)
if err != nil {
return false
}
runningInfo, err := os.Stat(daemonExePath(pid))
if err != nil {
return false
}
return !os.SameFile(currentInfo, runningInfo)
}
func restartDaemon(ctx context.Context, layout paths.Layout, pid int) error {
stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{})
if waitForPIDExit(pid, 2*time.Second) {
return startDaemon(ctx, layout)
}
if proc, err := os.FindProcess(pid); err == nil {
_ = proc.Signal(syscall.SIGTERM)
}
if !waitForPIDExit(pid, 2*time.Second) {
return fmt.Errorf("timed out restarting stale daemon pid %d", pid)
}
return startDaemon(ctx, layout)
}
func waitForPIDExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !pidRunning(pid) {
return true
}
time.Sleep(50 * time.Millisecond)
}
return !pidRunning(pid)
}
func pidRunning(pid int) bool {
if pid <= 0 {
return false
}
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
func startDaemon(ctx context.Context, layout paths.Layout) error {
if err := paths.Ensure(layout); err != nil {
return err
}
logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer logFile.Close()
daemonBin, err := paths.BangerdPath()
if err != nil {
return err
}
cmd := buildDaemonCommand(daemonBin)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return err
}
if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil {
return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err)
}
return nil
}
func buildDaemonCommand(daemonBin string) *exec.Cmd {
return exec.Command(daemonBin)
}