From 10a1c8ce07e8e530d186c66d9d44e15de1ccef9a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 14:12:18 +0000 Subject: [PATCH] refactor: remove daemon types moved to go-process, keep Mode/DetectMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove PIDFile, HealthServer, Daemon, DaemonOptions, HealthCheck, Run, and RunWithTimeout from daemon.go — all now live in go-process. Retain Mode type (ModeInteractive/ModePipe/ModeDaemon), DetectMode(), IsTTY(), IsStdinTTY(), and IsStderrTTY() as CLI-specific helpers. Co-Authored-By: Virgil --- pkg/cli/daemon.go | 385 ----------------------------------------- pkg/cli/daemon_test.go | 213 ----------------------- 2 files changed, 598 deletions(-) diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index b532688..6fb6c06 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -2,19 +2,8 @@ package cli import ( - "context" - "errors" - "fmt" - "net" - "net/http" "os" - "path/filepath" - "strconv" - "sync" - "syscall" - "time" - "forge.lthn.ai/core/go-io" "golang.org/x/term" ) @@ -71,377 +60,3 @@ func IsStderrTTY() bool { return term.IsTerminal(int(os.Stderr.Fd())) } -// --- PID File Management --- - -// PIDFile manages a process ID file for single-instance enforcement. -type PIDFile struct { - path string - mu sync.Mutex -} - -// NewPIDFile creates a PID file manager. -func NewPIDFile(path string) *PIDFile { - return &PIDFile{path: path} -} - -// Acquire writes the current PID to the file. -// Returns error if another instance is running. -func (p *PIDFile) Acquire() error { - p.mu.Lock() - defer p.mu.Unlock() - - // Check if PID file exists - if data, err := io.Local.Read(p.path); err == nil { - pid, err := strconv.Atoi(data) - if err == nil && pid > 0 { - // Check if process is still running - if process, err := os.FindProcess(pid); err == nil { - if err := process.Signal(syscall.Signal(0)); err == nil { - return fmt.Errorf("another instance is running (PID %d)", pid) - } - } - } - // Stale PID file, remove it - _ = io.Local.Delete(p.path) - } - - // Ensure directory exists - if dir := filepath.Dir(p.path); dir != "." { - if err := io.Local.EnsureDir(dir); err != nil { - return fmt.Errorf("failed to create PID directory: %w", err) - } - } - - // Write current PID - pid := os.Getpid() - if err := io.Local.Write(p.path, strconv.Itoa(pid)); err != nil { - return fmt.Errorf("failed to write PID file: %w", err) - } - - return nil -} - -// Release removes the PID file. -func (p *PIDFile) Release() error { - p.mu.Lock() - defer p.mu.Unlock() - return io.Local.Delete(p.path) -} - -// Path returns the PID file path. -func (p *PIDFile) Path() string { - return p.path -} - -// --- Health Check Server --- - -// HealthServer provides a minimal HTTP health check endpoint. -type HealthServer struct { - addr string - server *http.Server - listener net.Listener - mu sync.Mutex - ready bool - checks []HealthCheck -} - -// HealthCheck is a function that returns nil if healthy. -type HealthCheck func() error - -// NewHealthServer creates a health check server. -func NewHealthServer(addr string) *HealthServer { - return &HealthServer{ - addr: addr, - ready: true, - } -} - -// AddCheck registers a health check function. -func (h *HealthServer) AddCheck(check HealthCheck) { - h.mu.Lock() - h.checks = append(h.checks, check) - h.mu.Unlock() -} - -// SetReady sets the readiness status. -func (h *HealthServer) SetReady(ready bool) { - h.mu.Lock() - h.ready = ready - h.mu.Unlock() -} - -// Start begins serving health check endpoints. -// Endpoints: -// - /health - liveness probe (always 200 if server is up) -// - /ready - readiness probe (200 if ready, 503 if not) -func (h *HealthServer) Start() error { - mux := http.NewServeMux() - - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - checks := h.checks - h.mu.Unlock() - - for _, check := range checks { - if err := check(); err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) - return - } - } - - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintln(w, "ok") - }) - - mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - ready := h.ready - h.mu.Unlock() - - if !ready { - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = fmt.Fprintln(w, "not ready") - return - } - - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintln(w, "ready") - }) - - listener, err := net.Listen("tcp", h.addr) - if err != nil { - return fmt.Errorf("failed to listen on %s: %w", h.addr, err) - } - - h.listener = listener - h.server = &http.Server{Handler: mux} - - go func() { - if err := h.server.Serve(listener); err != http.ErrServerClosed { - LogError(fmt.Sprintf("health server error: %v", err)) - } - }() - - return nil -} - -// Stop gracefully shuts down the health server. -func (h *HealthServer) Stop(ctx context.Context) error { - if h.server == nil { - return nil - } - return h.server.Shutdown(ctx) -} - -// Addr returns the actual address the server is listening on. -// Useful when using port 0 for dynamic port assignment. -func (h *HealthServer) Addr() string { - if h.listener != nil { - return h.listener.Addr().String() - } - return h.addr -} - -// --- Daemon Runner --- - -// 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. -type Daemon struct { - opts DaemonOptions - pid *PIDFile - health *HealthServer - reload chan struct{} - 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, - reload: make(chan struct{}, 1), - } - - 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). -// Call this after cli.Init(). -func (d *Daemon) Start() error { - d.mu.Lock() - defer d.mu.Unlock() - - if d.running { - return errors.New("daemon already running") - } - - // Acquire PID file - if d.pid != nil { - if err := d.pid.Acquire(); err != nil { - return err - } - } - - // Start health server - 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 or a signal is received. -// Handles graceful shutdown with the configured timeout. -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() - - // Wait for context cancellation (from signal handler) - <-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 - - // Create shutdown context with timeout - shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) - defer cancel() - - // Stop health server - 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)) - } - } - - // Release PID file - 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 "" -} - -// --- Convenience Functions --- - -// Run blocks until context is cancelled or signal received. -// Simple helper for daemon mode without advanced features. -// -// cli.Init(cli.Options{AppName: "myapp"}) -// defer cli.Shutdown() -// cli.Run(cli.Context()) -func Run(ctx context.Context) error { - mustInit() - <-ctx.Done() - return ctx.Err() -} - -// RunWithTimeout wraps Run with a graceful shutdown timeout. -// The returned function should be deferred to replace cli.Shutdown(). -// -// cli.Init(cli.Options{AppName: "myapp"}) -// shutdown := cli.RunWithTimeout(30 * time.Second) -// defer shutdown() -// cli.Run(cli.Context()) -func RunWithTimeout(timeout time.Duration) func() { - return func() { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // Create done channel for shutdown completion - done := make(chan struct{}) - go func() { - Shutdown() - close(done) - }() - - select { - case <-done: - // Clean shutdown - case <-ctx.Done(): - // Timeout - force exit - LogWarn("shutdown timeout exceeded, forcing exit") - } - } -} diff --git a/pkg/cli/daemon_test.go b/pkg/cli/daemon_test.go index fb12c45..0de2b96 100644 --- a/pkg/cli/daemon_test.go +++ b/pkg/cli/daemon_test.go @@ -1,15 +1,9 @@ package cli import ( - "context" - "net/http" - "os" - "path/filepath" "testing" - "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDetectMode(t *testing.T) { @@ -25,210 +19,3 @@ func TestDetectMode(t *testing.T) { assert.Equal(t, "unknown", Mode(99).String()) }) } - -func TestPIDFile(t *testing.T) { - t.Run("acquire and release", func(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "test.pid") - - pid := NewPIDFile(pidPath) - - err := pid.Acquire() - require.NoError(t, err) - - err = pid.Release() - require.NoError(t, err) - }) - - t.Run("stale pid file", func(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "stale.pid") - - // Write a stale PID (non-existent process). - require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644)) - - pid := NewPIDFile(pidPath) - - // Should acquire successfully (stale PID removed). - err := pid.Acquire() - require.NoError(t, err) - - err = pid.Release() - require.NoError(t, err) - }) - - t.Run("creates parent directory", func(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid") - - pid := NewPIDFile(pidPath) - - err := pid.Acquire() - require.NoError(t, err) - - err = pid.Release() - require.NoError(t, err) - }) - - t.Run("path getter", func(t *testing.T) { - pid := NewPIDFile("/tmp/test.pid") - assert.Equal(t, "/tmp/test.pid", pid.Path()) - }) -} - -func TestHealthServer(t *testing.T) { - t.Run("health and ready endpoints", func(t *testing.T) { - hs := NewHealthServer("127.0.0.1:0") // Random port - - err := hs.Start() - require.NoError(t, err) - defer func() { _ = hs.Stop(context.Background()) }() - - addr := hs.Addr() - require.NotEmpty(t, addr) - - // Health should be OK - resp, err := http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Ready should be OK by default - resp, err = http.Get("http://" + addr + "/ready") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Set not ready - hs.SetReady(false) - - resp, err = http.Get("http://" + addr + "/ready") - require.NoError(t, err) - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - _ = resp.Body.Close() - }) - - t.Run("with health checks", func(t *testing.T) { - hs := NewHealthServer("127.0.0.1:0") - - healthy := true - hs.AddCheck(func() error { - if !healthy { - return assert.AnError - } - return nil - }) - - err := hs.Start() - require.NoError(t, err) - defer func() { _ = hs.Stop(context.Background()) }() - - addr := hs.Addr() - - // Should be healthy - resp, err := http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Make unhealthy - healthy = false - - resp, err = http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - _ = resp.Body.Close() - }) -} - -func TestDaemon(t *testing.T) { - t.Run("start and stop", func(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) - - // Health server should be running - 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() - - // Stop should succeed - err = d.Stop() - require.NoError(t, err) - }) - - t.Run("double start fails", func(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") - }) - - t.Run("run without start fails", func(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") - }) - - t.Run("set ready", func(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() - - // Initially ready - resp, _ := http.Get("http://" + addr + "/ready") - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Set not ready - d.SetReady(false) - - resp, _ = http.Get("http://" + addr + "/ready") - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - _ = resp.Body.Close() - }) - - t.Run("no health addr returns empty", func(t *testing.T) { - d := NewDaemon(DaemonOptions{}) - assert.Empty(t, d.HealthAddr()) - }) - - t.Run("default shutdown timeout", func(t *testing.T) { - d := NewDaemon(DaemonOptions{}) - assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) - }) -} - -func TestRunWithTimeout(t *testing.T) { - t.Run("creates shutdown function", func(t *testing.T) { - // Just test that it returns a function - shutdown := RunWithTimeout(100 * time.Millisecond) - assert.NotNil(t, shutdown) - }) -}