mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -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.
121 lines
3.4 KiB
Go
121 lines
3.4 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFrameRoundTrip(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
payloads := [][]byte{[]byte("hello"), {}, []byte(strings.Repeat("x", 4096))}
|
|
for _, p := range payloads {
|
|
if err := WriteFrame(&buf, KindCtrl, p); err != nil {
|
|
t.Fatalf("WriteFrame: %v", err)
|
|
}
|
|
}
|
|
for i, want := range payloads {
|
|
kind, got, err := ReadFrame(&buf)
|
|
if err != nil {
|
|
t.Fatalf("ReadFrame[%d]: %v", i, err)
|
|
}
|
|
if kind != KindCtrl {
|
|
t.Fatalf("frame[%d] kind = %d, want KindCtrl", i, kind)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatalf("frame[%d] payload = %q, want %q", i, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestControlRoundTrip(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
msg := Ctrl{Type: CtrlRun, Session: "s1", Cwd: "/tmp", Args: []string{"--prompt", "hi"}}
|
|
if err := WriteControl(&buf, msg); err != nil {
|
|
t.Fatalf("WriteControl: %v", err)
|
|
}
|
|
got, err := ReadControl(&buf)
|
|
if err != nil {
|
|
t.Fatalf("ReadControl: %v", err)
|
|
}
|
|
if got.Type != CtrlRun || got.Session != "s1" || got.Cwd != "/tmp" || len(got.Args) != 2 {
|
|
t.Fatalf("round-trip mismatch: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteFrameRejectsOversize(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
err := WriteFrame(&buf, KindCtrl, make([]byte, MaxFrameSize+1))
|
|
if !errors.Is(err, ErrFrameTooLarge) {
|
|
t.Fatalf("WriteFrame oversize err = %v, want ErrFrameTooLarge", err)
|
|
}
|
|
if buf.Len() != 0 {
|
|
t.Fatalf("oversize WriteFrame must not emit any bytes, wrote %d", buf.Len())
|
|
}
|
|
}
|
|
|
|
func TestReadFrameRejectsOversizeBeforeAlloc(t *testing.T) {
|
|
// A header advertising a 4 GiB payload must be rejected without reading or
|
|
// allocating that payload.
|
|
header := make([]byte, frameHeaderSize)
|
|
binary.BigEndian.PutUint32(header[:4], MaxFrameSize+1)
|
|
header[4] = byte(KindCtrl)
|
|
_, _, err := ReadFrame(bytes.NewReader(header))
|
|
if !errors.Is(err, ErrFrameTooLarge) {
|
|
t.Fatalf("ReadFrame oversize err = %v, want ErrFrameTooLarge", err)
|
|
}
|
|
}
|
|
|
|
func TestReadFrameUnknownKind(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
// Hand-write a frame with an unknown kind byte (7) and a small payload.
|
|
header := make([]byte, frameHeaderSize)
|
|
binary.BigEndian.PutUint32(header[:4], 2)
|
|
header[4] = 7
|
|
buf.Write(header)
|
|
buf.Write([]byte("ab"))
|
|
_, _, err := ReadFrame(&buf)
|
|
if !errors.Is(err, ErrUnknownFrameKind) {
|
|
t.Fatalf("unknown kind err = %v, want ErrUnknownFrameKind", err)
|
|
}
|
|
}
|
|
|
|
func TestReadFrameTruncated(t *testing.T) {
|
|
// Header promises 10 bytes but only 3 follow → unexpected EOF.
|
|
var buf bytes.Buffer
|
|
header := make([]byte, frameHeaderSize)
|
|
binary.BigEndian.PutUint32(header[:4], 10)
|
|
header[4] = byte(KindCtrl)
|
|
buf.Write(header)
|
|
buf.Write([]byte("abc"))
|
|
_, _, err := ReadFrame(&buf)
|
|
if err == nil || errors.Is(err, ErrFrameTooLarge) {
|
|
t.Fatalf("truncated frame err = %v, want a read error", err)
|
|
}
|
|
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("truncated frame err = %v, want io.ErrUnexpectedEOF", err)
|
|
}
|
|
}
|
|
|
|
func TestNegotiateVersion(t *testing.T) {
|
|
cases := []struct {
|
|
client int
|
|
want int
|
|
wantOK bool
|
|
}{
|
|
{client: 1, want: 1, wantOK: true},
|
|
{client: 99, want: ProtoVersion, wantOK: true}, // clamp down to ours
|
|
{client: 0, want: 0, wantOK: false},
|
|
{client: -3, want: 0, wantOK: false},
|
|
}
|
|
for _, tc := range cases {
|
|
got, ok := NegotiateVersion(tc.client)
|
|
if got != tc.want || ok != tc.wantOK {
|
|
t.Fatalf("NegotiateVersion(%d) = (%d,%v), want (%d,%v)", tc.client, got, ok, tc.want, tc.wantOK)
|
|
}
|
|
}
|
|
}
|