go-daemon-runner

module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 8, 2026 License: MIT

README

go-daemon-runner

Go Reference Test golangci-lint Coverage sloc deps GitHub Release Built Go

Compose your program from background goroutines — go-daemon-runner provides the process lifecycle around them: sequential start, signal handling, and concurrent, deadline-bounded graceful shutdown.

go-daemon-runner is for Go programs built from background goroutines. Your program is a set of components that run for its lifetime — periodic backups, schedulers, log aggregation, replication — each one a goroutine with a start/stop contract. The runner starts them sequentially in registration order, waits for SIGINT/SIGQUIT/SIGTERM, shuts them down concurrently within a configurable deadline (default 15s), and runs your reload hook on SIGHUP — the process keeps running, no restart.

Content

Features

  • Graceful shutdown on termination signals. On SIGINT, SIGQUIT, or SIGTERM the runner stops every started daemon concurrently and waits for each to complete before Run returns. No daemon is left running, no goroutine leaked, no process left hanging.
  • Deadline-bounded shutdown. Shutdown is bounded by a configurable deadline (default 15s, WithShutdownTimeout). A daemon that fails to stop in time is reported — Run returns its error — instead of being waited on indefinitely.
  • Rollback on startup failure. Daemons start sequentially in registration order. If one fails to start, the runner stops the daemons already running and Run returns the startup error: the process never continues half-started.
  • Reload on SIGHUP without restart. SIGHUP invokes the hook set via WithReloadFunc while all daemons keep running — systemctl reload works. With no hook configured, SIGHUP is logged and ignored.
  • Errors surfaced to the caller. Run returns startup and shutdown errors (joined), so the caller can map them to exit codes instead of guessing from logs.

Installation

go get github.com/caasmo/go-daemon-runner

The two packages

  • daemon (github.com/caasmo/go-daemon-runner/daemon) — the Daemon interface and the Base helper struct for building daemons. Stdlib only.
  • run (github.com/caasmo/go-daemon-runner/run) — the Runner dispatcher: sequential start, signal handling, and concurrent graceful shutdown.

Usage pattern: daemon to build daemons, run to run them.

Runnable example

A complete runnable example of the library — daemons covering the blocking patterns of rule 2, including a communicating pair (a producer sending messages to a logger that owns the channel), plus the reload hook — lives in cmd/example:

go run ./cmd/example

The example is self-commented: each daemon file documents its simulated workload, blocking pattern, and the README rules it follows.

Writing daemons

A daemon is an object that satisfies the Daemon interface:

type Daemon interface {
	Name() string
	Run() error
	Stop(ctx context.Context) error
}

The interface alone is not enough. The runner calls Run at startup, sequentially, and Stop at shutdown — ctx carries the graceful-shutdown deadline. Your object must additionally follow these rules:

Rules
  1. Run must spawn the daemon's background goroutine. It must return an error if startup fails. It may return nil immediately once the goroutine is running, or block until startup is confirmed.

  2. The goroutine must reach a blocking point. It must block until shutdown is signaled, among others:

    1. A select on Ctx.Done()
    2. A bare <-Ctx.Done()
    3. A context-aware library call

    A goroutine that never blocks would keep the process alive.

  3. The goroutine must signal completion of shutdown. Register defer close(ShutdownDone) as its first defer — defers run last-in-first-out, so it executes after every other deferred cleanup and Stop unblocks only once all cleanup has completed.

  4. Stop must wait for completion. Signal the daemon to shut down and wait until the goroutine signals completion, or until the context deadline expires, whichever comes first.

Example

The simplest daemon that satisfies all four rules — every rule is visible in the code:

type SimpleDaemon struct {
	ctx    context.Context
	cancel context.CancelFunc
	done   chan struct{} // closed by the goroutine to signal completion
}

func NewSimpleDaemon() *SimpleDaemon {
	ctx, cancel := context.WithCancel(context.Background())
	return &SimpleDaemon{ctx: ctx, cancel: cancel, done: make(chan struct{})}
}

func (d *SimpleDaemon) Name() string { return "SimpleDaemon" }

func (d *SimpleDaemon) Run() error { // rule 1: Run spawns the background goroutine
	go func() {
		defer close(d.done) // rule 3: signal completion, after all cleanup
		<-d.ctx.Done()      // rule 2: block until shutdown is signaled
	}()
	return nil
}

func (d *SimpleDaemon) Stop(ctx context.Context) error {
	d.cancel()
	select {
	case <-d.done: // rule 4: wait for the goroutine's completion signal
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}
Rule helper: Base

The example above hand-writes Stop, the context, and the completion channel — the boilerplate every daemon needs. Base reduces that boilerplate. Base alone does not satisfy the interface: it has no Run — it is a helper, not a daemon, and you can implement the interface directly without it. It implements Stop (rule 4) — cancelling Ctx and waiting on ShutdownDone or the deadline — and provides the fields Run needs for the other rules:

  • Ctx — cancelled by Stop to signal the goroutine to exit.
  • Cancel — the cancel function Stop calls.
  • ShutdownDone — the goroutine closes it to signal completion; Stop waits on it.
  • Logger — used by Stop for lifecycle logging.

The same daemon with Base:

type SimpleDaemon struct {
	daemon.Base // rule 4: Stop given as implemented; provides Ctx (rule 2) and ShutdownDone (rule 3)
}

func NewSimpleDaemon(logger *slog.Logger) *SimpleDaemon {
	return &SimpleDaemon{
		Base: daemon.NewBase("SimpleDaemon", logger),
	}
}

func (d *SimpleDaemon) Run() error { // rule 1: Run spawns the background goroutine
	go func() {
		defer close(d.ShutdownDone) // rule 3: signal completion, after all cleanup
		<-d.Ctx.Done()              // rule 2: block until shutdown is signaled
	}()
	return nil
}

With Base you only write Run: the context and completion-channel fields, the constructor plumbing, Name, and Stop (rule 4) are inherited — Name is trivial anyway. What remains the daemon's job: Run spawning the goroutine (rule 1), reaching the blocking point (rule 2), and the goroutine signaling completion (rule 3).

Real daemons following these rules: restinpieces' log daemon, scheduler, and the litestream daemon.

Wiring daemons

The Runner wires daemons into a process lifecycle: it starts them sequentially, then waits for a termination signal, then shuts the started daemons down concurrently within a graceful deadline.

r, err := run.NewRunner(
	run.WithLogger(logger),
	run.WithShutdownTimeout(30 * time.Second),
)
if err != nil {
	panic(err)
}
r.Add(backupDaemon) // any daemon.Daemon
r.Add(schedulerDaemon)
if err := r.Run(); err != nil {
	panic(err) // startup failed or shutdown had errors
}
Options
Signals

The runner supports the following signals — SIGHUP is the only one with a function, a single reload hook:

Signal Action
SIGINT, SIGQUIT, SIGTERM Graceful shutdown of all daemons
SIGHUP Reload hook (below); ignored if none configured

Under systemd: systemctl stop sends SIGTERM, systemctl reload sends SIGHUP via ExecReload=/bin/kill -HUP $MAINPID.

The reload hook

SIGHUP does not shut the runner down — it runs the reloadFunc set via WithReloadFunc and the signal loop keeps running:

systemctl reloadExecReload=/bin/kill -HUP $MAINPID → SIGHUP → reloadFunc

reloadFunc is a closure that captures whatever state the reload needs and rebuilds it in place; the daemon instance is never swapped, so the rebuilt state takes effect on its next iteration. A failed reload is logged and the runner keeps running. Keep reloadFunc fast: it runs synchronously inside the signal loop, and termination signals arriving while it blocks can be dropped.

Wired into the runner with the example above:

// pseudo-code: rebuild the daemon's state in place
reloadFunc := func() error {
	cfg, err := fetchConfig()
	if err != nil {
		return err
	}
	schedulerDaemon.Update(cfg) // takes effect on the next tick
	return nil
}

r, err := run.NewRunner(run.WithReloadFunc(reloadFunc))
if err != nil {
	panic(err)
}
r.Add(backupDaemon)
r.Add(schedulerDaemon)
if err := r.Run(); err != nil {
	panic(err)
}

Communicating daemons

Daemons communicate the Go way — shared channels, not shared state. Forms:

  • Channel sharing at init time — as in the example: main wires the write-end of one daemon's channel into another's constructor (NewLogProducerDaemon(loggerDaemon.Chan(), ...) in main.go). The consumer owns the channel, the drain, and the shutdown flush; producers hold only the write-end. See logger_daemon.go and log_producer_daemon.go.
  • Channel sharing at runtime — a daemon exposes a Submit-style method (QueueDaemon.SubmitJob in queue_daemon.go); callers never touch the channel.
  • Context exposure — a daemon's Chan() returns its context too, so producers stop sending before the owner drains and closes (restinpieces' log daemon).
  • Shared state — a common pointer mutated in place, e.g. the reload hook flipping the backup pause flag in main.go.

Directories

Path Synopsis
cmd
example command
Command example wires four daemons through the runner: sequential start, signal-driven main loop, and concurrent graceful shutdown.
Command example wires four daemons through the runner: sequential start, signal-driven main loop, and concurrent graceful shutdown.
Package daemon defines the contract and base lifecycle for background components managed by a runner.
Package daemon defines the contract and base lifecycle for background components managed by a runner.
Package run provides a Runner that manages the lifecycle of daemons: sequential start, signal handling, and concurrent graceful shutdown.
Package run provides a Runner that manages the lifecycle of daemons: sequential start, signal handling, and concurrent graceful shutdown.

Jump to

Keyboard shortcuts

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