Pure-Go on-demand process lifecycle and local IPC layer for shared background services used by CLI tools and agents.
Zero CGO. Zero dependencies. Cross-platform.
Your CLI starts a background process when needed, shares it between multiple clients, and shuts it down when idle. The owner of the process lifetime is demand, not an init system.
CLI #1 ──┐
│ EnsureRunning
CLI #2 ──┼──────────────────► daemon process ──► idle ──► shutdown
│ │
CLI #3 ──┘ lease tracking
(zero connections
+ IdleTimeout)
MCP servers, language servers, and dev tools need a persistent background process shared by multiple agents. Without lifecycle management, each agent spawns its own instance -- 6 agents x ~1-2 GB gopls = 6-12 GB RAM.
daemon solves this with one abstraction: EnsureRunning -- start if needed, return the port if already running. Race-safe: two agents calling simultaneously never produce two daemons. The loser detects the winner via a kernel-level file lock and returns the same port.
go get github.com/grpmsoft/daemoncfg := daemon.Config{
Name: "myapp",
DataDir: ".myapp",
Args: []string{"serve"},
}
// Start-if-needed, get port (package-level convenience):
port, err := daemon.EnsureRunning(ctx, cfg)
// Or use the method form for full control:
d := daemon.New(cfg)
info, err := d.Start(ctx) // error if already running
info, err = d.EnsureRunning(ctx) // idempotent
info, err = d.Restart(ctx) // stop + start, one lock
err = d.Stop(ctx) // graceful shutdown
info, err = d.Status() // read-only, no lock
// Explicit autostart prevention:
err = d.Hold(ctx) // stop + write .stop-intent marker
d.Release() // clear marker, allow restartsAll mutating methods serialize on a startup lock and an in-process mutex. Public methods never call other public methods. Config is immutable after New() -- concurrent calls are safe.
Your CLI manages a background daemon:
d := daemon.New(daemon.Config{
Name: "myapp",
DataDir: ".myapp",
Args: []string{"serve"},
})
info, err := d.Start(ctx) // spawn, wait for health
info, _ := d.Status() // PID, port, uptime
d.Stop(ctx) // graceful shutdownThe spawned process calls Serve() to become the daemon. It picks a free port, writes a PID file with a bearer token, registers /health, and blocks until shutdown.
mux := http.NewServeMux()
mux.HandleFunc("GET /api/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello from daemon")
})
daemon.Serve(ctx, daemon.Config{
Name: "myapp",
DataDir: ".myapp",
IdleTimeout: 5 * time.Minute,
}, mux)Serve() uses a Group (actor pattern) internally: HTTP server + signal handler + idle timer + shutdown endpoint run as concurrent actors. When any actor returns, all others are interrupted.
Proxy() bridges stdin/stdout to the daemon's HTTP endpoint for MCP/JSON-RPC protocols:
port, _ := daemon.EnsureRunning(ctx, cfg)
daemon.Proxy(ctx, cfg, daemon.ProxyOptions{MCPPath: "/mcp"})ProxyOptions fields: MCPPath (HTTP path, default "/mcp"), Stdin/Stdout (io.Reader/io.Writer -- replaceable for testing, default os.Stdin/os.Stdout), LogPayloads (opt-in, default false -- when enabled, logs request/response bodies to proxy.log; disabled by default because payloads may contain sensitive data).
The proxy automatically establishes a lease-based connection to the daemon (see below) and forwards newline-delimited JSON between stdin and the daemon's HTTP endpoint.
The daemon may be replaced while a proxy runs (restart, crash, idle shutdown). The proxy follows it, and the client on stdin never has to reconnect:
| What happened | What the proxy does |
|---|---|
| Its lease dropped (daemon restarted or exited) | Looks for a daemon in the PID file for up to Config.Timeout and re-attaches. It never starts one here: a stop (Stop, Hold, the idle timer) is a decision an idle client must not undo |
| A request's dial failed, or a daemon answered 401 (another daemon took the old port) | The request never ran: the proxy reconnects (EnsureRunning when Config.Binary is set, otherwise the PID file) and sends it once more |
| A request failed after it was sent | It may have run (a tool call may have changed files): the client gets a JSON-RPC error for that id (-32603, "daemon unavailable: …") and the request is never sent again. Notifications get no answer |
The daemon is held (Hold) |
Requests get the JSON-RPC error; the proxy keeps running and works again after Release |
Proxy returns only when its context ends, stdin ends or fails, or stdout fails.
The lifetime of a TCP connection is the lease. When a proxy process dies -- SIGKILL, OOM, closed terminal -- the kernel closes the socket, the daemon sees the disconnect, and the connection count drops. No heartbeats. No timers. No stray counts from crashed clients.
Proxy process Daemon
│ │
├── GET /daemon/attach ──► ct.Connect()
│ │ │
│ TCP alive │ connection count > 0
│ = lease held │ idle timer blocked
│ │ │
╳ SIGKILL │
│ │
kernel closes socket ──► ct.Disconnect()
│
count drops to 0
│
IdleTimeout fires
│
graceful shutdown
- One request in flight (sequential dispatch, not pipelined)
- No server-to-client notifications (unidirectional)
- Not Streamable-HTTP/SSE compatible (plain JSON-RPC POST only)
- Use direct HTTP connection for concurrent tool calls
Stop() shuts down the daemon but does not prevent EnsureRunning from starting it again. If you need the daemon to stay down -- for example, during a binary upgrade or manual debugging -- use Hold():
d := daemon.New(cfg)
// Stop and block automatic restarts:
err := d.Hold(ctx) // writes .stop-intent marker
// Any subsequent EnsureRunning returns ErrStopIntent:
_, err = d.EnsureRunning(ctx) // err == daemon.ErrStopIntent
// Allow restarts again:
d.Release() // clears the marker
// -- or --
d.Start(ctx) // Start and Restart also clear the markerThe stop-intent marker is a file in DataDir. It survives process crashes. Start() and Restart() clear it implicitly -- an explicit start decision overrides a previous hold.
When a spawn fails (binary not found, port conflict, health check timeout), EnsureRunning writes a cooldown marker. During the cooldown window, subsequent EnsureRunning calls return ErrSpawnCooldown immediately instead of retrying. This prevents rapid respawn loops when multiple MCP agents call EnsureRunning after a failure.
cfg := daemon.Config{
Name: "myapp",
DataDir: ".myapp",
SpawnCooldown: 10 * time.Second, // default: 5s, -1 to disable
}
d := daemon.New(cfg)
_, err := d.EnsureRunning(ctx) // spawn fails
_, err = d.EnsureRunning(ctx) // err == daemon.ErrSpawnCooldown (within 10s)The cooldown marker is time-based: it expires after SpawnCooldown elapses. A successful Start() or Restart() clears it. Context cancellation skips the cooldown check.
Config.ShutdownTimeout (default 10s) is the daemon's graceful drain budget. Serve() passes it to http.Server.Shutdown. On the client side, Stop() sends a shutdown request (fixed 2s timeout), waits ShutdownTimeout + 2s for the daemon to release the PID lock, then escalates to kill with a fixed 3s grace. Worst case: ShutdownTimeout + 7s.
cfg := daemon.Config{
Name: "myapp",
DataDir: ".myapp",
ShutdownTimeout: 15 * time.Second, // default: 10s
}Serve() generates a bearer token (crypto/rand) at startup and stores it in the PID file (0600 permissions). All /daemon/* control-plane endpoints require Authorization: Bearer <token>. The /health endpoint stays open for external probes. The application handler (everything outside /health and /daemon/*) also requires the token by default (secure by default since v0.4.0).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/health |
None | Readiness probe |
GET |
/daemon/attach |
Bearer | Lease connection |
POST |
/daemon/shutdown |
Bearer | Graceful stop |
DNS rebinding protection: loopbackGuard middleware rejects requests with non-loopback Host or Origin headers. Combined with the bearer token, a page with a rebinding domain that passes the Host check still cannot authenticate.
Set Config.DisableTokenAuth = true to skip token auth for the application handler (e.g., for local curl debugging).
| Field | Type | Default | Description |
|---|---|---|---|
Name |
string |
(required) | Application name, used for PID file naming |
DataDir |
string |
(required) | Directory for runtime files (PID, logs). Resolved to absolute path |
Binary |
string |
os.Executable() |
Executable path for the daemon process |
Args |
[]string |
nil |
Arguments passed to the child process |
Dir |
string |
DataDir |
Working directory for the spawned daemon |
Timeout |
time.Duration |
30s |
How long Start() waits for health check |
HealthPath |
string |
"/health" |
HTTP path for the health endpoint |
IdleTimeout |
time.Duration |
0 (disabled) |
Auto-shutdown after this duration with zero connections |
DisableTokenAuth |
bool |
false |
Skip bearer token auth for the application handler |
SpawnCooldown |
time.Duration |
5s |
Cooldown after spawn failure; -1 to disable |
ShutdownTimeout |
time.Duration |
10s |
Daemon graceful drain budget; Stop() worst case is ShutdownTimeout + 7s |
Config is normalized at construction time (New()). Set Binary and Args once; all lifecycle methods use them automatically.
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 stopAll interfaces (PIDStore, ProcessManager, HealthChecker) are designed for test injection:
d := daemon.NewWithDeps(cfg, mockPIDStore, mockProcs, mockHealth)Integration tests use the helper-process pattern -- no external binaries needed. CI runs with -race on Linux, macOS, and Windows.
This library is not a replacement for systemd, launchd, or Windows SCM. If you need a long-lived system service, use kardianos/service -- it is the industry standard for that job.
The two libraries are complementary: if your tool must be present from boot, run myapp serve under kardianos/service and skip EnsureRunning. Do not combine Restart=always with idle auto-shutdown -- two supervisors will fight.
Contributions welcome. See CONTRIBUTING.md for guidelines and CHANGELOG.md for release history.
MIT License. See LICENSE.