feat(gateway): add daemon controls for background operation

Add start, stop, restart, and status commands for running the gateway
as a background daemon with automatic crash recovery.

Features:
- Daemon mode with PID file management
- Auto-restart with exponential backoff (3 attempts, 5-min window)
- File logging with automatic rotation (100MB, 3 backups)
- Status tracking (PID, uptime, restart count)
- Graceful shutdown with SIGTERM handling

New commands:
- picoclaw gateway start # Start as daemon
- picoclaw gateway stop # Stop daemon
- picoclaw gateway restart # Restart with auto-recovery
- picoclaw gateway status # Show daemon status
This commit is contained in:
Vishnuvardhan Reddy 2026-02-26 10:24:30 +00:00
parent 8a1fb03974
commit 072fa84b5a
4 changed files with 1351 additions and 3 deletions

View file

@ -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
}

View file

@ -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"))
}

584
pkg/daemon/daemon.go Normal file
View file

@ -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<<uint(state.CrashCount)) * time.Second
if backoff > 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
}

522
pkg/daemon/daemon_test.go Normal file
View file

@ -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)
}
}