From 0696d32042536d17c7b6523c058153ef1cc7cb5d Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 14:01:40 +0000 Subject: [PATCH] feat: add Daemon orchestrator for managed process lifecycle Co-Authored-By: Claude Opus 4.6 --- daemon.go | 156 +++++++++++++++++++++++++++++++++++++++++++++++++ daemon_test.go | 93 +++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 daemon.go create mode 100644 daemon_test.go diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..96b76aa --- /dev/null +++ b/daemon.go @@ -0,0 +1,156 @@ +package process + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" +) + +// DaemonOptions configures daemon mode execution. +type DaemonOptions struct { + // PIDFile path for single-instance enforcement. + // Leave empty to skip PID file management. + PIDFile string + + // ShutdownTimeout is the maximum time to wait for graceful shutdown. + // Default: 30 seconds. + ShutdownTimeout time.Duration + + // HealthAddr is the address for health check endpoints. + // Example: ":8080", "127.0.0.1:9000" + // Leave empty to disable health checks. + HealthAddr string + + // HealthChecks are additional health check functions. + HealthChecks []HealthCheck + + // OnReload is called when SIGHUP is received. + // Use for config reloading. Leave nil to ignore SIGHUP. + OnReload func() error +} + +// Daemon manages daemon lifecycle: PID file, health server, graceful shutdown. +type Daemon struct { + opts DaemonOptions + pid *PIDFile + health *HealthServer + running bool + mu sync.Mutex +} + +// NewDaemon creates a daemon runner with the given options. +func NewDaemon(opts DaemonOptions) *Daemon { + if opts.ShutdownTimeout == 0 { + opts.ShutdownTimeout = 30 * time.Second + } + + d := &Daemon{opts: opts} + + if opts.PIDFile != "" { + d.pid = NewPIDFile(opts.PIDFile) + } + + if opts.HealthAddr != "" { + d.health = NewHealthServer(opts.HealthAddr) + for _, check := range opts.HealthChecks { + d.health.AddCheck(check) + } + } + + return d +} + +// Start initialises the daemon (PID file, health server). +func (d *Daemon) Start() error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.running { + return errors.New("daemon already running") + } + + if d.pid != nil { + if err := d.pid.Acquire(); err != nil { + return err + } + } + + if d.health != nil { + if err := d.health.Start(); err != nil { + if d.pid != nil { + _ = d.pid.Release() + } + return err + } + } + + d.running = true + return nil +} + +// Run blocks until the context is cancelled. +func (d *Daemon) Run(ctx context.Context) error { + d.mu.Lock() + if !d.running { + d.mu.Unlock() + return errors.New("daemon not started - call Start() first") + } + d.mu.Unlock() + + <-ctx.Done() + + return d.Stop() +} + +// Stop performs graceful shutdown. +func (d *Daemon) Stop() error { + d.mu.Lock() + defer d.mu.Unlock() + + if !d.running { + return nil + } + + var errs []error + + shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) + defer cancel() + + if d.health != nil { + d.health.SetReady(false) + if err := d.health.Stop(shutdownCtx); err != nil { + errs = append(errs, fmt.Errorf("health server: %w", err)) + } + } + + if d.pid != nil { + if err := d.pid.Release(); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("pid file: %w", err)) + } + } + + d.running = false + + if len(errs) > 0 { + return fmt.Errorf("shutdown errors: %v", errs) + } + return nil +} + +// SetReady sets the daemon readiness status for health checks. +func (d *Daemon) SetReady(ready bool) { + if d.health != nil { + d.health.SetReady(ready) + } +} + +// HealthAddr returns the health server address, or empty if disabled. +func (d *Daemon) HealthAddr() string { + if d.health != nil { + return d.health.Addr() + } + return "" +} diff --git a/daemon_test.go b/daemon_test.go new file mode 100644 index 0000000..0fe52c7 --- /dev/null +++ b/daemon_test.go @@ -0,0 +1,93 @@ +package process + +import ( + "context" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDaemon_StartAndStop(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "test.pid") + + d := NewDaemon(DaemonOptions{ + PIDFile: pidPath, + HealthAddr: "127.0.0.1:0", + ShutdownTimeout: 5 * time.Second, + }) + + err := d.Start() + require.NoError(t, err) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + resp, err := http.Get("http://" + addr + "/health") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + + err = d.Stop() + require.NoError(t, err) +} + +func TestDaemon_DoubleStartFails(t *testing.T) { + d := NewDaemon(DaemonOptions{ + HealthAddr: "127.0.0.1:0", + }) + + err := d.Start() + require.NoError(t, err) + defer func() { _ = d.Stop() }() + + err = d.Start() + assert.Error(t, err) + assert.Contains(t, err.Error(), "already running") +} + +func TestDaemon_RunWithoutStartFails(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := d.Run(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestDaemon_SetReady(t *testing.T) { + d := NewDaemon(DaemonOptions{ + HealthAddr: "127.0.0.1:0", + }) + + err := d.Start() + require.NoError(t, err) + defer func() { _ = d.Stop() }() + + addr := d.HealthAddr() + + resp, _ := http.Get("http://" + addr + "/ready") + assert.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + + d.SetReady(false) + + resp, _ = http.Get("http://" + addr + "/ready") + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + _ = resp.Body.Close() +} + +func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + assert.Empty(t, d.HealthAddr()) +} + +func TestDaemon_DefaultShutdownTimeout(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) +}