feat(cli): add daemon lifecycle helper
All checks were successful
Security Scan / security (push) Successful in 20s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 04:22:22 +00:00
parent f13c3bf095
commit a2f27b9af4
4 changed files with 338 additions and 0 deletions

View file

@ -42,6 +42,34 @@ func runDaemon(cmd *cli.Command, args []string) error {
} }
``` ```
## Daemon Helper
Use `cli.NewDaemon()` when you want a helper that writes a PID file and serves
basic `/health` and `/ready` probes:
```go
daemon := cli.NewDaemon(cli.DaemonOptions{
PIDFile: "/tmp/core.pid",
HealthAddr: "127.0.0.1:8080",
HealthCheck: func() bool {
return true
},
ReadyCheck: func() bool {
return true
},
})
if err := daemon.Start(context.Background()); err != nil {
return err
}
defer func() {
_ = daemon.Stop(context.Background())
}()
```
`Start()` writes the current process ID to the configured file, and `Stop()`
removes it after shutting the probe server down.
## Shutdown with Timeout ## Shutdown with Timeout
The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file. The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file.

View file

@ -52,6 +52,7 @@ The framework has three layers:
| `TreeNode` | Tree structure with box-drawing connectors | | `TreeNode` | Tree structure with box-drawing connectors |
| `TaskTracker` | Concurrent task display with live spinners | | `TaskTracker` | Concurrent task display with live spinners |
| `CheckBuilder` | Fluent API for pass/fail/skip result lines | | `CheckBuilder` | Fluent API for pass/fail/skip result lines |
| `Daemon` | PID file and probe helper for background processes |
| `AnsiStyle` | Terminal text styling (bold, dim, colour) | | `AnsiStyle` | Terminal text styling (bold, dim, colour) |
## Built-in Services ## Built-in Services

219
pkg/cli/daemon_process.go Normal file
View file

@ -0,0 +1,219 @@
package cli
import (
"context"
"errors"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
// DaemonOptions configures a background process helper.
type DaemonOptions struct {
// PIDFile stores the current process ID on Start and removes it on Stop.
PIDFile string
// HealthAddr binds the HTTP health server.
// Pass an empty string to disable the server.
HealthAddr string
// HealthPath serves the liveness probe endpoint.
HealthPath string
// ReadyPath serves the readiness probe endpoint.
ReadyPath string
// HealthCheck reports whether the process is healthy.
// Defaults to true when nil.
HealthCheck func() bool
// ReadyCheck reports whether the process is ready to serve traffic.
// Defaults to HealthCheck when nil, or true when both are nil.
ReadyCheck func() bool
}
// Daemon manages a PID file and optional HTTP health endpoints.
type Daemon struct {
opts DaemonOptions
mu sync.Mutex
listener net.Listener
server *http.Server
addr string
started bool
}
// NewDaemon creates a daemon helper with sensible defaults.
func NewDaemon(opts DaemonOptions) *Daemon {
if opts.HealthPath == "" {
opts.HealthPath = "/health"
}
if opts.ReadyPath == "" {
opts.ReadyPath = "/ready"
}
return &Daemon{opts: opts}
}
// Start writes the PID file and starts the health server, if configured.
func (d *Daemon) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
d.mu.Lock()
defer d.mu.Unlock()
if d.started {
return nil
}
if err := d.writePIDFile(); err != nil {
return err
}
if d.opts.HealthAddr != "" {
if err := d.startHealthServer(ctx); err != nil {
_ = d.removePIDFile()
return err
}
}
d.started = true
return nil
}
// Stop shuts down the health server and removes the PID file.
func (d *Daemon) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
d.mu.Lock()
server := d.server
listener := d.listener
d.server = nil
d.listener = nil
d.addr = ""
d.started = false
d.mu.Unlock()
var firstErr error
if server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil && !isClosedServerError(err) {
firstErr = err
}
}
if listener != nil {
if err := listener.Close(); err != nil && !isListenerClosedError(err) && firstErr == nil {
firstErr = err
}
}
if err := d.removePIDFile(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
// HealthAddr returns the bound health server address, if running.
func (d *Daemon) HealthAddr() string {
d.mu.Lock()
defer d.mu.Unlock()
if d.addr != "" {
return d.addr
}
return d.opts.HealthAddr
}
func (d *Daemon) writePIDFile() error {
if d.opts.PIDFile == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
return err
}
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
}
func (d *Daemon) removePIDFile() error {
if d.opts.PIDFile == "" {
return nil
}
if err := os.Remove(d.opts.PIDFile); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *Daemon) startHealthServer(ctx context.Context) error {
mux := http.NewServeMux()
healthCheck := d.opts.HealthCheck
if healthCheck == nil {
healthCheck = func() bool { return true }
}
readyCheck := d.opts.ReadyCheck
if readyCheck == nil {
readyCheck = healthCheck
}
mux.HandleFunc(d.opts.HealthPath, func(w http.ResponseWriter, r *http.Request) {
writeProbe(w, healthCheck())
})
mux.HandleFunc(d.opts.ReadyPath, func(w http.ResponseWriter, r *http.Request) {
writeProbe(w, readyCheck())
})
listener, err := net.Listen("tcp", d.opts.HealthAddr)
if err != nil {
return err
}
server := &http.Server{
Handler: mux,
BaseContext: func(net.Listener) context.Context {
return ctx
},
}
d.listener = listener
d.server = server
d.addr = listener.Addr().String()
go func() {
err := server.Serve(listener)
if err != nil && !isClosedServerError(err) {
_ = err
}
}()
return nil
}
func writeProbe(w http.ResponseWriter, ok bool) {
if ok {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
return
}
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, "unhealthy\n")
}
func isClosedServerError(err error) bool {
return err == nil || err == http.ErrServerClosed
}
func isListenerClosedError(err error) bool {
return err == nil || errors.Is(err, net.ErrClosed)
}

View file

@ -0,0 +1,90 @@
package cli
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDaemon_StartStop(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
ready := false
daemon := NewDaemon(DaemonOptions{
PIDFile: pidFile,
HealthAddr: "127.0.0.1:0",
HealthCheck: func() bool {
return true
},
ReadyCheck: func() bool {
return ready
},
})
require.NoError(t, daemon.Start(context.Background()))
defer func() {
require.NoError(t, daemon.Stop(context.Background()))
}()
rawPID, err := os.ReadFile(pidFile)
require.NoError(t, err)
assert.Equal(t, strconv.Itoa(os.Getpid()), strings.TrimSpace(string(rawPID)))
addr := daemon.HealthAddr()
require.NotEmpty(t, addr)
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get("http://" + addr + "/health")
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ok\n", string(body))
resp, err = client.Get("http://" + addr + "/ready")
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, "unhealthy\n", string(body))
ready = true
resp, err = client.Get("http://" + addr + "/ready")
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ok\n", string(body))
}
func TestDaemon_StopRemovesPIDFile(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
daemon := NewDaemon(DaemonOptions{PIDFile: pidFile})
require.NoError(t, daemon.Start(context.Background()))
_, err := os.Stat(pidFile)
require.NoError(t, err)
require.NoError(t, daemon.Stop(context.Background()))
_, err = os.Stat(pidFile)
require.Error(t, err)
assert.True(t, os.IsNotExist(err))
}