package daemon import ( "context" "fmt" "net" "net/http" "os" "os/exec" "path/filepath" "syscall" "time" "task/internal/config" ) // Status describes whether a daemon is reachable for cfg.DataDir. type Status struct { Running bool PID int Socket string DataDir string } // StatusOf returns daemon status for the given config. func StatusOf(cfg *config.Config) (Status, error) { p, err := PathsForDataDir(cfg.DataDir) if err != nil { return Status{}, err } st := Status{Socket: p.Socket, DataDir: cfg.DataDir} pid, err := readPID(p.PIDFile) if err == nil && processAlive(pid) { st.PID = pid } if Healthy(p.Socket) { st.Running = true return st, nil } return st, nil } // Healthy reports whether the daemon HTTP health endpoint responds on socket. func Healthy(socket string) bool { cli := &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", socket) }, }, Timeout: 500 * time.Millisecond, } resp, err := cli.Get("http://daemon/v1/health") if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == http.StatusOK } // Ensure starts the daemon if needed and waits until it is healthy. func Ensure(cfg *config.Config) (Paths, error) { p, err := PathsForDataDir(cfg.DataDir) if err != nil { return Paths{}, err } if Healthy(p.Socket) { return p, nil } cleanupStale(p) if err := Spawn(cfg, p); err != nil { return Paths{}, err } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if Healthy(p.Socket) { return p, nil } time.Sleep(50 * time.Millisecond) } return Paths{}, fmt.Errorf("daemon did not become healthy at %s", p.Socket) } // Stop terminates the daemon for cfg.DataDir if running. func Stop(cfg *config.Config) error { p, err := PathsForDataDir(cfg.DataDir) if err != nil { return err } pid, err := readPID(p.PIDFile) if err != nil { if os.IsNotExist(err) { cleanupStale(p) return nil } return err } if processAlive(pid) { if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return fmt.Errorf("signal daemon: %w", err) } deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if !processAlive(pid) { break } time.Sleep(50 * time.Millisecond) } if processAlive(pid) { _ = syscall.Kill(pid, syscall.SIGKILL) } } cleanupStale(p) return nil } // Spawn starts a detached daemon process for cfg. func Spawn(cfg *config.Config, p Paths) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("executable: %w", err) } logFile, err := os.OpenFile(p.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return fmt.Errorf("open log: %w", err) } defer logFile.Close() cmd := exec.Command(exe, "daemon") cmd.Env = os.Environ() cmd.Stdout = logFile cmd.Stderr = logFile cmd.Stdin = nil cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { return fmt.Errorf("spawn daemon: %w", err) } // Detach: do not wait; child writes its own PID file when listening. _ = cmd.Process.Release() return nil } func cleanupStale(p Paths) { _ = os.Remove(p.Socket) _ = os.Remove(p.PIDFile) } // AbsoluteDataDir ensures DataDir is absolute for path hashing. func AbsoluteDataDir(dataDir string) (string, error) { if filepath.IsAbs(dataDir) { return filepath.Clean(dataDir), nil } abs, err := filepath.Abs(dataDir) if err != nil { return "", err } return abs, nil }