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:
parent
8a1fb03974
commit
072fa84b5a
4 changed files with 1351 additions and 3 deletions
|
|
@ -1,23 +1,250 @@
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/daemon"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
|
var runDaemon bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Short: "Start picoclaw gateway",
|
Short: "Manage picoclaw gateway daemon",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
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)
|
return gatewayCmd(debug)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "gateway", cmd.Use)
|
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.Len(t, cmd.Aliases, 1)
|
||||||
assert.True(t, cmd.HasAlias("g"))
|
assert.True(t, cmd.HasAlias("g"))
|
||||||
|
|
@ -24,8 +24,23 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.Nil(t, cmd.PersistentPreRun)
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
assert.Nil(t, cmd.PersistentPostRun)
|
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.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("run-daemon"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
584
pkg/daemon/daemon.go
Normal file
584
pkg/daemon/daemon.go
Normal 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
522
pkg/daemon/daemon_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue