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 (d *deps) 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 := d.daemonPing(ctx, layout.SocketPath); err == nil { if d.daemonOutdated(ping.PID) { if err := d.restartDaemon(ctx, layout, ping.PID); err != nil { return paths.Layout{}, model.DaemonConfig{}, err } return layout, cfg, nil } return layout, cfg, nil } if err := d.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 (d *deps) daemonOutdated(pid int) bool { if pid <= 0 { return false } daemonBin, err := d.bangerdPath() if err != nil { return false } currentInfo, err := os.Stat(daemonBin) if err != nil { return false } runningInfo, err := os.Stat(d.daemonExePath(pid)) if err != nil { return false } return !os.SameFile(currentInfo, runningInfo) } func (d *deps) 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 d.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 d.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 (d *deps) 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) }