mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -10:00
* feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running local service that supervises a pool of headless `zero exec` workers and routes multiple agent sessions to them over an owner-only Unix-domain control socket. Additive — interactive and one-shot exec are unchanged. Reuses zero's building blocks: - internal/background: ConfigureChildProcessGroup (process-group setup) + TerminateProcess (cross-platform terminate) for worker kill/drain. The pool does its own os/exec and uses background only for those helpers. - internal/streamjson: the worker speaks stream-json on stdout; the daemon forwards those lines to clients. - internal/cli exec: a worker is `zero exec --output-format stream-json`. Pieces (reimplemented in Go from a reference daemon): - protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap, JSON control messages, version negotiation. - pool.go + backoff.go: bounded worker pool — at-least-once dispatch with exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/ EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is one-shot, so workers are on-demand (one session per worker = the lease), not warm long-lived slots (a persistent worker mode is a follow-up). - session.go: SessionManager — sessionID->worker routing, lease (one active request per worker), queue when the pool is full, per-session output buffer for attach + metrics. - server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop; cleans up lock/socket/status on exit. - launcher.go: production worker launcher. client.go: control-socket client. Security model: - Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the per-user profile ACL; a dedicated pipe ACL is a follow-up). - Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker re-establishes its OWN sandbox instead of inheriting a pass-through plan, while propagating the rest of the policy config/env. The daemon never bypasses the sandbox. - Control frames over the 1 MiB cap are rejected before allocation. Robustness: a crashed worker never takes down the daemon (backoff+jitter retry, MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then force-kills stragglers; a stale lock from an unclean exit is reclaimed. Out of scope (follow-up): remote session-linking; warm pre-spawned persistent workers; Windows named-pipe ACL. Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds; pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/ attach; lock single-instance + stale recovery; launcher env-scrub (incl. an end-to-end check that the spawned worker process receives scrubbed env); server e2e start->run->stream-json->status->attach->stop with a fake worker; CLI usage/validation/not-running paths. Live-verified start/status/stop (0600 socket, 0700 dir, file cleanup). Platform files behind explicit //go:build tags. * daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions - cli/app.go: list `daemon` in `zero --help` so the command is discoverable. - cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than one session id (fail fast on typos instead of silently ignoring tokens). - daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would leave a malformed lock file that another process reads as stale and wrongly reclaims, breaking the single-instance guarantee; on write failure remove the file and return the error. - daemon/session: bound the registry with MaxSessions (default 256) — evict the oldest FINISHED sessions once past the cap so a long-running daemon doesn't accumulate sessions without limit; running/queued sessions are never evicted, so attach-after-finish for recent sessions is preserved. Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted. Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0), deadcode (no new), GOOS=linux+windows cross-compile.
177 lines
4.8 KiB
Go
177 lines
4.8 KiB
Go
package daemon
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
paths := Paths{
|
|
Socket: filepath.Join(dir, "d.sock"),
|
|
Lock: filepath.Join(dir, "d.lock"),
|
|
Status: filepath.Join(dir, "d.status"),
|
|
}
|
|
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("NewPool: %v", err)
|
|
}
|
|
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager: %v", err)
|
|
}
|
|
srv, err := NewServer(ServerOptions{Paths: paths, Manager: mgr, Pool: pool})
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
return srv, paths
|
|
}
|
|
|
|
func waitForFile(t *testing.T, path string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
t.Fatalf("file %s did not appear within timeout", path)
|
|
}
|
|
|
|
func TestServerEndToEnd(t *testing.T) {
|
|
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
|
|
srv, paths := newTestServer(t, launcher)
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv.Serve() }()
|
|
waitForFile(t, paths.Status)
|
|
|
|
// --- run a session and collect its stream-json output ---
|
|
runClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(run): %v", err)
|
|
}
|
|
var got []string
|
|
if err := runClient.Run("s1", "", "hello", nil, func(line string) { got = append(got, line) }); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
runClient.Close()
|
|
if len(got) != 2 || got[0] != out[0] || got[1] != out[1] {
|
|
t.Fatalf("streamed lines = %v, want %v", got, out)
|
|
}
|
|
|
|
// --- status reflects the session ---
|
|
statusClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(status): %v", err)
|
|
}
|
|
report, err := statusClient.Status()
|
|
statusClient.Close()
|
|
if err != nil {
|
|
t.Fatalf("Status: %v", err)
|
|
}
|
|
if report.PoolSize != 2 {
|
|
t.Fatalf("status PoolSize = %d, want 2", report.PoolSize)
|
|
}
|
|
foundSession := false
|
|
for _, s := range report.Sessions {
|
|
if s.ID == "s1" {
|
|
foundSession = true
|
|
if s.State != string(SessionDone) {
|
|
t.Fatalf("session s1 state = %s, want done", s.State)
|
|
}
|
|
}
|
|
}
|
|
if !foundSession {
|
|
t.Fatalf("status missing session s1: %+v", report.Sessions)
|
|
}
|
|
|
|
// --- attach to the finished session sees the buffered history ---
|
|
attachClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(attach): %v", err)
|
|
}
|
|
var attached []string
|
|
if err := attachClient.Attach("s1", func(line string) { attached = append(attached, line) }); err != nil {
|
|
t.Fatalf("Attach: %v", err)
|
|
}
|
|
attachClient.Close()
|
|
if len(attached) != 2 {
|
|
t.Fatalf("attach lines = %v, want 2 buffered", attached)
|
|
}
|
|
|
|
// --- graceful stop, daemon cleans up ---
|
|
stopClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(stop): %v", err)
|
|
}
|
|
if err := stopClient.Shutdown(); err != nil {
|
|
t.Fatalf("Shutdown: %v", err)
|
|
}
|
|
stopClient.Close()
|
|
|
|
select {
|
|
case err := <-serveErr:
|
|
if err != nil {
|
|
t.Fatalf("Serve returned error: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Serve did not return after shutdown")
|
|
}
|
|
// Socket, status, and lock files are removed on exit.
|
|
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
|
|
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerSecondInstanceFails(t *testing.T) {
|
|
block := make(chan struct{})
|
|
defer close(block)
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
|
srv1, paths := newTestServer(t, launcher)
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv1.Serve() }()
|
|
waitForFile(t, paths.Status)
|
|
|
|
// A second server on the same paths must refuse to start (single instance).
|
|
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
|
mgr2, _ := NewSessionManager(SessionManagerOptions{Pool: pool2})
|
|
srv2, _ := NewServer(ServerOptions{Paths: paths, Manager: mgr2, Pool: pool2})
|
|
if err := srv2.Serve(); !errors.Is(err, ErrAlreadyRunning) {
|
|
t.Fatalf("second Serve err = %v, want ErrAlreadyRunning", err)
|
|
}
|
|
|
|
srv1.Shutdown()
|
|
<-serveErr
|
|
}
|
|
|
|
func TestServerRejectsUnknownCommand(t *testing.T) {
|
|
block := make(chan struct{})
|
|
defer close(block)
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
|
srv, paths := newTestServer(t, launcher)
|
|
go func() { _ = srv.Serve() }()
|
|
defer srv.Shutdown()
|
|
waitForFile(t, paths.Status)
|
|
|
|
client, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer client.Close()
|
|
// Run with an empty session id is rejected with an error frame.
|
|
err = client.Run("", "", "hi", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("run with empty session id must return an error")
|
|
}
|
|
}
|