diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 66a56f9ce..5bbbc2047 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -1,23 +1,250 @@ package gateway import ( + "fmt" + "os" + "syscall" + "time" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/daemon" "github.com/spf13/cobra" ) func NewGatewayCommand() *cobra.Command { var debug bool + var runDaemon bool cmd := &cobra.Command{ Use: "gateway", Aliases: []string{"g"}, - Short: "Start picoclaw gateway", + Short: "Manage picoclaw gateway daemon", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { + // Check if running as daemon child process + if runDaemon { + return runDaemonMode(debug) + } + // Default to running in foreground return gatewayCmd(debug) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + cmd.Flags().BoolVar(&runDaemon, "run-daemon", false, "Run in daemon mode (internal use)") + + // Add daemon subcommands + cmd.AddCommand( + newStartCommand(), + newStopCommand(), + newRestartCommand(), + newStatusCommand(), + ) return cmd } + +func newStartCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start the gateway as a daemon", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return startDaemon() + }, + } + return cmd +} + +func newStopCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the gateway daemon", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return stopDaemon() + }, + } + return cmd +} + +func newRestartCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart the gateway daemon", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return restartDaemon() + }, + } + return cmd +} + +func newStatusCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show the gateway daemon status", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return showStatus() + }, + } + return cmd +} + +func startDaemon() error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Get the current executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Create daemon instance + d := daemon.New(daemon.Config{ + WorkspaceDir: cfg.WorkspacePath(), + Executable: execPath, + Args: []string{"gateway", "--run-daemon"}, + MaxRestarts: 3, + RestartWindow: 5 * time.Minute, + }) + + if err := d.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + fmt.Println("Gateway daemon started successfully") + fmt.Printf("Logs: %s\n", fmt.Sprintf("%s/%s", cfg.WorkspacePath(), daemon.LogFileName)) + return nil +} + +func stopDaemon() error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + d := daemon.New(daemon.Config{ + WorkspaceDir: cfg.WorkspacePath(), + }) + + if err := d.Stop(); err != nil { + return fmt.Errorf("failed to stop daemon: %w", err) + } + + fmt.Println("Gateway daemon stopped successfully") + return nil +} + +func restartDaemon() error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + d := daemon.New(daemon.Config{ + WorkspaceDir: cfg.WorkspacePath(), + Executable: execPath, + Args: []string{"gateway", "--run-daemon"}, + MaxRestarts: 3, + RestartWindow: 5 * time.Minute, + }) + + if err := d.Restart(); err != nil { + return fmt.Errorf("failed to restart daemon: %w", err) + } + + fmt.Println("Gateway daemon restarted successfully") + return nil +} + +func showStatus() error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + d := daemon.New(daemon.Config{ + WorkspaceDir: cfg.WorkspacePath(), + }) + + state, err := d.Status() + if err != nil { + return fmt.Errorf("daemon status: %w", err) + } + + uptime := time.Since(state.StartTime) + fmt.Printf("Gateway Daemon Status:\n") + fmt.Printf(" Status: Running\n") + fmt.Printf(" PID: %d\n", state.PID) + fmt.Printf(" Uptime: %s\n", formatDuration(uptime)) + fmt.Printf(" Restarts: %d\n", state.RestartCount) + if !state.LastRestart.IsZero() { + fmt.Printf(" Last Restart: %s ago\n", formatDuration(time.Since(state.LastRestart))) + } + + return nil +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%d seconds", int(d.Seconds())) + } else if d < time.Hour { + return fmt.Sprintf("%d minutes", int(d.Minutes())) + } else if d < 24*time.Hour { + hours := int(d.Hours()) + mins := int(d.Minutes()) % 60 + return fmt.Sprintf("%d hours %d minutes", hours, mins) + } else { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%d days %d hours", days, hours) + } +} + +// runDaemonMode is the entry point when running as a daemon +func runDaemonMode(debug bool) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + d := daemon.New(daemon.Config{ + WorkspaceDir: cfg.WorkspacePath(), + MaxRestarts: 3, + RestartWindow: 5 * time.Minute, + }) + + // Run the gateway with auto-restart + return d.RunWithAutoRestart(func() error { + return gatewayCmd(debug) + }) +} + +// isProcessRunning checks if a process with the given PID is running +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // Send signal 0 to check if process exists + if err := process.Signal(syscall.Signal(0)); err != nil { + return false + } + + return true +} diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 4d591ea67..65621ef32 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -13,7 +13,7 @@ func TestNewGatewayCommand(t *testing.T) { require.NotNil(t, cmd) assert.Equal(t, "gateway", cmd.Use) - assert.Equal(t, "Start picoclaw gateway", cmd.Short) + assert.Equal(t, "Manage picoclaw gateway daemon", cmd.Short) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("g")) @@ -24,8 +24,23 @@ func TestNewGatewayCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasSubCommands()) + // Should now have subcommands + assert.True(t, cmd.HasSubCommands()) + + // Check for daemon subcommands + assert.True(t, cmd.HasSubCommands()) + subcommands := cmd.Commands() + subcommandUses := make([]string, len(subcommands)) + for i, sub := range subcommands { + subcommandUses[i] = sub.Use + } + + assert.Contains(t, subcommandUses, "start") + assert.Contains(t, subcommandUses, "stop") + assert.Contains(t, subcommandUses, "restart") + assert.Contains(t, subcommandUses, "status") assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) + assert.NotNil(t, cmd.Flags().Lookup("run-daemon")) } diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go new file mode 100644 index 000000000..c8502227b --- /dev/null +++ b/pkg/daemon/daemon.go @@ -0,0 +1,584 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "sync" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + // Default configuration values + DefaultMaxRestarts = 3 + DefaultRestartWindow = 5 * time.Minute + DefaultMaxLogSize = 100 * 1024 * 1024 // 100MB + DefaultMaxLogBackups = 3 + DefaultShutdownTimeout = 30 * time.Second + + // File names + PIDFileName = "gateway.pid" + StateFileName = "gateway_state.json" + LogFileName = "gateway.log" +) + +// State represents the persistent daemon state +type State struct { + PID int `json:"pid"` + StartTime time.Time `json:"start_time"` + RestartCount int `json:"restart_count"` + LastRestart time.Time `json:"last_restart,omitempty"` + CrashCount int `json:"crash_count"` + FirstCrashTime time.Time `json:"first_crash_time,omitempty"` +} + +// Daemon manages the gateway daemon lifecycle +type Daemon struct { + pidFile string + stateFile string + logFile string + executable string + args []string + maxRestarts int + restartWindow time.Duration + maxLogSize int64 + maxLogBackups int + mu sync.RWMutex +} + +// Config holds daemon configuration +type Config struct { + WorkspaceDir string + Executable string + Args []string + MaxRestarts int + RestartWindow time.Duration + MaxLogSize int64 + MaxLogBackups int +} + +// New creates a new Daemon instance +func New(cfg Config) *Daemon { + if cfg.MaxRestarts <= 0 { + cfg.MaxRestarts = DefaultMaxRestarts + } + if cfg.RestartWindow <= 0 { + cfg.RestartWindow = DefaultRestartWindow + } + if cfg.MaxLogSize <= 0 { + cfg.MaxLogSize = DefaultMaxLogSize + } + if cfg.MaxLogBackups <= 0 { + cfg.MaxLogBackups = DefaultMaxLogBackups + } + + return &Daemon{ + pidFile: filepath.Join(cfg.WorkspaceDir, PIDFileName), + stateFile: filepath.Join(cfg.WorkspaceDir, StateFileName), + logFile: filepath.Join(cfg.WorkspaceDir, LogFileName), + executable: cfg.Executable, + args: cfg.Args, + maxRestarts: cfg.MaxRestarts, + restartWindow: cfg.RestartWindow, + maxLogSize: cfg.MaxLogSize, + maxLogBackups: cfg.MaxLogBackups, + } +} + +// Start starts the gateway as a daemon +func (d *Daemon) Start() error { + d.mu.Lock() + defer d.mu.Unlock() + + // Check if already running + if state, err := d.loadState(); err == nil && d.isProcessRunning(state.PID) { + return fmt.Errorf("gateway is already running (PID: %d)", state.PID) + } + + // Setup logging + if err := d.setupLogging(); err != nil { + return fmt.Errorf("failed to setup logging: %w", err) + } + + logger.InfoC("daemon", "Starting gateway daemon") + + // Start the process + cmd := exec.Command(d.executable, d.args...) + cmd.Dir = filepath.Dir(d.executable) + + // Set up output to log file + logFH, err := os.OpenFile(d.logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + defer logFH.Close() + + cmd.Stdout = logFH + cmd.Stderr = logFH + + // Set process group for cleanup + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start gateway: %w", err) + } + + pid := cmd.Process.Pid + + // Save state + state := &State{ + PID: pid, + StartTime: time.Now(), + RestartCount: 0, + } + + if err := d.saveState(state); err != nil { + // If we can't save state, kill the process + _ = cmd.Process.Kill() + return fmt.Errorf("failed to save daemon state: %w", err) + } + + logger.InfoCF("daemon", "Gateway daemon started", map[string]any{ + "pid": pid, + }) + + return nil +} + +// Stop stops the running gateway daemon +func (d *Daemon) Stop() error { + d.mu.Lock() + defer d.mu.Unlock() + + state, err := d.loadState() + if err != nil { + return fmt.Errorf("daemon not running: %w", err) + } + + if !d.isProcessRunning(state.PID) { + _ = d.cleanup() + return fmt.Errorf("daemon not running (stale PID file)") + } + + logger.InfoCF("daemon", "Stopping gateway daemon", map[string]any{ + "pid": state.PID, + }) + + // Try graceful shutdown first + process, err := os.FindProcess(state.PID) + if err != nil { + _ = d.cleanup() + return fmt.Errorf("failed to find process: %w", err) + } + + // Send SIGTERM for graceful shutdown + if err := process.Signal(syscall.SIGTERM); err != nil { + logger.WarnCF("daemon", "Failed to send SIGTERM, forcing kill", map[string]any{ + "pid": state.PID, + "error": err.Error(), + }) + // Force kill if SIGTERM fails + if err := process.Kill(); err != nil { + return fmt.Errorf("failed to kill process: %w", err) + } + } + + // Wait for process to exit with timeout + done := make(chan error, 1) + go func() { + _, err := process.Wait() + done <- err + }() + + select { + case <-time.After(DefaultShutdownTimeout): + logger.WarnCF("daemon", "Shutdown timeout, forcing kill", map[string]any{ + "pid": state.PID, + }) + _ = process.Kill() + <-done // Drain the channel + case err := <-done: + if err != nil { + logger.WarnCF("daemon", "Process wait error", map[string]any{ + "error": err.Error(), + }) + } + } + + if err := d.cleanup(); err != nil { + return fmt.Errorf("failed to cleanup daemon files: %w", err) + } + + logger.InfoC("daemon", "Gateway daemon stopped") + return nil +} + +// Restart restarts the gateway daemon +func (d *Daemon) Restart() error { + d.mu.Lock() + defer d.mu.Unlock() + + state, err := d.loadState() + wasRunning := err == nil && d.isProcessRunning(state.PID) + + if wasRunning { + logger.InfoC("daemon", "Restarting gateway daemon") + if err := d.stopLocked(); err != nil { + return fmt.Errorf("failed to stop daemon: %w", err) + } + // Small delay to ensure ports are released + time.Sleep(1 * time.Second) + } else { + logger.InfoC("daemon", "Starting gateway daemon (was not running)") + } + + if err := d.startLocked(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + return nil +} + +// Status returns the current daemon status +func (d *Daemon) Status() (*State, error) { + d.mu.RLock() + defer d.mu.RUnlock() + + state, err := d.loadState() + if err != nil { + return nil, fmt.Errorf("daemon not running: %w", err) + } + + if !d.isProcessRunning(state.PID) { + return nil, fmt.Errorf("daemon not running (stale PID file)") + } + + // Update uptime + state.StartTime = state.StartTime + + return state, nil +} + +// RunWithAutoRestart runs the gateway with automatic restart on crash +func (d *Daemon) RunWithAutoRestart(gatewayFunc func() error) error { + // Save current PID + state := &State{ + PID: os.Getpid(), + StartTime: time.Now(), + } + + if err := d.saveState(state); err != nil { + return fmt.Errorf("failed to save initial state: %w", err) + } + + logger.InfoCF("daemon", "Gateway started with auto-recovery", map[string]any{ + "pid": state.PID, + "max_restarts": d.maxRestarts, + "restart_window": d.restartWindow, + }) + + for { + if err := gatewayFunc(); err != nil { + logger.ErrorCF("daemon", "Gateway crashed", map[string]any{ + "error": err.Error(), + }) + + // Update crash state + if err := d.recordCrash(); err != nil { + logger.ErrorCF("daemon", "Failed to record crash", map[string]any{ + "error": err.Error(), + }) + } + + // Check if we should restart + shouldRestart, delay := d.shouldRestart() + if !shouldRestart { + logger.ErrorC("daemon", "Too many crashes, giving up") + return fmt.Errorf("gateway crashed too many times") + } + + logger.InfoCF("daemon", "Restarting gateway", map[string]any{ + "delay": delay, + }) + + time.Sleep(delay) + } else { + // Clean shutdown + logger.InfoC("daemon", "Gateway shutdown cleanly") + return d.cleanup() + } + } +} + +// startLocked starts the daemon (caller must hold lock) +func (d *Daemon) startLocked() error { + // Check if already running + if state, err := d.loadState(); err == nil && d.isProcessRunning(state.PID) { + return fmt.Errorf("gateway is already running (PID: %d)", state.PID) + } + + // Setup logging + if err := d.setupLogging(); err != nil { + return fmt.Errorf("failed to setup logging: %w", err) + } + + // Start the process + cmd := exec.Command(d.executable, d.args...) + cmd.Dir = filepath.Dir(d.executable) + + // Set up output to log file + logFH, err := os.OpenFile(d.logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + defer logFH.Close() + + cmd.Stdout = logFH + cmd.Stderr = logFH + + // Set process group for cleanup + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start gateway: %w", err) + } + + pid := cmd.Process.Pid + + // Save state + state := &State{ + PID: pid, + StartTime: time.Now(), + } + + if err := d.saveState(state); err != nil { + _ = cmd.Process.Kill() + return fmt.Errorf("failed to save daemon state: %w", err) + } + + logger.InfoCF("daemon", "Gateway daemon started", map[string]any{ + "pid": pid, + }) + + return nil +} + +// stopLocked stops the daemon (caller must hold lock) +func (d *Daemon) stopLocked() error { + state, err := d.loadState() + if err != nil { + return fmt.Errorf("daemon not running: %w", err) + } + + if !d.isProcessRunning(state.PID) { + _ = d.cleanup() + return fmt.Errorf("daemon not running (stale PID file)") + } + + process, err := os.FindProcess(state.PID) + if err != nil { + _ = d.cleanup() + return fmt.Errorf("failed to find process: %w", err) + } + + // Send SIGTERM for graceful shutdown + if err := process.Signal(syscall.SIGTERM); err != nil { + logger.WarnCF("daemon", "Failed to send SIGTERM, forcing kill", map[string]any{ + "pid": state.PID, + "error": err.Error(), + }) + _ = process.Kill() + } + + // Wait for process to exit with timeout + done := make(chan error, 1) + go func() { + _, err := process.Wait() + done <- err + }() + + select { + case <-time.After(DefaultShutdownTimeout): + logger.WarnCF("daemon", "Shutdown timeout, forcing kill", map[string]any{ + "pid": state.PID, + }) + _ = process.Kill() + <-done + case <-done: + } + + return d.cleanup() +} + +// recordCrash records a crash and updates restart state +func (d *Daemon) recordCrash() error { + state, err := d.loadState() + if err != nil { + state = &State{ + PID: os.Getpid(), + StartTime: time.Now(), + CrashCount: 0, + } + } + + state.CrashCount++ + now := time.Now() + + // Reset crash count if outside window + if !state.FirstCrashTime.IsZero() && now.Sub(state.FirstCrashTime) > d.restartWindow { + state.CrashCount = 1 + state.FirstCrashTime = now + } else if state.FirstCrashTime.IsZero() { + state.FirstCrashTime = now + } + + state.RestartCount++ + state.LastRestart = now + + return d.saveState(state) +} + +// shouldRestart determines if the daemon should restart and the delay +func (d *Daemon) shouldRestart() (bool, time.Duration) { + state, err := d.loadState() + if err != nil { + return true, time.Second + } + + // Check if we've exceeded max restarts + if state.CrashCount >= d.maxRestarts { + // Check if we're outside the restart window + if !state.FirstCrashTime.IsZero() && time.Since(state.FirstCrashTime) > d.restartWindow { + // Reset and allow restart + state.CrashCount = 0 + state.FirstCrashTime = time.Time{} + _ = d.saveState(state) + return true, time.Second + } + return false, 0 + } + + // Exponential backoff: 2^n seconds, max 30 seconds + backoff := time.Duration(1< 30*time.Second { + backoff = 30 * time.Second + } + + return true, backoff +} + +// loadState loads the daemon state from disk +func (d *Daemon) loadState() (*State, error) { + data, err := os.ReadFile(d.stateFile) + if err != nil { + return nil, err + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// saveState saves the daemon state to disk atomically +func (d *Daemon) saveState(state *State) error { + // Write to temp file first + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + tmpFile := d.stateFile + ".tmp" + if err := os.WriteFile(tmpFile, data, 0o644); err != nil { + return err + } + + // Atomic rename + return os.Rename(tmpFile, d.stateFile) +} + +// cleanup removes daemon state files +func (d *Daemon) cleanup() error { + if err := os.Remove(d.pidFile); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.Remove(d.stateFile); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// isProcessRunning checks if a process with the given PID is running +func (d *Daemon) isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // Send signal 0 to check if process exists + if err := process.Signal(syscall.Signal(0)); err != nil { + return false + } + + return true +} + +// setupLogging sets up file logging with rotation +func (d *Daemon) setupLogging() error { + // Rotate log if too large + if info, err := os.Stat(d.logFile); err == nil { + if info.Size() >= d.maxLogSize { + d.rotateLog() + } + } + + return logger.EnableFileLogging(d.logFile) +} + +// rotateLog rotates the log file +func (d *Daemon) rotateLog() { + // Remove oldest backup + oldestBackup := d.logFile + "." + strconv.Itoa(d.maxLogBackups) + os.Remove(oldestBackup) + + // Rotate existing backups + for i := d.maxLogBackups - 1; i >= 1; i-- { + oldFile := d.logFile + "." + strconv.Itoa(i) + newFile := d.logFile + "." + strconv.Itoa(i+1) + os.Rename(oldFile, newFile) + } + + // Move current log to .1 + os.Rename(d.logFile, d.logFile+".1") +} + +// WaitForShutdown waits for SIGTERM and returns a channel +func (d *Daemon) WaitForShutdown() <-chan struct{} { + ch := make(chan struct{}) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) + <-sigChan + close(ch) + }() + + return ch +} diff --git a/pkg/daemon/daemon_test.go b/pkg/daemon/daemon_test.go new file mode 100644 index 000000000..9e680c561 --- /dev/null +++ b/pkg/daemon/daemon_test.go @@ -0,0 +1,522 @@ +package daemon + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestNew(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + MaxRestarts: 5, + RestartWindow: 10 * time.Minute, + } + + d := New(cfg) + + if d == nil { + t.Fatal("New() returned nil") + } + + if d.pidFile != filepath.Join(tmpDir, PIDFileName) { + t.Errorf("Expected pidFile %s, got %s", filepath.Join(tmpDir, PIDFileName), d.pidFile) + } + + if d.stateFile != filepath.Join(tmpDir, StateFileName) { + t.Errorf("Expected stateFile %s, got %s", filepath.Join(tmpDir, StateFileName), d.stateFile) + } + + if d.logFile != filepath.Join(tmpDir, LogFileName) { + t.Errorf("Expected logFile %s, got %s", filepath.Join(tmpDir, LogFileName), d.logFile) + } + + if d.maxRestarts != 5 { + t.Errorf("Expected maxRestarts 5, got %d", d.maxRestarts) + } + + if d.restartWindow != 10*time.Minute { + t.Errorf("Expected restartWindow 10m, got %v", d.restartWindow) + } +} + +func TestNewDefaults(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + if d.maxRestarts != DefaultMaxRestarts { + t.Errorf("Expected default maxRestarts %d, got %d", DefaultMaxRestarts, d.maxRestarts) + } + + if d.restartWindow != DefaultRestartWindow { + t.Errorf("Expected default restartWindow %v, got %v", DefaultRestartWindow, d.restartWindow) + } + + if d.maxLogSize != DefaultMaxLogSize { + t.Errorf("Expected default maxLogSize %d, got %d", DefaultMaxLogSize, d.maxLogSize) + } + + if d.maxLogBackups != DefaultMaxLogBackups { + t.Errorf("Expected default maxLogBackups %d, got %d", DefaultMaxLogBackups, d.maxLogBackups) + } +} + +func TestSaveAndLoadState(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + state := &State{ + PID: 12345, + StartTime: time.Now().UTC().Truncate(time.Second), + RestartCount: 2, + LastRestart: time.Now().UTC().Truncate(time.Second), + CrashCount: 1, + FirstCrashTime: time.Now().UTC().Truncate(time.Second), + } + + if err := d.saveState(state); err != nil { + t.Fatalf("saveState() failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(d.stateFile); os.IsNotExist(err) { + t.Fatal("State file was not created") + } + + // Load state + loaded, err := d.loadState() + if err != nil { + t.Fatalf("loadState() failed: %v", err) + } + + if loaded.PID != state.PID { + t.Errorf("Expected PID %d, got %d", state.PID, loaded.PID) + } + + if loaded.RestartCount != state.RestartCount { + t.Errorf("Expected RestartCount %d, got %d", state.RestartCount, loaded.RestartCount) + } + + if loaded.CrashCount != state.CrashCount { + t.Errorf("Expected CrashCount %d, got %d", state.CrashCount, loaded.CrashCount) + } + + // Check file is valid JSON + data, err := os.ReadFile(d.stateFile) + if err != nil { + t.Fatalf("Failed to read state file: %v", err) + } + + var jsonState State + if err := json.Unmarshal(data, &jsonState); err != nil { + t.Fatalf("State file is not valid JSON: %v", err) + } +} + +func TestSaveStateAtomic(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + state := &State{ + PID: 12345, + StartTime: time.Now().UTC(), + } + + // Save multiple times to ensure atomic rename works + for i := 0; i < 10; i++ { + state.PID = 12345 + i + if err := d.saveState(state); err != nil { + t.Fatalf("saveState() iteration %d failed: %v", i, err) + } + } + + // Verify final state + loaded, err := d.loadState() + if err != nil { + t.Fatalf("loadState() failed: %v", err) + } + + if loaded.PID != 12354 { + t.Errorf("Expected final PID 12354, got %d", loaded.PID) + } + + // Verify no temp file left + tmpFile := d.stateFile + ".tmp" + if _, err := os.Stat(tmpFile); !os.IsNotExist(err) { + t.Error("Temp file was not cleaned up") + } +} + +func TestLoadStateNonexistent(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + _, err := d.loadState() + if err == nil { + t.Error("Expected error loading nonexistent state file") + } +} + +func TestIsProcessRunning(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + // Test with current process (should be running) + if !d.isProcessRunning(os.Getpid()) { + t.Error("Expected current process to be running") + } + + // Test with invalid PID + if d.isProcessRunning(-1) { + t.Error("Expected invalid PID to not be running") + } + + if d.isProcessRunning(0) { + t.Error("Expected PID 0 to not be running") + } + + // Test with likely unused PID (999999) + if d.isProcessRunning(999999) { + t.Error("Expected unlikely PID 999999 to not be running") + } +} + +func TestRecordCrash(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + MaxRestarts: 3, + RestartWindow: 5 * time.Minute, + } + + d := New(cfg) + + // Record initial state + state := &State{ + PID: os.Getpid(), + StartTime: time.Now(), + CrashCount: 0, + } + if err := d.saveState(state); err != nil { + t.Fatalf("Failed to save initial state: %v", err) + } + + // Record first crash + if err := d.recordCrash(); err != nil { + t.Fatalf("recordCrash() failed: %v", err) + } + + loaded, err := d.loadState() + if err != nil { + t.Fatalf("loadState() failed: %v", err) + } + + if loaded.CrashCount != 1 { + t.Errorf("Expected CrashCount 1, got %d", loaded.CrashCount) + } + + if loaded.RestartCount != 1 { + t.Errorf("Expected RestartCount 1, got %d", loaded.RestartCount) + } + + if loaded.FirstCrashTime.IsZero() { + t.Error("Expected FirstCrashTime to be set") + } + + // Record second crash + firstCrashTime := loaded.FirstCrashTime + if err := d.recordCrash(); err != nil { + t.Fatalf("recordCrash() failed: %v", err) + } + + loaded, err = d.loadState() + if err != nil { + t.Fatalf("loadState() failed: %v", err) + } + + if loaded.CrashCount != 2 { + t.Errorf("Expected CrashCount 2, got %d", loaded.CrashCount) + } + + if loaded.RestartCount != 2 { + t.Errorf("Expected RestartCount 2, got %d", loaded.RestartCount) + } + + if !loaded.FirstCrashTime.Equal(firstCrashTime) { + t.Error("Expected FirstCrashTime to remain unchanged") + } +} + +func TestShouldRestart(t *testing.T) { + tests := []struct { + name string + crashCount int + maxRestarts int + windowExpiry bool + shouldRestart bool + minDelay time.Duration + maxDelay time.Duration + }{ + { + name: "first crash", + crashCount: 1, + maxRestarts: 3, + windowExpiry: false, + shouldRestart: true, + minDelay: 1 * time.Second, + maxDelay: 2 * time.Second, + }, + { + name: "second crash", + crashCount: 2, + maxRestarts: 3, + windowExpiry: false, + shouldRestart: true, + minDelay: 2 * time.Second, + maxDelay: 4 * time.Second, + }, + { + name: "third crash", + crashCount: 3, + maxRestarts: 3, + windowExpiry: false, + shouldRestart: false, + }, + { + name: "crashes outside window", + crashCount: 3, + maxRestarts: 3, + windowExpiry: true, + shouldRestart: true, + minDelay: 1 * time.Second, + maxDelay: 2 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + MaxRestarts: tt.maxRestarts, + RestartWindow: 5 * time.Minute, + } + + d := New(cfg) + + state := &State{ + PID: os.Getpid(), + StartTime: time.Now(), + CrashCount: tt.crashCount - 1, + } + + if tt.crashCount > 0 { + if tt.windowExpiry { + // Set first crash time outside window + state.FirstCrashTime = time.Now().Add(-10 * time.Minute) + } else { + state.FirstCrashTime = time.Now().Add(-1 * time.Minute) + } + } + + if err := d.saveState(state); err != nil { + t.Fatalf("Failed to save state: %v", err) + } + + // Simulate a crash + if tt.crashCount > 0 { + if err := d.recordCrash(); err != nil { + t.Fatalf("recordCrash() failed: %v", err) + } + } + + shouldRestart, delay := d.shouldRestart() + + if shouldRestart != tt.shouldRestart { + t.Errorf("Expected shouldRestart %v, got %v", tt.shouldRestart, shouldRestart) + } + + if shouldRestart { + if delay < tt.minDelay || delay > tt.maxDelay { + t.Errorf("Expected delay between %v and %v, got %v", tt.minDelay, tt.maxDelay, delay) + } + } + }) + } +} + +func TestCleanup(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + // Create state files + state := &State{ + PID: 12345, + StartTime: time.Now(), + } + if err := d.saveState(state); err != nil { + t.Fatalf("Failed to save state: %v", err) + } + + // Create pid file + if err := os.WriteFile(d.pidFile, []byte("12345\n"), 0o644); err != nil { + t.Fatalf("Failed to create pid file: %v", err) + } + + // Verify files exist + if _, err := os.Stat(d.stateFile); os.IsNotExist(err) { + t.Error("State file was not created") + } + if _, err := os.Stat(d.pidFile); os.IsNotExist(err) { + t.Error("PID file was not created") + } + + // Cleanup + if err := d.cleanup(); err != nil { + t.Fatalf("cleanup() failed: %v", err) + } + + // Verify files are removed + if _, err := os.Stat(d.stateFile); !os.IsNotExist(err) { + t.Error("State file was not removed") + } + if _, err := os.Stat(d.pidFile); !os.IsNotExist(err) { + t.Error("PID file was not removed") + } +} + +func TestRotateLog(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + MaxLogSize: 1024, + MaxLogBackups: 3, + } + + d := New(cfg) + + // Create initial log file with some content + content := make([]byte, 1024) + for i := range content { + content[i] = 'x' + } + if err := os.WriteFile(d.logFile, content, 0o644); err != nil { + t.Fatalf("Failed to create log file: %v", err) + } + + // Rotate + d.rotateLog() + + // Check that .1 exists + if _, err := os.Stat(d.logFile + ".1"); os.IsNotExist(err) { + t.Error("Log file was not rotated to .1") + } + + // Create new log and rotate again + if err := os.WriteFile(d.logFile, content, 0o644); err != nil { + t.Fatalf("Failed to create log file: %v", err) + } + d.rotateLog() + + // Check .1 and .2 exist + if _, err := os.Stat(d.logFile + ".1"); os.IsNotExist(err) { + t.Error("Log file .1 does not exist") + } + if _, err := os.Stat(d.logFile + ".2"); os.IsNotExist(err) { + t.Error("Log file was not rotated to .2") + } +} + +func TestStatus(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + WorkspaceDir: tmpDir, + Executable: "/usr/bin/false", + Args: []string{}, + } + + d := New(cfg) + + // Test with no state + _, err := d.Status() + if err == nil { + t.Error("Expected error when daemon not running") + } + + // Save state with current process + state := &State{ + PID: os.Getpid(), + StartTime: time.Now(), + } + if err := d.saveState(state); err != nil { + t.Fatalf("Failed to save state: %v", err) + } + + // Get status + status, err := d.Status() + if err != nil { + t.Fatalf("Status() failed: %v", err) + } + + if status.PID != os.Getpid() { + t.Errorf("Expected PID %d, got %d", os.Getpid(), status.PID) + } +}