Repository navigation
Releases: grpmsoft/daemon
Release list
v0.4.2 — an authenticated control-plane client
An authenticated client for the daemon's control plane, and a shutdown path that uses it.
Added
Daemon.ControlClient()andControlClient—Get(ctx, path)andPost(ctx, path)against the loopback control plane with the bearer token from the PID file. The token stays inside the client (no accessor hands it out); a non-2xx status is an error carrying the status line and a bounded body snippet, so a failed call is reported instead of read as an empty answer. Requests have a 30-second ceiling of their own (a caller's shorter deadline still wins), redirects are not followed, and a body over 8 MiB is an error instead of a silently truncated one.
Changed
Stopsends its shutdown request throughControlClient— one implementation of the address, the bearer token and the status check.
Upgrade
go get github.com/grpmsoft/daemon@v0.4.2Full changelog: https://github.com/grpmsoft/daemon/blob/main/CHANGELOG.md
Compare: v0.4.1...v0.4.2
v0.4.1 — Proxy survives a daemon restart
v0.4.1 — Proxy survives a daemon restart
Fixed
Proxyfollows a replaced daemon. Before this release,Proxyread the daemon's port once. After a restart, a crash or an idle shutdown, the next request failed andProxyreturned, so every MCP client had to reconnect by hand. The lease was not re-attached either, so the new daemon saw zero agents while clients were still open. Now:- when its lease drops, the proxy looks for a daemon in the PID file for up to
Config.Timeoutand re-attaches. It never starts one there:Stop,Holdand the idle timer are not undone by an idle client; - a request whose dial failed, or that a daemon answered with 401 (another daemon on the old port), is sent once more after reconnecting (
EnsureRunningwhenConfig.Binaryis set, otherwise the PID file); - a request that failed after it was sent is answered with a JSON-RPC error for its id (
-32603, "daemon unavailable: …") and is never sent again, because it may have run. Notifications get no answer; Proxyreturns only when its context ends, stdin ends or fails, or stdout fails.
- when its lease drops, the proxy looks for a daemon in the PID file for up to
No API changes.
go get github.com/grpmsoft/daemon@v0.4.1
Full Changelog: v0.4.0...v0.4.1
v0.4.0 — Breaking Cleanup
v0.4.0 — Breaking Cleanup
Zero deprecated code. All breaking changes in one release.
Breaking Changes
- Remove
connect/disconnectendpoints — use/daemon/attach(lease-based) ProcessManager.Start(ctx, StartSpec)replacesStartDetachedDisableTokenAuthreplacesRequireToken(inverted: secure by default)WaitUntilReady(ctx, ...)— ctx parameter added to HealthCheckerShutdownTimeout— daemon drain budget; worst caseShutdownTimeout + 7s
Added
Hold(ctx)/Release()— opt-in autostart prevention via stop-intent markerSpawnCooldown(default 5s) — prevents rapid respawn after failuresync.Mutexon*Daemon— satisfies race detector for in-process calls
Fixed
- StateNew connection drain (5s stdlib delay with pooled connections)
- EnsureRunning check order: IsHeld → stop-intent → cooldown → start
Migration
| v0.3.x | v0.4.0 |
|---|---|
RequireToken: true |
Remove field (secure by default) |
RequireToken: false |
DisableTokenAuth: true |
StartDetached(bin, args, log, env) |
Start(ctx, StartSpec{...}) |
WaitUntilReady(port, path, timeout) |
WaitUntilReady(ctx, port, path, timeout) |
POST /daemon/connect |
GET /daemon/attach |
v0.3.3 — Config.Dir + DataDir absolute path
- Config.Dir: working directory for spawned daemon process (default = DataDir). Prevents daemon from holding parent's CWD which may be deleted or unmounted after start
- DataDir absolute: resolved to absolute path at construction time via filepath.Abs. Fixes relative DataDir (.gode) causing path doubling on Windows
- Config immutable after New(): applyDefaults runs once at construction, methods only read — eliminates data race on concurrent calls
v0.3.2
v0.3.1 — Lease-based attach + control-plane bearer token
Lease-based connection tracking and control-plane bearer token. Additive patch -- no API breakage, full backward compat with v0.3.0.
Added
GET /daemon/attach-- lease-based connection tracking (ADR-003). The TCP connection IS the lease: when the client process dies (SIGKILL, OOM, closed terminal), the kernel closes the socket and the connection count drops. No heartbeats, no timers. Replaces counting-basedconnect/disconnectfor new clients- Bearer token on
/daemon/*endpoints --rand.Text()(crypto/rand) stored in PID file (0600).Authorization: Bearer <token>required for/daemon/attach,/daemon/connect,/daemon/disconnect,/daemon/shutdown./healthstays open. Constant-time comparison viacrypto/subtle Config.RequireToken-- whentrue, the bearer token is also required for the application handler (everything outside/healthand/daemon/). Defaultfalseforcurldebugging compatibility- Proxy attach with fallback -- proxy tries
GET /daemon/attachfirst; if daemon returns 404 (v0.3.0), falls back toPOST /daemon/connect+POST /daemon/disconnect. Token sent on all daemon requests when available - ADR-003 -- design document: why counting fails, lease via TCP, token via PID file, mixed-version matrix, alternatives rejected (heartbeats, SSE, mTLS)
- 12 new tests (3 integration), coverage 75.5% → 85%
Deprecated
POST /daemon/connectandPOST /daemon/disconnect-- useGET /daemon/attachinstead. Kept functional for backward compatibility with v0.3.0 proxies
Security
- DNS rebinding hardening completed:
loopbackGuard(v0.1.1) + bearer token (v0.3.1). A page with a rebinding domain that passes the Host check still cannot authenticate - Token never appears in
proxy.log, daemon stderr, or error messages
Mixed-version compatibility
| Proxy | Daemon | Behavior |
|---|---|---|
| v0.3.1 | v0.3.1 | Lease attach + bearer token (optimal) |
| v0.3.1 | v0.3.0 | Fallback to connect/disconnect, no token (compatible) |
| v0.3.0 | v0.3.1 | connect/disconnect with token from PID file (compatible) |
Full Changelog: v0.3.0...v0.3.1
v0.3.0 — Lifecycle API v2, unified lock protocol
Lifecycle API v2 -- unified lock protocol. All lifecycle mutations serialize on a single startup lock. One internal path, two public semantics.
Breaking Changes
Start(ctx, binary, args) error→Start(ctx) (*Info, error)Restart(ctx, binary, args) error→Restart(ctx) (*Info, error)EnsureRunning(ctx, cfg, binary, args) (int, error)→ methodd.EnsureRunning(ctx) (*Info, error); package-levelEnsureRunning(ctx, cfg) (int, error)kept as convenience wrapperKillProcess(pid int) error→KillProcess(ctx, pid, grace) error-- context-aware with configurable timeoutConfiggainsBinaryandArgsfields -- daemon identity in config, not per-call params.Binarydefaults toos.Executable()Stop(ctx)now serialized on startup lock
Added
- Unified lock protocol (ADR-002):
startLocked/stopLockedinternal methods; public methods never call other public methods (prevents OFD deadlock) Config.Validate()enforced: called inStart,EnsureRunning,Stop,Restart,Serve-- path traversal rejected before any file I/O- Proxy
IsHeldpre-check: dead daemon returnsErrNotRunningimmediately instead of connection refused - InheritFD EWOULDBLOCK: documented and tested same-OFD re-flock behavior
- Windows
IsHeld: usesOPEN_EXISTING-- no empty file creation on probe - Orphan child killed on start failure (D8):
KillProcess+ log tail in error diagnostics - CI: actions upgraded to Node 24 (checkout@v7, setup-go@v7, codecov@v7, golangci-lint@v9), Dependabot for actions,
cache: falsefor zero-deps module
Changed
EnsureRunningis now a method on*Daemon(testable withNewWithDeps)- All mutating methods return
*Info(pid, port, start time in one call) RestartrunsstopLocked+startLockedunder a single lock acquisition
Validation
- Fable 5.1: 8 harness scenarios green -- concurrent Start (3 goroutines), Restart under parallel EnsureRunning (33 calls, 0 errors), D8 orphan kill, path traversal rejection
- CI: 8/8 checks green (build/test/lint/fmt × 3 OS including Windows with
-race)
Full Changelog: v0.2.0...v0.3.0
v0.2.0 — Inherited-Lock PID File + API Redesign
Breaking Changes
Stop()→Stop(ctx)— callers must pass contextProxy(ctx, port, path)→Proxy(ctx, cfg, ProxyOptions)— testable I/OSetHandler()deleted — dead codeIsAlive()based on pidlock (lock held = alive)
Architecture: Inherited-Lock PID File
PID file IS the lock. Correct by construction on all platforms.
- Unix: flock + fd inheritance via ExtraFiles
- Windows: share-mode + startup LockFileEx
- Startup lock protocol: two locks, two purposes, correct ordering
- N12 recovery: holder dies → next waiter becomes starter
- LockCtx: ctx-aware locking with LOCK_NB poll
New Features
- Sentinel errors (
ErrNotRunning,ErrAlreadyRunning, etc.) Config.Validate()— rejects invalid names, pathsPOST /daemon/shutdown— cross-platform graceful stopProxyOptions— testable stdin/stdout, opt-in payload logging- CAS Disconnect, EINTR retry
Verified
- 13 review rounds
- 5 integration tests (helper-process pattern)
- CI green on Linux + macOS + Windows
- Coverage: 71.4% daemon, 86.2% internal, 75% pidlock
v0.1.1 — Correctness & Security
Fixed
Start()createsDataDirif missing (os.MkdirAll) — first-run failure- Idle timer arms on startup with zero connections — was waiting forever
ConnTracker.Disconnect()clamps to zero — no more negative countStop()usesIsAlive()(PID + binary verification) — no more killing unrelated processes. Note: macOS/BSD remains PID-only (no/proc, deferred to v0.2.0)- Unix reaper goroutine prevents zombie processes;
isZombie()via/proc/<pid>/stat EnsureRunning()serialized with exclusive file lock (flock/LockFileEx)Unlock()no longer deletes lock file (flock+unlink race fix)- Binary path comparison handles
(deleted)suffix + symlinks + case-insensitive on Windows Serve()compare-and-delete: orphan cleanup can't delete a live daemon's PID fileProxy()skips disconnect if connect failed (no stray decrements)
Security
loopbackGuardmiddleware: rejects non-loopback Host/Origin headers (DNS rebinding, MCP spec)- Origin validation via
url.Parse+ hostname (blocks127.0.0.1.evil.combypass)
Tests
- Lock exclusivity + no-delete regression (V1)
binaryPathsEqual10 table-driven cases incl.(deleted)suffix- Compare-and-delete: orphan keeps foreign PID, own removed (V2c)
- Loopback guard 16 cases incl. bypass attack vectors
- ConnTracker clamp 6 table-driven cases
isZombieparser 13 cases
v0.1.0 — Initial Release
daemon v0.1.0
Cross-platform daemon lifecycle library for Go. Pure Go, zero CGO, zero dependencies (stdlib only).
Features
- Client mode — Start/Stop/Restart/Status to manage background daemon processes
- Server mode —
Serve()with automatic port selection, PID file, signal handling, health endpoint - Proxy mode —
Proxy()bridges stdin/stdout to daemon HTTP for MCP/JSON-RPC protocols - EnsureRunning — start-if-needed with race-safe PID locking
- run.Group — actor concurrency pattern (oklog/run reimplemented, zero deps)
- Connection tracking — atomic connect/disconnect counting with idle auto-shutdown
- Rich Model —
Daemonstruct + interfaces (PIDStore,ProcessManager,HealthChecker) - Cross-platform — Windows (
CREATE_NEW_PROCESS_GROUP) + Linux/macOS (setsid)
Install
go get github.com/grpmsoft/daemonQuality
- 106 tests, 83%/87% coverage
- Zero lint warnings (golangci-lint v2)
- CI: build + test + lint on 3 OS (Ubuntu, macOS, Windows)
- Go 1.27+, stdlib only