Skip to content

cnuss/daemonize

Repository files navigation

daemonize

Go Reference Go Report Card CI CodeQL OpenSSF Scorecard Latest release License: MIT

daemonize wraps any cobra command with Unix daemon lifecycle controls — start, stop, status — by re-execing the binary as a detached background process. The wrapped command runs in the foreground; the daemon manages backgrounding, a pid file, log streaming during startup and shutdown, and signal-based readiness, all without mutating the command.

Quick Start

Add the dependency, the daemonize import, and wrap cmd.Execute():

go get github.com/cnuss/daemonize
 import (
 	"fmt"
 	"os"
+	"syscall"

+	"github.com/cnuss/daemonize"
 	"github.com/spf13/cobra"
 )

 func main() {
+	ready := make(chan struct{})

 	cmd := &cobra.Command{
 		...
 		RunE: func(cmd *cobra.Command, args []string) error {
 			fmt.Printf("hello %s\n", message)
+			close(ready)
 			<-cmd.Context().Done()
 			...
 		},
 	}
 	cmd.Flags().StringVarP(&message, "message", "m", "world", "who to greet")

-	if err := cmd.Execute(); err != nil {
+	if err := daemonize.FromCobra(cmd).
+		WithShutdownSignal(os.Interrupt, syscall.SIGTERM).
+		DetachOn(ready).
+		Execute(); err != nil {
 		fmt.Fprintln(os.Stderr, "error:", err)
 		os.Exit(1)
 	}
 }

(Full source: examples/hello/main.go.)

Before

A basic cobra command:

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

func main() {
	var message string
	cmd := &cobra.Command{
		Use:   "hello",
		Short: "Say hello",
		RunE: func(cmd *cobra.Command, args []string) error {
			fmt.Printf("hello %s\n", message)
			<-cmd.Context().Done()
			fmt.Println("stopping")
			return nil
		},
	}
	cmd.Flags().StringVarP(&message, "message", "m", "world", "who to greet")

	if err := cmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ ./hello --help
Say hello

Usage:
  hello [flags]

Flags:
  -h, --help             help for hello
  -m, --message string   who to greet (default "world")

After

$ ./hello --help
Say hello

Usage:
  hello [flags]
  hello [command]

Daemon Commands:
  start       Start `hello` in the background
  status      Report whether `hello` is running
  stop        Stop the running `hello`

Additional Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command

Flags:
  -h, --help             help for hello
  -m, --message string   who to greet (default "world")

Use "hello [command] --help" for more information about a command.

The wrapped flags (-m) carry through to start, so hello start -m there forwards exactly what the foreground worker would have seen:

$ ./hello help start
Start `hello` in the background

Usage:
  hello start [flags]

Flags:
  -h, --help             help for start
  -m, --message string   who to greet (default "world")

The command's own Short and flags stay as written; daemonize attaches the lifecycle subcommands and wraps RunE to own the pid file. Then:

./hello start         # daemonize (streams startup output, detaches when ready)
./hello status        # running (pid N)
./hello stop          # SIGTERM (Ctrl+C escalates to SIGKILL)
./hello               # run the wrapped command in the foreground
./hello -m there      # forward -m to the foreground run
./hello start -m there  # forward -m to the daemonized run

Platforms

  • Linux, macOS, and other Unix-likes — start/stop/status work through standard POSIX signals.
  • Windows — same surface, different primitives: CreateProcess(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) for detach, a <base>.ready sentinel file for the readiness handshake, and a per-daemon named pipe (\\.\pipe\daemonize-<base>) for graceful shutdown. Workers that want cross-platform graceful shutdown must use WithShutdownSignal + <-cmd.Context().Done() (raw signal.Notify(stop, SIGTERM) works on Unix but won't pick up the Windows pipe signal).

Side-by-side flow + the wire format for the named-pipe shutdown live in CLAUDE.md → Platform layer.

Exit codes

Lifecycle subcommands and the foreground run obey a fixed contract, verified by the TestExit* suite under ./e2e. Pinned for both Unix and Windows.

Exit 0 — clean

Path
start succeeded (child is detached and ready)
stop graceful — worker exited via cmd.Context().Done() teardown
stop when nothing was running (no pid file)
stop with a stale pid file — daemon cleared it
stop where the worker errored mid-shutdown but still exited
stop where Ctrl+C escalated to a forced kill — parent reaped cleanly
status running / not running / stale
Foreground worker returned without a shutdown signal arriving

Exit 1 — caller- or state-level failure

Path
start while a daemon is already running
start where the wrapped command exited during startup
start cancelled by Ctrl+C (startup cancelled)
status --output=<not text|json> (pflag rejection)
Any subcommand invoked with an unknown flag (cobra rejection)

Note: unknown POSITIONAL args are NOT a flag error — buildCobra defaults command.Args to cobra.ArbitraryArgs so positionals forward to the wrapped worker (see examples/with-args). Set a stricter Args validator on the wrapped command if you want positionals rejected.

Exit 128 + signum — foreground interrupted by signal

When the wrapped command is run directly (no start), a shutdown signal observed by WithShutdownSignal's signal.NotifyContext re-exits the process with the conventional 128 + signum code after the worker drains. Bash, init systems, and process supervisors expect this — a clean 0 would hide the fact that the run was interrupted.

Signal Exit code
SIGINT (2) 130
SIGTERM (15) 143
Other registered signals 128 + signum

Only fires for foreground runs of the wrapped command — daemon children spawned by start skip the re-raise so the parent's stop remains the authoritative exit reporter for that lifetime.

On Windows, the Go runtime maps both CTRL_C_EVENT and CTRL_BREAK_EVENT to syscall.SIGINT before delivery, so Ctrl+C of a foreground worker exits 130 on that target too.

Features

  • In-place enrichment: FromCobra(cmd).DetachOn(ready) returns the same *cobra.Command, now with start/stop/status attached as subcommands. Running the command directly still invokes its original RunE (the foreground worker); the wrapped RunE owns the pid file and relays readiness, so stop/status work against foreground runs too.
  • Channel-based readiness relay: the wrapped command closes a chan struct{} when bound/ready; the daemon translates that to SIGUSR1 internally so the parent can stop streaming and detach. Opaque to the wrapped command — it never sees a signal.
  • Streaming: start tails the child's log so the user sees real startup output until ready; stop tails it during graceful shutdown.
  • Ctrl+C handling: start sends SIGTERM to the child on the first Ctrl+C and waits for it to exit; a second Ctrl+C escalates to SIGKILL. stop follows the same pattern: first interrupt is the implicit SIGTERM, second escalates to SIGKILL.
  • Per-daemon state files: pid/log live under <UserCacheDir>/.<command-name>/<base>.{pid,log}. Override with WithName.
  • Help grouping: lifecycle subcommands are grouped (Daemon Commands: by default). Customize or disable with WithGroup.
  • Nestable: the enriched command can be mounted under a larger cobra tree — start re-execs along the full command path (foo run …).
  • Generic builder: Daemon[T] is parameterized; today T == *cobra.Command via FromCobra. Future backends can plug in.

Install

go get github.com/cnuss/daemonize

Module floor is go 1.21 / cobra v1.6.0.

API at a glance

type Daemon[T any] interface {
    FromCobra(inner *cobra.Command) Daemon[*cobra.Command]
    DetachOn(detachSig <-chan struct{}) T  // terminal: builds and returns T

    WithName(name string) Daemon[T]          // override state-file base name
    WithGroup(name *string) Daemon[T]        // help-group title (nil = ungroup)
    WithContext(parent context.Context) Daemon[T]    // parent ctx for the wrapped cmd; nil = opt out
    WithShutdownSignal(sigs ...os.Signal) Daemon[T]   // signal.NotifyContext around the parent ctx

    // Runtime accessors / actions (usable without building the cobra tree)
    Stop() error
    Status() error
    PID() (int, error)
    IsAlive() bool
    PIDFile() (string, error)
    LogFile() (string, error)
    Name() (string, error)
}

func NewDaemon() Daemon[any]                                       // untyped bootstrap
func FromCobra(command *cobra.Command) Daemon[*cobra.Command]      // shorthand

Examples

Self-contained programs in ./examples:

Example Demonstrates
hello Smallest wiring (FromCobra + DetachOn).
named WithName("widget") for custom pid/log file names.
grouped WithGroup(&"Lifecycle") for a custom help-group title.
ungrouped WithGroup(nil) to put lifecycle under Additional Commands.
with-args Flag + positional forwarding through start to the child.
slow-start Streaming a multi-second startup until ready.
slow-shutdown Streaming a multi-second graceful shutdown.
start-error Daemon detects a child that fails before signaling ready.
shutdown-error Daemon streams a failure during shutdown; still stops.
pid-cleanup Worker exits early without signaling ready; pid file gone.
stubborn Worker ignores SIGTERM; demonstrates Ctrl+C → SIGKILL.
subcommand Daemon mounted under a larger cobra tree (e.g. app run).

Run one locally:

make run hello start
make run hello status
make run hello stop

Testing

make test   # library unit tests (fast, in-package)
make e2e    # end-to-end harness: builds + drives every example binary

make e2e runs go test -count=1 -v ./e2e, with an isolated cache (HOME/XDG_CACHE_HOME) per test so pid/log files never collide.

Contributing

PRs welcome. See CONTRIBUTING.md for the local dev loop, release process, and what makes a good example.

License

MIT

About

Generic, mutation-free wrapper that adds Unix daemon lifecycle controls (start/stop/status/reload) to any cobra command via re-exec.

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors