Help us shape the future of Go! Take the 2026 Go Developer Survey

daemon

package module
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 25 Imported by: 0

README

daemon

CI Go Reference codecov Go Version

Cross-platform daemon lifecycle library for Go applications. Pure Go, zero CGO, zero dependencies.

Manages background daemon processes with HTTP health checks, PID file locking, signal handling, connection tracking, and idle auto-shutdown. Built for tools that need a persistent background server -- MCP servers, language servers, dev tools.

Features

  • Client mode -- Start/Stop/Restart/Status to manage a background daemon from any CLI
  • Server mode -- Serve() runs as a foreground daemon with automatic port selection, PID file, signal handling, and health endpoint
  • Proxy mode -- Proxy() bridges stdin/stdout to the daemon's HTTP endpoint for MCP/JSON-RPC protocols
  • EnsureRunning -- "start if needed" with race-safe PID locking (two agents calling simultaneously is safe)
  • run.Group -- actor concurrency pattern (oklog/run reimplemented, zero deps) for composing HTTP server + signal handler + idle timer
  • Connection tracking -- atomic connect/disconnect counting with idle auto-shutdown when all clients leave
  • Rich Model -- Daemon struct depends on interfaces (PIDStore, ProcessManager, HealthChecker), not concrete implementations. Testable by design
  • Cross-platform -- Windows (CREATE_NEW_PROCESS_GROUP), Linux/macOS (setsid) with platform-specific process management in internal/
  • Pure Go -- no CGO, no assembly, stdlib only

Installation

go get github.com/grpmsoft/daemon

Quick Start

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/grpmsoft/daemon"
)

func main() {
    cfg := daemon.Config{
        Name:    "myapp",
        DataDir: ".myapp",
        Binary:  "", // empty = os.Executable()
        Args:    []string{"serve"},
    }

    d := daemon.New(cfg)

    // Start a background daemon.
    info, err := d.Start(context.Background())
    if err != nil {
        fmt.Fprintf(os.Stderr, "start: %v\n", err)
        os.Exit(1)
    }
    fmt.Printf("Started: PID %d, Port %d\n", info.PID, info.Port)

    // Check status.
    info, _ = d.Status()
    fmt.Printf("Status: %s, PID: %d, Port: %d\n", info.Status, info.PID, info.Port)

    // Stop the daemon.
    if err := d.Stop(context.Background()); err != nil {
        fmt.Fprintf(os.Stderr, "stop: %v\n", err)
    }
}

Server Mode

The spawned process calls Serve() to run as the daemon. It picks a free port, writes a PID file, registers a /health endpoint, and blocks until shutdown.

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "time"

    "github.com/grpmsoft/daemon"
)

func main() {
    cfg := daemon.Config{
        Name:        "myapp",
        DataDir:     ".myapp",
        IdleTimeout: 5 * time.Minute, // auto-shutdown after 5 min idle
    }

    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/hello", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hello from daemon")
    })

    // Blocks until SIGINT/SIGTERM, context cancel, or idle timeout.
    if err := daemon.Serve(context.Background(), cfg, mux); err != nil {
        fmt.Fprintf(os.Stderr, "serve: %v\n", err)
        os.Exit(1)
    }
}

Serve() uses a Group (actor pattern) internally: HTTP server + signal handler + idle timer run as concurrent actors. When any actor returns, all others are interrupted.

Proxy Mode

Proxy() bridges stdin/stdout to the daemon's HTTP endpoint. Reads newline-delimited JSON from stdin, POSTs each message to the daemon, writes responses to stdout. Tracks connections for idle auto-shutdown.

// In your CLI's "mcp serve" command:
port, err := daemon.EnsureRunning(ctx, cfg)
if err != nil {
    return err
}
return daemon.Proxy(ctx, cfg, daemon.ProxyOptions{MCPPath: "/mcp"})

ProxyOptions supports custom Stdin/Stdout (for testing) and LogPayloads (opt-in, disabled by default for security).

EnsureRunning

EnsureRunning is the main entry point for consumers. It checks if a daemon is already running and returns its port. If not, it starts a new one and waits for the health check to pass.

Available as both a method on *Daemon (returns *Info) and a package-level convenience function (returns port as int):

// Package-level convenience (creates a Daemon internally):
port, err := daemon.EnsureRunning(ctx, daemon.Config{
    Name:    "myapp",
    DataDir: ".myapp",
    Args:    []string{"serve"},
})
if err != nil {
    return err
}
fmt.Printf("Daemon ready on port %d\n", port)

// Method form (when you already have a Daemon instance):
d := daemon.New(cfg)
info, err := d.EnsureRunning(ctx)

Race-safe: if two agents call EnsureRunning simultaneously and one wins the start, the other detects the running daemon via the PID file and returns its port.

Configuration

Field Type Default Description
Name string (required) Application name. Used for PID file naming and health response
DataDir string (required) Directory for runtime files (PID, logs)
Binary string os.Executable() Executable path for the daemon process. Empty uses current binary
Args []string nil Arguments passed to the child process in Start()/Restart()
Timeout time.Duration 30s How long Start() waits for health check to pass
HealthPath string "/health" HTTP path for the health endpoint
IdleTimeout time.Duration 0 (disabled) Auto-shutdown after this duration with zero connections
RequireToken bool false When true, bearer token required for app handler too

Note: DataDir will contain a persistent <Name>.lock file used to serialize concurrent EnsureRunning calls. After a binary upgrade (go install), EnsureRunning returns the existing daemon's port -- call Restart() to pick up the new binary. Set Binary and Args in Config once; all lifecycle methods (Start, Stop, Restart, EnsureRunning) use them automatically.

Control-Plane Endpoints

Serve() registers internal endpoints for lifecycle management. All /daemon/* endpoints require a bearer token (generated at startup, stored in the PID file).

Method Path Auth Purpose
GET /health None Health check (used by WaitUntilReady)
GET /daemon/attach Bearer Lease-based connection tracking (v0.3.1+)
POST /daemon/connect Bearer Increment connection count (deprecated, use attach)
POST /daemon/disconnect Bearer Decrement connection count (deprecated, use attach)
POST /daemon/shutdown Bearer Graceful shutdown
Lease-Based Connection Tracking

Proxy() opens GET /daemon/attach and keeps the TCP connection alive for its lifetime. The connection IS the lease -- when the proxy process dies (SIGKILL, OOM, closed terminal), the kernel closes the socket, the connection count drops, and idle auto-shutdown can proceed. No heartbeats, no timers, no stray counts from crashed clients.

If the daemon doesn't support /daemon/attach (v0.3.0), the proxy falls back to POST /daemon/connect and POST /daemon/disconnect.

run.Group

Group is a zero-dependency reimplementation of the oklog/run actor pattern. Start multiple concurrent actors; when any one returns, all others are interrupted.

var g daemon.Group

g.Add(
    func() error { return httpServer.Serve(ln) },
    func(error) { httpServer.Shutdown(ctx) },
)

g.Add(
    func() error { <-sigCtx.Done(); return sigCtx.Err() },
    func(error) { sigStop() },
)

err := g.Run() // blocks until all actors stop

Testing

All interfaces (PIDStore, ProcessManager, HealthChecker) are designed for test injection via NewWithDeps():

d := daemon.NewWithDeps(cfg, mockPIDStore, mockProcs, mockHealth)
go test ./...

Contributing

Contributions are welcome. Please open an issue or pull request on GitHub.

See CONTRIBUTING.md for development guidelines and CHANGELOG.md for release history.

Star History

Star History Chart

License

MIT License. See LICENSE.

Documentation

Overview

Package daemon provides a reusable, cross-platform daemon lifecycle library.

Two modes of operation:

  • Client mode: Start/Stop/Restart/EnsureRunning/Status -- manage a background daemon process.
  • Server mode: Serve() -- run as the foreground daemon process with HTTP, PID management, signal handling.

All lifecycle mutations (Start, Stop, Restart, EnsureRunning) serialize on a single startup lock file. Public methods are thin wrappers: acquire lock, check state, delegate to internal *Locked methods, release lock.

Consumer wires CLI commands; this library provides the operations.

import "github.com/grpmsoft/daemon"

d := daemon.New(daemon.Config{Name: "myapp", DataDir: ".myapp", Args: []string{"serve"}})
info, err := d.Start(ctx)         // spawn detached background process
info, err = d.EnsureRunning(ctx)  // idempotent start-or-attach
d.Stop(ctx)                       // graceful shutdown
d.Status()                        // check if running, get info

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotRunning indicates the daemon is not currently running.
	ErrNotRunning = errors.New("daemon: not running")

	// ErrAlreadyRunning indicates a daemon instance is already running.
	ErrAlreadyRunning = errors.New("daemon: already running")

	// ErrStartTimeout indicates the daemon did not become ready within the configured timeout.
	ErrStartTimeout = errors.New("daemon: start timeout")

	// ErrUnhealthy indicates the health check failed after the daemon started.
	ErrUnhealthy = errors.New("daemon: health check failed")

	// ErrStalePIDFile indicates the PID file exists but the daemon is not running.
	ErrStalePIDFile = errors.New("daemon: stale pid file")

	// ErrInvalidConfig indicates the configuration is invalid.
	ErrInvalidConfig = errors.New("daemon: invalid config")
)

Functions

func EnsureRunning

func EnsureRunning(ctx context.Context, cfg Config) (int, error)

EnsureRunning is a convenience package-level wrapper that creates a Daemon from cfg and calls its EnsureRunning method. Returns the port the daemon is listening on.

func Proxy

func Proxy(ctx context.Context, cfg Config, opts ProxyOptions) error

Proxy bridges an input stream to the daemon's HTTP endpoint. Reads newline-delimited JSON-RPC from opts.Stdin, POSTs each to the daemon, writes responses to opts.Stdout.

Connection tracking: signals /daemon/connect on start and /daemon/disconnect on exit (only if connect succeeded — prevents stray decrements).

Respects ctx for cancellation. Uses context-based timeouts instead of hardcoded http.Client.Timeout.

func Serve

func Serve(ctx context.Context, cfg Config, handler http.Handler) error

Serve runs the daemon in the foreground. Called by the spawned process. It picks a free port, registers /health + connection tracking endpoints, writes PID data to the locked PID file, and blocks until ctx is cancelled, SIGINT/SIGTERM is received, or idle timeout fires.

Lock inheritance: if DAEMON_PIDFD=3 is set (spawned by Start/EnsureRunning), Serve inherits the locked fd from the parent via ExtraFiles. If not set (manual foreground start), Serve acquires the lock itself.

Idle auto-shutdown: if Config.IdleTimeout > 0 and all connections disconnect, Serve waits IdleTimeout before initiating graceful shutdown. A new connection arriving during the wait resets the timer.

Types

type Config

type Config struct {
	// Name identifies the application (e.g. "gode", "goda").
	// Used for PID file naming and health response.
	Name string `json:"name"`

	// DataDir is the directory for runtime files (PID, logs), relative to
	// the workspace root. Example: ".gode".
	DataDir string `json:"dataDir"`

	// Binary is the executable path for the daemon process.
	// Empty means os.Executable() (current binary).
	Binary string `json:"binary"`

	// Args are the arguments passed to the child process in Start/Restart.
	Args []string `json:"args"`

	// Timeout is how long Start() waits for the health check to pass.
	// Zero means 30 seconds.
	Timeout time.Duration `json:"timeout"`

	// HealthPath is the HTTP path for the health endpoint.
	// Zero value means "/health".
	HealthPath string `json:"healthPath"`

	// IdleTimeout is how long the daemon waits with zero active connections
	// before initiating a graceful shutdown. Zero means never auto-shutdown.
	IdleTimeout time.Duration `json:"idleTimeout"`

	// RequireToken, when true, requires the bearer token for the application
	// handler (everything outside /health and /daemon/*). Default false so
	// curl debugging of the app handler keeps working. MCP deployments
	// should set this to true.
	RequireToken bool `json:"requireToken"`
}

Config controls daemon lifecycle behavior. Consumers provide this when creating a Daemon instance.

func (*Config) Validate added in v0.2.0

func (c *Config) Validate() error

Validate checks the configuration for invalid values. Name must be a single path element (no slashes). DataDir must be non-empty. HealthPath must not conflict with reserved daemon paths.

type ConnTracker

type ConnTracker struct {
	// contains filtered or unexported fields
}

ConnTracker tracks active client connections to the daemon. When IdleTimeout is configured, dropping to zero connections starts an idle timer that triggers graceful shutdown.

func NewConnTracker

func NewConnTracker() *ConnTracker

NewConnTracker creates a connection tracker.

func (*ConnTracker) Active

func (ct *ConnTracker) Active() int64

Active returns the current number of active connections.

func (*ConnTracker) Connect

func (ct *ConnTracker) Connect()

Connect increments the connection count and signals that idle timer should be reset.

func (*ConnTracker) Disconnect

func (ct *ConnTracker) Disconnect()

Disconnect decrements the connection count. If it drops to zero, signals on idleCh so the idle timer can start. The count is clamped to zero — an unmatched Disconnect (stray request, crashed client) cannot drive it negative and cause premature shutdown.

type Daemon

type Daemon struct {
	// contains filtered or unexported fields
}

Daemon manages a background daemon process. It depends on PIDStore, ProcessManager, and HealthChecker interfaces — not on concrete implementations. New() wires the defaults.

func New

func New(cfg Config) *Daemon

New creates a Daemon with the given configuration and default implementations.

func NewWithDeps

func NewWithDeps(cfg Config, pids PIDStore, procs ProcessManager, health HealthChecker) *Daemon

NewWithDeps creates a Daemon with explicit interface implementations. Intended for testing — callers supply mock PIDStore, ProcessManager, and HealthChecker.

func (*Daemon) EnsureRunning added in v0.3.0

func (d *Daemon) EnsureRunning(ctx context.Context) (*Info, error)

EnsureRunning is idempotent: if a daemon is already running, returns its info. If not, starts a new one. Safe for concurrent callers — all serialize on the startup lock.

func (*Daemon) IsRunning

func (d *Daemon) IsRunning() bool

IsRunning returns true if the daemon process is alive according to the PID file.

func (*Daemon) Restart

func (d *Daemon) Restart(ctx context.Context) (*Info, error)

Restart stops a running daemon (if any) and starts a new one, under a single startup lock acquisition. This prevents the deadlock that would occur if Restart called the public Stop() then Start() (each acquires the startup lock independently).

func (*Daemon) Start

func (d *Daemon) Start(ctx context.Context) (*Info, error)

Start spawns a detached background process using Config.Binary and Config.Args. Returns ErrAlreadyRunning if a daemon is already running. The spawned process must eventually call Serve().

Lock protocol:

  • Acquire startup lock (serializes concurrent Start/EnsureRunning/Stop/Restart)
  • Under startup lock, check if daemon already running (PID file held)
  • Delegate to startLocked (acquire PID lock, spawn, health check)

func (*Daemon) Status

func (d *Daemon) Status() (*Info, error)

Status reads the PID file and checks the process state.

func (*Daemon) Stop

func (d *Daemon) Stop(ctx context.Context) error

Stop gracefully shuts down the daemon. Serialized on the startup lock. Idempotent: returns nil if the daemon is already stopped.

type Group

type Group struct {
	// contains filtered or unexported fields
}

Group manages multiple concurrent actors. When any actor returns, all others are interrupted. Modeled after oklog/run.Group but implemented from scratch with zero external dependencies.

func (*Group) Add

func (g *Group) Add(execute func() error, interrupt func(error))

Add registers an actor pair. execute is started in a goroutine by Run. interrupt is called when another actor's execute returns, with that actor's error. interrupt must cause execute to return promptly.

func (*Group) Run

func (g *Group) Run() error

Run starts all actors concurrently. It blocks until all actors have stopped. The first actor whose execute returns triggers interrupt on all others (including itself). Returns the error from the first actor that returned.

type HealthChecker

type HealthChecker interface {
	Check(port int, healthPath string) error
	WaitUntilReady(port int, healthPath string, timeout time.Duration) error
}

HealthChecker abstracts daemon readiness verification.

type Info

type Info struct {
	Status    Status        `json:"status"`
	PID       int           `json:"pid"`
	Port      int           `json:"port"`
	Name      string        `json:"name"`
	StartTime time.Time     `json:"startTime"`
	Uptime    time.Duration `json:"uptime"`
}

Info is the status snapshot returned by Daemon.Status().

type PIDInfo

type PIDInfo struct {
	PID       int       `json:"pid"`
	Port      int       `json:"port"`
	Name      string    `json:"name"`
	Binary    string    `json:"binary"`
	StartTime time.Time `json:"startTime"`
	Token     string    `json:"token,omitempty"`
}

PIDInfo holds the persisted state of a running daemon, as returned by PIDStore.Load().

type PIDStore

type PIDStore interface {
	Save(pid, port int, name, binary string, startTime time.Time) error
	Load() (PIDInfo, error)
	Clear() error
	IsAlive() bool
	Path() string
}

PIDStore abstracts PID file persistence. The Daemon model depends on this interface, not on a concrete file-based implementation. PIDInfo is returned by Load() with the stored daemon state.

type ProcessManager

type ProcessManager interface {
	StartDetached(binary string, args []string, logFile string, env []string) (pid int, err error)
	KillProcess(ctx context.Context, pid int, grace time.Duration) error
	IsProcessAlive(pid int) bool
}

ProcessManager abstracts platform-specific process lifecycle operations.

type ProxyOptions added in v0.2.0

type ProxyOptions struct {
	// MCPPath is the HTTP path on the daemon to POST JSON-RPC messages to.
	// Default: "/mcp".
	MCPPath string

	// Stdin is the input reader for JSON-RPC messages. Default: os.Stdin.
	Stdin io.Reader

	// Stdout is the output writer for JSON-RPC responses. Default: os.Stdout.
	Stdout io.Writer

	// LogPayloads enables logging of request/response bodies (first 500 bytes).
	// Default: false (only method/id/size/latency logged for security — S3).
	LogPayloads bool
}

ProxyOptions configures the Proxy behavior.

type Status

type Status int

Status represents the lifecycle state of a daemon process.

const (
	StatusStopped  Status = iota // not running, no PID file
	StatusStarting               // process spawned, health check pending
	StatusRunning                // healthy and responding
	StatusStopping               // shutdown in progress
	StatusError                  // PID file exists but process is dead or unhealthy
)

Status constants representing the lifecycle state of a daemon process.

func (Status) String

func (s Status) String() string

String returns a human-readable representation of the status.

Directories

Path Synopsis
Package internal provides platform-specific implementations for the daemon library: PID file management, process lifecycle operations, and health checking.
Package internal provides platform-specific implementations for the daemon library: PID file management, process lifecycle operations, and health checking.
pidlock
Package pidlock provides an exclusive advisory lock backed by the PID file itself.
Package pidlock provides an exclusive advisory lock backed by the PID file itself.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL