diff --git a/README.md b/README.md index 65cedd3f..89a32267 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ on your own machine. ``` antares # terminal UI -antares serve # API + dashboard on :8787 +antares serve # start API + dashboard in the background on :8787 +antares status # show the background server status +antares stop # stop the background server antares setup # configure it, in the browser or the terminal ``` @@ -118,9 +120,13 @@ Production build: ```bash make build # → bin/antares, dashboard embedded -./bin/antares serve +./bin/antares serve # starts in the background ``` +Use `antares serve --foreground` when running under systemd, Docker, or while +debugging and you want the server attached to the current terminal. Daemon logs +are written to `~/.antares/logs/daemon.log`. + ### Accessing it from another machine Both dev servers bind `0.0.0.0`, so a private-network address works directly: diff --git a/cmd/antares/daemon.go b/cmd/antares/daemon.go new file mode 100644 index 00000000..fe75828b --- /dev/null +++ b/cmd/antares/daemon.go @@ -0,0 +1,442 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/version" +) + +var ( + daemonReadyTimeout = 30 * time.Second + daemonStopTimeout = 15 * time.Second +) + +type daemonState struct { + PID int `json:"pid"` + StartTime string `json:"start_time,omitempty"` + Exe string `json:"exe"` + URL string `json:"url"` + Version string `json:"version"` + StartedAt string `json:"started_at"` + Managed bool `json:"managed,omitempty"` +} + +func daemonPIDFile() string { return config.Path("antares.pid") } +func daemonLogFile() string { return config.Path("logs", "daemon.log") } +func daemonLockDir() string { return config.Path("antares.start.lock") } + +func cmdServe(args []string) error { + foreground := false + for _, arg := range args { + switch arg { + case "--foreground", "-f": + foreground = true + case "--background", "-d", "--daemon": + // Background is the default; retain explicit aliases for scripts. + case "--help", "-h": + fmt.Println("usage: antares serve [--foreground]") + return nil + default: + return fmt.Errorf("unknown serve option %q", arg) + } + } + if foreground { + return cmdServeForeground() + } + return startDaemon() +} + +func startDaemon() error { + if runtime.GOOS != "linux" { + return errors.New("background serve is currently implemented on Linux; use antares serve --foreground") + } + if err := config.EnsureHome(); err != nil { + return err + } + release, err := acquireDaemonStartLock(5 * time.Second) + if err != nil { + return err + } + defer release() + if state, live, err := currentDaemon(); err != nil { + return err + } else if live { + fmt.Printf("Antares is already running (pid %d) at %s\n", state.PID, state.URL) + return nil + } + + cfg, err := config.Load() + if err != nil { + return err + } + url := daemonURL(cfg) + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locating antares executable: %w", err) + } + exe, _ = filepath.EvalSymlinks(exe) + logPath := daemonLogFile() + if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil { + return err + } + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("opening daemon log: %w", err) + } + if err := logFile.Chmod(0o600); err != nil { + _ = logFile.Close() + return fmt.Errorf("securing daemon log: %w", err) + } + defer logFile.Close() + + cmd := exec.Command(exe, "_serve_foreground") + cmd.Stdin = nil + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Env = os.Environ() + configureDaemonProcess(cmd) + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting antares daemon: %w", err) + } + state := daemonState{ + PID: cmd.Process.Pid, Exe: exe, URL: url, Version: version.Version, + StartedAt: time.Now().UTC().Format(time.RFC3339Nano), Managed: true, + } + state.StartTime, err = processStartTime(state.PID) + if err != nil { + _ = terminateDaemonProcess(cmd.Process, true) + return fmt.Errorf("inspecting daemon process: %w", err) + } + if err := writeDaemonState(state); err != nil { + _ = terminateDaemonProcess(cmd.Process, true) + return err + } + _ = cmd.Process.Release() + + if err := waitDaemonReady(state, daemonReadyTimeout); err != nil { + _ = stopDaemonState(state, 3*time.Second) + _ = os.Remove(daemonPIDFile()) + return fmt.Errorf("daemon failed to become ready: %w (see %s)", err, logPath) + } + fmt.Printf("Antares started in background (pid %d)\n", state.PID) + fmt.Printf("Dashboard: %s\n", state.URL) + fmt.Printf("Logs: %s\n", logPath) + fmt.Println("Stop with: antares stop") + return nil +} + +func daemonURL(cfg *config.Config) string { + host := cfg.Server.Host + switch host { + case "", "0.0.0.0": + host = "127.0.0.1" + case "::", "[::]": + host = "::1" + } + return "http://" + net.JoinHostPort(host, strconv.Itoa(cfg.Server.Port)) +} + +func acquireDaemonStartLock(timeout time.Duration) (func(), error) { + deadline := time.Now().Add(timeout) + for { + err := os.Mkdir(daemonLockDir(), 0o700) + if err == nil { + return func() { _ = os.Remove(daemonLockDir()) }, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("creating daemon start lock: %w", err) + } + if info, statErr := os.Stat(daemonLockDir()); statErr == nil && time.Since(info.ModTime()) > daemonReadyTimeout+5*time.Second { + _ = os.Remove(daemonLockDir()) // abandoned by a crashed starter + continue + } + if time.Now().After(deadline) { + return nil, errors.New("another antares serve command is still starting the daemon") + } + time.Sleep(50 * time.Millisecond) + } +} + +func cmdStop(args []string) error { + if len(args) > 0 { + if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + fmt.Println("usage: antares stop") + return nil + } + return fmt.Errorf("unknown stop option %q", args[0]) + } + state, live, err := currentDaemon() + if err != nil { + return err + } + if !live { + fmt.Println("Antares is not running") + return nil + } + fmt.Printf("Stopping Antares (pid %d)...\n", state.PID) + if err := stopDaemonState(state, daemonStopTimeout); err != nil { + return err + } + _ = os.Remove(daemonPIDFile()) + fmt.Println("Antares stopped") + return nil +} + +func cmdStatus(args []string) error { + if len(args) > 0 { + return errors.New("usage: antares status") + } + state, live, err := currentDaemon() + if err != nil { + return err + } + if !live { + fmt.Println("Antares is stopped") + return nil + } + fmt.Printf("Antares is running (pid %d)\n", state.PID) + fmt.Printf("Dashboard: %s\n", state.URL) + fmt.Printf("Version: %s\n", state.Version) + fmt.Printf("Logs: %s\n", daemonLogFile()) + return nil +} + +func currentDaemon() (daemonState, bool, error) { + state, err := readDaemonState() + if errors.Is(err, os.ErrNotExist) { + return discoverLegacyDaemon() + } + if err != nil { + return daemonState{}, false, err + } + live, err := validateDaemonState(state) + if err != nil { + return daemonState{}, false, err + } + if !live { + _ = os.Remove(daemonPIDFile()) + return discoverLegacyDaemon() + } + return state, true, nil +} + +func discoverLegacyDaemon() (daemonState, bool, error) { + if runtime.GOOS != "linux" { + return daemonState{}, false, nil + } + cfg, err := config.Load() + if err != nil { + return daemonState{}, false, err + } + url := daemonURL(cfg) + pids, err := processesListeningOnPort(cfg.Server.Port) + if err != nil { + return daemonState{}, false, err + } + for _, pid := range pids { + if pid == os.Getpid() { + continue + } + owner, err := processOwnerUID(pid) + if err != nil || owner != os.Getuid() { + continue + } + args, err := processArguments(pid) + if err != nil || !isAntaresServerArgs(args) { + continue + } + exe, err := processExecutable(pid) + if err != nil || !strings.Contains(strings.ToLower(filepath.Base(exe)), "antares") { + continue + } + if !healthResponds(url) { + continue + } + start, err := processStartTime(pid) + if err != nil { + continue + } + state := daemonState{PID: pid, StartTime: start, Exe: exe, URL: url, Version: "legacy"} + _ = writeDaemonState(state) + return state, true, nil + } + return daemonState{}, false, nil +} + +func isAntaresServerArgs(args []string) bool { + if len(args) < 2 { + return false + } + return args[1] == "_serve_foreground" || args[1] == "serve" || args[1] == "start" +} + +func healthResponds(url string) bool { + client := &http.Client{Timeout: 800 * time.Millisecond} + resp, err := client.Get(strings.TrimRight(url, "/") + "/api/health") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func writeDaemonState(state daemonState) error { + b, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + path := daemonPIDFile() + tmp := path + ".tmp." + strconv.Itoa(os.Getpid()) + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func readDaemonState() (daemonState, error) { + b, err := os.ReadFile(daemonPIDFile()) + if err != nil { + return daemonState{}, err + } + var state daemonState + if err := json.Unmarshal(b, &state); err != nil { + return daemonState{}, fmt.Errorf("invalid daemon state %s: %w", daemonPIDFile(), err) + } + if state.PID <= 1 || state.Exe == "" { + return daemonState{}, fmt.Errorf("invalid daemon state %s", daemonPIDFile()) + } + return state, nil +} + +func validateDaemonState(state daemonState) (bool, error) { + alive, err := processAlive(state.PID) + if err != nil || !alive { + return false, err + } + start, err := processStartTime(state.PID) + if err != nil { + return false, err + } + if state.StartTime != "" && start != state.StartTime { + return false, nil // PID has been reused. + } + owner, err := processOwnerUID(state.PID) + if err != nil { + return false, err + } + if owner != os.Getuid() { + return false, nil + } + exe, err := processExecutable(state.PID) + if err != nil { + return false, err + } + wantExe, _ := filepath.EvalSymlinks(state.Exe) + if exe != wantExe { + return false, nil + } + args, err := processArguments(state.PID) + if err != nil { + return false, err + } + return isAntaresServerArgs(args), nil +} + +func waitDaemonReady(state daemonState, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 800 * time.Millisecond} + cfg, err := config.Load() + if err != nil { + return err + } + for time.Now().Before(deadline) { + live, err := validateDaemonState(state) + if err != nil { + return err + } + if !live { + return errors.New("process exited during startup") + } + owners, ownerErr := processesListeningOnPort(cfg.Server.Port) + if ownerErr != nil { + return ownerErr + } + ownsListener := false + for _, pid := range owners { + if pid == state.PID { + ownsListener = true + break + } + } + if !ownsListener { + time.Sleep(100 * time.Millisecond) + continue + } + resp, err := client.Get(strings.TrimRight(state.URL, "/") + "/api/health") + if err == nil { + var health struct { + OK bool `json:"ok"` + Version string `json:"version"` + } + decodeErr := json.NewDecoder(resp.Body).Decode(&health) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK && decodeErr == nil && health.OK && health.Version == version.Version { + return nil + } + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("timed out after %s", timeout) +} + +func stopDaemonState(state daemonState, timeout time.Duration) error { + live, err := validateDaemonState(state) + if err != nil { + return err + } + if !live { + return nil + } + proc, err := os.FindProcess(state.PID) + if err != nil { + return err + } + if err := terminateDaemonProcess(proc, state.Managed); err != nil && !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("sending SIGTERM: %w", err) + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + alive, _ := daemonTargetAlive(state.PID, state.Managed) + if !alive { + return nil + } + time.Sleep(100 * time.Millisecond) + } + if err := killDaemonProcess(proc, state.Managed); err != nil && !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("daemon did not stop after %s; SIGKILL failed: %w", timeout, err) + } + for i := 0; i < 20; i++ { + alive, _ := daemonTargetAlive(state.PID, state.Managed) + if !alive { + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("daemon pid %d is still alive after SIGKILL", state.PID) +} diff --git a/cmd/antares/daemon_linux.go b/cmd/antares/daemon_linux.go new file mode 100644 index 00000000..5d6c6339 --- /dev/null +++ b/cmd/antares/daemon_linux.go @@ -0,0 +1,179 @@ +//go:build linux + +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" +) + +func configureDaemonProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} + +func processAlive(pid int) (bool, error) { + err := syscall.Kill(pid, 0) + switch { + case err == nil: + // A detached child can remain as a zombie briefly. Treat it as stopped. + if stat, readErr := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")); readErr == nil { + fields := strings.Fields(string(stat)) + if len(fields) > 2 && fields[2] == "Z" { + return false, nil + } + } + return true, nil + case errors.Is(err, syscall.ESRCH): + return false, nil + case errors.Is(err, syscall.EPERM): + return true, nil + default: + return false, err + } +} + +func processStartTime(pid int) (string, error) { + b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return "", err + } + // Field 2 (comm) may contain spaces and parentheses. Everything after the + // final ')' starts at field 3; starttime is field 22, index 19 in this tail. + end := strings.LastIndexByte(string(b), ')') + if end < 0 { + return "", errors.New("malformed /proc stat") + } + fields := strings.Fields(string(b[end+1:])) + if len(fields) <= 19 { + return "", errors.New("short /proc stat") + } + return fields[19], nil +} + +func processExecutable(pid int) (string, error) { + path, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "exe")) + if err != nil { + return "", err + } + path = strings.TrimSuffix(path, " (deleted)") + if resolved, resolveErr := filepath.EvalSymlinks(path); resolveErr == nil { + return resolved, nil + } + // After an in-place binary upgrade, /proc/PID/exe names the original path + // with " (deleted)" even though that inode is still executing. The path is + // still valid process identity metadata; requiring its replacement file to + // resolve would make `antares stop` unable to adopt the old instance. + return filepath.Clean(path), nil +} + +func processArguments(pid int) ([]string, error) { + b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) + if err != nil { + return nil, err + } + parts := strings.Split(strings.TrimRight(string(b), "\x00"), "\x00") + return parts, nil +} + +func processOwnerUID(pid int) (int, error) { + info, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))) + if err != nil { + return 0, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, errors.New("process owner unavailable") + } + return int(stat.Uid), nil +} + +func processesListeningOnPort(port int) ([]int, error) { + inodes := map[string]bool{} + for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + b, err := os.ReadFile(table) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n")[1:] { + fields := strings.Fields(line) + if len(fields) < 10 || fields[3] != "0A" { // TCP_LISTEN + continue + } + parts := strings.Split(fields[1], ":") + if len(parts) != 2 { + continue + } + got, err := strconv.ParseInt(parts[1], 16, 32) + if err == nil && int(got) == port { + inodes[fields[9]] = true + } + } + } + if len(inodes) == 0 { + return nil, nil + } + entries, err := os.ReadDir("/proc") + if err != nil { + return nil, err + } + seen := map[int]bool{} + var pids []int + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + fds, err := os.ReadDir(filepath.Join("/proc", entry.Name(), "fd")) + if err != nil { + continue + } + for _, fd := range fds { + target, err := os.Readlink(filepath.Join("/proc", entry.Name(), "fd", fd.Name())) + if err != nil || !strings.HasPrefix(target, "socket:[") { + continue + } + inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") + if inodes[inode] && !seen[pid] { + seen[pid] = true + pids = append(pids, pid) + } + } + } + sort.Ints(pids) + return pids, nil +} + +func signalDaemonProcess(proc *os.Process, managed bool, sig syscall.Signal) error { + if managed { + return syscall.Kill(-proc.Pid, sig) + } + return proc.Signal(sig) +} + +func daemonTargetAlive(pid int, managed bool) (bool, error) { + if !managed { + return processAlive(pid) + } + err := syscall.Kill(-pid, 0) + switch { + case err == nil, errors.Is(err, syscall.EPERM): + return true, nil + case errors.Is(err, syscall.ESRCH): + return false, nil + default: + return false, err + } +} + +func terminateDaemonProcess(proc *os.Process, managed bool) error { + return signalDaemonProcess(proc, managed, syscall.SIGTERM) +} +func killDaemonProcess(proc *os.Process, managed bool) error { + return signalDaemonProcess(proc, managed, syscall.SIGKILL) +} diff --git a/cmd/antares/daemon_other.go b/cmd/antares/daemon_other.go new file mode 100644 index 00000000..5720f2f8 --- /dev/null +++ b/cmd/antares/daemon_other.go @@ -0,0 +1,30 @@ +//go:build !linux + +package main + +import ( + "errors" + "os" + "os/exec" +) + +func configureDaemonProcess(_ *exec.Cmd) {} +func processAlive(_ int) (bool, error) { + return false, errors.New("daemon process inspection is only implemented on Linux") +} +func processStartTime(_ int) (string, error) { + return "", errors.New("daemon process inspection is only implemented on Linux") +} +func processExecutable(_ int) (string, error) { + return "", errors.New("daemon process inspection is only implemented on Linux") +} +func processArguments(_ int) ([]string, error) { + return nil, errors.New("daemon process inspection is only implemented on Linux") +} +func processOwnerUID(_ int) (int, error) { + return 0, errors.New("daemon process inspection is only implemented on Linux") +} +func processesListeningOnPort(_ int) ([]int, error) { return nil, nil } +func daemonTargetAlive(pid int, _ bool) (bool, error) { return processAlive(pid) } +func terminateDaemonProcess(proc *os.Process, _ bool) error { return proc.Signal(os.Interrupt) } +func killDaemonProcess(proc *os.Process, _ bool) error { return proc.Kill() } diff --git a/cmd/antares/daemon_test.go b/cmd/antares/daemon_test.go new file mode 100644 index 00000000..90473e68 --- /dev/null +++ b/cmd/antares/daemon_test.go @@ -0,0 +1,235 @@ +//go:build linux + +package main + +import ( + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/enowdev/antares/internal/config" +) + +func TestDaemonStateRoundTripAndStaleCleanup(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + if err := config.EnsureHome(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.ConfigFile(), []byte("server:\n host: 127.0.0.1\n port: 1\n"), 0o600); err != nil { + t.Fatal(err) + } + state := daemonState{PID: os.Getpid(), StartTime: "wrong-reused-token", Exe: "/does/not/matter", URL: "http://127.0.0.1:1"} + if err := writeDaemonState(state); err != nil { + t.Fatal(err) + } + got, err := readDaemonState() + if err != nil || got.PID != state.PID || got.StartTime != state.StartTime { + t.Fatalf("read state = %#v, %v", got, err) + } + _, live, err := currentDaemon() + if err != nil || live { + t.Fatalf("stale daemon = live %v, err %v", live, err) + } + if _, err := os.Stat(daemonPIDFile()); !os.IsNotExist(err) { + t.Fatalf("stale pid file was not removed: %v", err) + } +} + +func TestReadDaemonStateRejectsCorruption(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + if err := config.EnsureHome(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(daemonPIDFile(), []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readDaemonState(); err == nil || !strings.Contains(err.Error(), "invalid daemon state") { + t.Fatalf("corrupt state error = %v", err) + } +} + +func TestDaemonStartLockSerializesStartersAndReapsStaleLock(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + if err := config.EnsureHome(); err != nil { + t.Fatal(err) + } + release, err := acquireDaemonStartLock(time.Second) + if err != nil { + t.Fatal(err) + } + if _, err := acquireDaemonStartLock(80 * time.Millisecond); err == nil { + t.Fatal("second starter acquired an active lock") + } + release() + + if err := os.Mkdir(daemonLockDir(), 0o700); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-daemonReadyTimeout - 10*time.Second) + if err := os.Chtimes(daemonLockDir(), old, old); err != nil { + t.Fatal(err) + } + release, err = acquireDaemonStartLock(time.Second) + if err != nil { + t.Fatalf("stale lock was not reaped: %v", err) + } + release() +} + +func TestProcessStartTimeAndIdentity(t *testing.T) { + start, err := processStartTime(os.Getpid()) + if err != nil || start == "" { + t.Fatalf("start time = %q, %v", start, err) + } + exe, err := processExecutable(os.Getpid()) + if err != nil || exe == "" { + t.Fatalf("exe = %q, %v", exe, err) + } + args, err := processArguments(os.Getpid()) + if err != nil || len(args) == 0 { + t.Fatalf("args = %v, %v", args, err) + } +} + +func TestProcessesListeningOnPortFindsOwner(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + pids, err := processesListeningOnPort(port) + if err != nil { + t.Fatal(err) + } + for _, pid := range pids { + if pid == os.Getpid() { + return + } + } + t.Fatalf("listener owner %d not found in %v", os.Getpid(), pids) +} + +func TestDaemonCLILifecycle(t *testing.T) { + if testing.Short() { + t.Skip("builds and starts a real daemon") + } + dir := t.TempDir() + bin := filepath.Join(dir, "antares-test") + build := exec.Command("go", "build", "-o", bin, ".") + build.Dir = "." + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + home := filepath.Join(dir, "home") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + configYAML := "server:\n host: 127.0.0.1\n port: " + strconv.Itoa(port) + "\nmodel:\n default: test\n provider: custom\nproviders:\n custom:\n enabled: true\n kind: custom\n base_url: http://127.0.0.1:1/v1\ndatabase:\n driver: sqlite\n dsn: " + filepath.Join(home, "antares.db") + "\ncron:\n enabled: false\nmcp:\n enabled: false\n" + if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(configYAML), 0o600); err != nil { + t.Fatal(err) + } + env := append(os.Environ(), "ANTARES_HOME="+home) + run := func(args ...string) (string, error) { + cmd := exec.Command(bin, args...) + cmd.Env = env + out, err := cmd.CombinedOutput() + return string(out), err + } + + startAt := time.Now() + out, err := run("serve") + if err != nil { + t.Fatalf("serve: %v\n%s", err, out) + } + if time.Since(startAt) > 10*time.Second || !strings.Contains(out, "started in background") { + t.Fatalf("serve did not detach promptly: %s after %s", out, time.Since(startAt)) + } + stateBytes, err := os.ReadFile(filepath.Join(home, "antares.pid")) + if err != nil { + t.Fatal(err) + } + var state daemonState + if err := json.Unmarshal(stateBytes, &state); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if state.PID > 1 { + _ = syscall.Kill(state.PID, syscall.SIGKILL) + } + }) + + status, err := run("status") + if err != nil || !strings.Contains(status, "pid "+strconv.Itoa(state.PID)) { + t.Fatalf("status: %v\n%s", err, status) + } + again, err := run("serve") + if err != nil || !strings.Contains(again, "already running") { + t.Fatalf("second serve: %v\n%s", err, again) + } + stateAgain, _ := os.ReadFile(filepath.Join(home, "antares.pid")) + if string(stateAgain) != string(stateBytes) { + t.Fatal("duplicate serve replaced the running daemon state") + } + + stop, err := run("stop") + if err != nil || !strings.Contains(stop, "Antares stopped") { + t.Fatalf("stop: %v\n%s", err, stop) + } + if alive, _ := processAlive(state.PID); alive { + t.Fatalf("daemon pid %d survived stop", state.PID) + } + secondStop, err := run("stop") + if err != nil || !strings.Contains(secondStop, "not running") { + t.Fatalf("second stop: %v\n%s", err, secondStop) + } +} + +func TestDaemonServeRejectsOccupiedPortWithoutLeavingState(t *testing.T) { + if testing.Short() { + t.Skip("builds and starts a real daemon") + } + dir := t.TempDir() + bin := filepath.Join(dir, "antares-test") + build := exec.Command("go", "build", "-o", bin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + home := filepath.Join(dir, "home") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + configYAML := "server:\n host: 127.0.0.1\n port: " + strconv.Itoa(port) + "\nmodel:\n default: test\n provider: custom\nproviders:\n custom:\n enabled: true\n kind: custom\n base_url: http://127.0.0.1:1/v1\ndatabase:\n driver: sqlite\n dsn: " + filepath.Join(home, "antares.db") + "\ncron:\n enabled: false\nmcp:\n enabled: false\n" + if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(configYAML), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bin, "serve") + cmd.Env = append(os.Environ(), "ANTARES_HOME="+home) + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "failed to become ready") { + t.Fatalf("occupied serve = %v\n%s", err, out) + } + if _, statErr := os.Stat(filepath.Join(home, "antares.pid")); !os.IsNotExist(statErr) { + t.Fatalf("failed startup left pid state: %v", statErr) + } +} diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 740e987e..0f97a304 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -62,8 +62,9 @@ func run() error { // Bare `antares` opens the TUI when there is a terminal to draw on, and // falls back to serving when there is not (systemd, Docker, cron). command := "tui" - if !term.IsTerminal(int(os.Stdin.Fd())) { + if len(args) == 0 && !term.IsTerminal(int(os.Stdin.Fd())) { command = "serve" + args = []string{"--foreground"} } if len(args) > 0 && !strings.HasPrefix(args[0], "-") { command, args = args[0], args[1:] @@ -71,7 +72,13 @@ func run() error { switch command { case "serve", "start": - return cmdServe() + return cmdServe(args) + case "stop": + return cmdStop(args) + case "status": + return cmdStatus(args) + case "_serve_foreground": + return cmdServeForeground() case "tui", "chat-ui": return cmdTUI() case "chat", "repl", "cli": @@ -118,7 +125,10 @@ func printUsage() { Usage: antares Open the terminal UI (serves the API when headless) - antares serve Run the API server and dashboard + antares serve Start the API server in the background + antares serve --foreground Run attached to this terminal (debug/systemd) + antares stop Stop the background API server + antares status Show background server status antares tui Open the terminal UI explicitly antares setup Configure Antares (web or terminal wizard) antares chat Send one message and print the reply @@ -557,7 +567,7 @@ func (rt *runtimeServices) close() { _ = rt.db.Close() } -func cmdServe() error { +func cmdServeForeground() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop()