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.
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.)
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")
$ ./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
- 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>.readysentinel 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 useWithShutdownSignal+<-cmd.Context().Done()(rawsignal.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.
Lifecycle subcommands and the foreground run obey a fixed contract,
verified by the TestExit* suite under ./e2e. Pinned for
both Unix and Windows.
| 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 |
| 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.
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.
- In-place enrichment:
FromCobra(cmd).DetachOn(ready)returns the same*cobra.Command, now withstart/stop/statusattached as subcommands. Running the command directly still invokes its originalRunE(the foreground worker); the wrappedRunEowns the pid file and relays readiness, sostop/statuswork against foreground runs too. - Channel-based readiness relay: the wrapped command closes a
chan struct{}when bound/ready; the daemon translates that toSIGUSR1internally so the parent can stop streaming and detach. Opaque to the wrapped command — it never sees a signal. - Streaming:
starttails the child's log so the user sees real startup output until ready;stoptails it during graceful shutdown. - Ctrl+C handling:
startsendsSIGTERMto the child on the first Ctrl+C and waits for it to exit; a second Ctrl+C escalates toSIGKILL.stopfollows the same pattern: first interrupt is the implicitSIGTERM, second escalates toSIGKILL. - Per-daemon state files: pid/log live under
<UserCacheDir>/.<command-name>/<base>.{pid,log}. Override withWithName. - Help grouping: lifecycle subcommands are grouped (
Daemon Commands:by default). Customize or disable withWithGroup. - Nestable: the enriched command can be mounted under a larger cobra
tree —
startre-execs along the full command path (foo run …). - Generic builder:
Daemon[T]is parameterized; todayT == *cobra.CommandviaFromCobra. Future backends can plug in.
go get github.com/cnuss/daemonizeModule floor is go 1.21 / cobra v1.6.0.
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] // shorthandSelf-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
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.
PRs welcome. See CONTRIBUTING.md for the local dev loop, release process, and what makes a good example.