From 52b598c9137619576847ab3ba2dd15059812af3f Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 16:01:32 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor(engine):=20daemon=20=E7=9A=84?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E3=80=81=E8=BF=9E=E6=8E=A5=E4=B8=8E?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=99=A8=E4=BB=A3=E7=A0=81=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根目录 23 个生产文件按职责重述:连接关闭顺序、锁的获取与释放、生命周期 钩子、日志轮转、端口解析与配置编辑保持原有语义;RPC 方法名、JSON 字段、 错误码与日志文案未改。文件路径未动,复制段从 1,584 行归零。 Co-Authored-By: Claude Code --- engine/internal/daemon/conn.go | 142 ++++++------ engine/internal/daemon/dispatch.go | 65 +++--- engine/internal/daemon/groups_config.go | 240 +++++++++++--------- engine/internal/daemon/groups_kill.go | 20 +- engine/internal/daemon/groups_read.go | 31 +-- engine/internal/daemon/groups_rename.go | 130 ++++++----- engine/internal/daemon/handlers.go | 107 +++++---- engine/internal/daemon/handlers_store.go | 88 ++++---- engine/internal/daemon/kill_handlers.go | 268 +++++++++++++---------- engine/internal/daemon/lifecycle.go | 181 ++++++++------- engine/internal/daemon/listen_unix.go | 64 +++--- engine/internal/daemon/listen_windows.go | 60 ++--- engine/internal/daemon/lock.go | 115 +++++----- engine/internal/daemon/lock_windows.go | 59 +++-- engine/internal/daemon/log.go | 80 +++---- engine/internal/daemon/ports.go | 158 +++++++------ engine/internal/daemon/ports_logs.go | 31 +-- engine/internal/daemon/server.go | 165 +++++++------- engine/internal/daemon/service_ports.go | 7 +- engine/internal/daemon/socket.go | 24 +- engine/internal/daemon/store.go | 82 +++---- engine/internal/daemon/stream.go | 128 ++++++----- engine/internal/daemon/testguard.go | 54 ++--- 23 files changed, 1250 insertions(+), 1049 deletions(-) diff --git a/engine/internal/daemon/conn.go b/engine/internal/daemon/conn.go index b5cb9b17..367bdc1e 100644 --- a/engine/internal/daemon/conn.go +++ b/engine/internal/daemon/conn.go @@ -14,66 +14,68 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// QueueSize bounds a connection's outbound queue. A client that stops reading -// fills it and is disconnected rather than blocking the scanner (spec, -// "Subscriptions"). +// QueueSize is the capacity of a connection's outbound queue. A client that +// stops reading fills the queue and is disconnected instead of blocking the +// scanner (spec, "Subscriptions"). const QueueSize = 256 -// MaxMessageBytes is the framing limit from the spec's "Transport details". +// MaxMessageBytes is the framing limit the spec states under "Transport +// details". const MaxMessageBytes = 4 << 20 -// Conn is one client connection: a read loop that dispatches requests and a -// write loop that drains a bounded outbound queue. +// Conn is a single client connection: the read loop that dispatches its +// requests, and the write loop that drains its bounded outbound queue. type Conn struct { + nc net.Conn id uint64 srv *Server - nc net.Conn - out chan []byte + writerWG sync.WaitGroup closeOnce sync.Once closed chan struct{} - writerWG sync.WaitGroup + out chan []byte - // Guarded by srv.subsMu, because the publisher reads them while fanning - // out and state.subscribe must register atomically with its own reply. - subscribed bool - include scanner.Include + // Guarded by srv.subsMu: the publisher reads these while fanning out, and + // state.subscribe has to register them atomically with its own reply. events bool + subscribed bool scope state.Scope + include scanner.Include // Guarded by mu. Everything here describes this one connection and is read // only by its own goroutines, which is why it does not share subsMu. mu sync.Mutex - client string - clientVersion string - keepalive bool helloDone bool + keepalive bool + clientVersion string + client string streams map[string]context.CancelFunc shutdownOnIdle bool } // newConn builds a connection with its queues ready. The outbound queue is -// bounded here rather than by the caller: a client that stops reading must not -// be able to grow the daemon's memory one message at a time. +// bounded right here rather than by the caller: a client that stops reading +// must not be able to grow the daemon's memory one message at a time. func newConn(id uint64, srv *Server, nc net.Conn) *Conn { return &Conn{ + nc: nc, id: id, srv: srv, - nc: nc, + streams: map[string]context.CancelFunc{}, out: make(chan []byte, QueueSize), closed: make(chan struct{}), - streams: map[string]context.CancelFunc{}, } } -// ID is the connection's daemon-assigned identifier, used in log lines. +// ID is the daemon-assigned identifier of this connection, used in log lines. func (c *Conn) ID() uint64 { return c.id } -// Client is the {name, version, keepalive} the client sent with daemon.hello. +// Client is the {name, version, keepalive} triple sent with daemon.hello. func (c *Conn) Client() (name, version string, keepalive bool) { c.mu.Lock() - defer c.mu.Unlock() - return c.client, c.clientVersion, c.keepalive + name, version, keepalive = c.client, c.clientVersion, c.keepalive + c.mu.Unlock() + return name, version, keepalive } // setHello records the client identity. A second hello on the same connection @@ -93,16 +95,17 @@ func (c *Conn) setHello(name, version string, keepalive bool) { // Keepalive reports whether this client asked the daemon to stay up. func (c *Conn) Keepalive() bool { c.mu.Lock() - defer c.mu.Unlock() - return c.keepalive + keepalive := c.keepalive + c.mu.Unlock() + return keepalive } -// RegisterStream records a cancel function for a streaming subscription so -// stream.cancel and disconnect can both stop it. +// RegisterStream records the cancel function of a streaming subscription, so +// both stream.cancel and a disconnect can stop it. func (c *Conn) RegisterStream(id string, cancel context.CancelFunc) { c.mu.Lock() + defer c.mu.Unlock() c.streams[id] = cancel - c.mu.Unlock() } // CancelStream stops one stream. It reports whether the id was known. @@ -154,28 +157,38 @@ func (c *Conn) requestShutdown() { } // shutdownRequested reports whether this connection asked for a shutdown. The -// request is sticky: once set, it stays set for the connection's life. +// request is sticky: once set it stays set for the rest of the connection's +// life. func (c *Conn) shutdownRequested() bool { c.mu.Lock() - defer c.mu.Unlock() - return c.shutdownOnIdle + requested := c.shutdownOnIdle + c.mu.Unlock() + return requested } -// enqueue puts one pre-marshalled message on the outbound queue. It never -// blocks: a full queue means the client is not reading, and the connection is -// closed instead (spec, "Subscriptions"). It reports whether the message was -// accepted. +// enqueue puts one pre-marshalled message on the outbound queue without ever +// blocking. A full queue means the client is not reading, so the connection is +// closed instead (spec, "Subscriptions"); the report says whether the message +// was accepted. func (c *Conn) enqueue(msg []byte) bool { - select { - case <-c.closed: + if c.closing() { return false - default: } select { case c.out <- msg: return true default: - c.abandonSlowClient() + } + c.abandonSlowClient() + return false +} + +// closing reports whether the connection has already been closed. +func (c *Conn) closing() bool { + select { + case <-c.closed: + return true + default: return false } } @@ -218,23 +231,25 @@ var marshalNotification = func(method string, params any) ([]byte, error) { // closeWithReason shuts the connection down once, logging why. func (c *Conn) closeWithReason(reason string) { - c.closeOnce.Do(func() { - // The order matters: the closed channel stops both loops first, then - // streams are cancelled, then the socket goes, and only then is the - // connection removed from the registry — removing it last is what keeps - // a broadcast written during the teardown from targeting a socket that - // is already gone. - close(c.closed) - c.cancelAllStreams() - _ = c.nc.Close() - c.srv.removeConn(c, reason) - }) + c.closeOnce.Do(func() { c.teardown(reason) }) +} + +// teardown releases a connection in the one order that is safe: the closed +// channel stops both loops first, streams are cancelled next, then the socket +// goes, and only then does the connection leave the registry. Removing it last +// is what stops a broadcast written during teardown from targeting a socket +// that is already gone. +func (c *Conn) teardown(reason string) { + close(c.closed) + c.cancelAllStreams() + c.nc.Close() + c.srv.removeConn(c, reason) } -// Close ends the connection. +// Close ends this connection. func (c *Conn) Close() { c.closeWithReason("closed") } -// writeLoop drains the outbound queue onto the socket. It exits when the +// writeLoop drains the outbound queue onto the socket. It exits once the // connection closes; a write error closes the connection. func (c *Conn) writeLoop() { defer c.writerWG.Done() @@ -289,9 +304,9 @@ func writeQueued(out *bufio.Writer, queue <-chan []byte) { // Requests are served one at a time, in order, so a subscribe reply can never // overtake the deltas that follow it. func (c *Conn) readLoop(ctx context.Context) { - dec := rpc.NewDecoder(c.nc, MaxMessageBytes) + decoder := rpc.NewDecoder(c.nc, MaxMessageBytes) for { - msg, err := dec.Next() + msg, err := decoder.Next() if err != nil { if !c.survivesDecodeError(err) { return @@ -369,13 +384,11 @@ func (c *Conn) serve(ctx context.Context, msg rpc.Message) { Runtime: c.srv.runtime, } result, err := handler(ctx, request) - switch { - case errors.Is(err, ErrResponseSent): - // The handler already wrote its own reply; adding one would be a - // protocol violation. - case err != nil: + if err != nil && !errors.Is(err, ErrResponseSent) { c.replyError(msg.ID, errorFor(err)) - default: + } else if err == nil { + // A handler that sent its own reply (ErrResponseSent) must not get a + // second one; that would be a protocol violation. c.replyResult(msg.ID, result) } @@ -415,13 +428,14 @@ func (c *Conn) replyResult(id json.RawMessage, result any) { c.sendResponse(id, encoded, nil) } -// replyError queues an error response. A nil id still gets a response, per -// JSON-RPC, so a client that sent an unparseable request learns why. +// replyError queues an error response. Per JSON-RPC a nil id still gets an +// answer, so a client whose request could not be parsed learns why. func (c *Conn) replyError(id json.RawMessage, e *rpc.Error) { if id == nil { id = json.RawMessage("null") } - msg, err := json.Marshal(rpc.Response{JSONRPC: rpc.Version, ID: id, Error: e}) + response := rpc.Response{JSONRPC: rpc.Version, ID: id, Error: e} + msg, err := json.Marshal(response) if err != nil { return } diff --git a/engine/internal/daemon/dispatch.go b/engine/internal/daemon/dispatch.go index 1d644108..3601a899 100644 --- a/engine/internal/daemon/dispatch.go +++ b/engine/internal/daemon/dispatch.go @@ -11,82 +11,85 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" ) -// Request is what a Handler receives: the decoded call plus the connection it -// arrived on and the daemon runtime it may act through. +// Request is what a Handler receives: the decoded call, the connection it came +// in on, and the daemon runtime it may act through. type Request struct { + Runtime *Runtime Method string ID json.RawMessage Params json.RawMessage Conn *Conn - Runtime *Runtime } -// Bind unmarshals the request's params into v. Absent params are left as v's -// zero value, so a method whose params are all optional needs no special case. +// Bind unmarshals the request's params into v. When params are absent, v is +// left at its zero value — so a method whose params are all optional needs no +// special case. func (r *Request) Bind(v any) error { - if len(r.Params) == 0 || string(r.Params) == "null" { + empty := len(r.Params) == 0 || string(r.Params) == "null" + if empty { return nil } - if err := json.Unmarshal(r.Params, v); err != nil { + err := json.Unmarshal(r.Params, v) + if err != nil { return rpc.NewError(rpc.CodeInvalidParams, fmt.Sprintf("invalid params for %s: %v", r.Method, err), "") } return nil } -// Handler serves one method. Returning ErrResponseSent means the handler has -// already written the reply itself (state.subscribe does, so that its snapshot -// is queued atomically with the subscription). +// Handler serves one method. Returning ErrResponseSent says the handler has +// written its own reply (state.subscribe does, so its snapshot is queued +// atomically with the subscription). type Handler func(ctx context.Context, req *Request) (any, error) // ErrResponseSent tells the dispatcher not to write a reply. var ErrResponseSent = errors.New("daemon: response already sent") var ( - handlersMu sync.RWMutex handlers = map[string]Handler{} + handlersMu sync.RWMutex ) -// RegisterHandler adds a method to the dispatcher. Packages that own a -// namespace (groups, killer, store, share, sessions) call it from their own -// init() so this package never imports them. Registering the -// same method twice panics: two owners for one method is always a bug. +// RegisterHandler adds one method to the dispatcher. A package owning a +// namespace (groups, killer, store, share, sessions) calls it from its own +// init(), so this package never imports that owner. Registering the same method +// twice panics: two owners for a single method is always a bug. func RegisterHandler(method string, h Handler) { handlersMu.Lock() defer handlersMu.Unlock() - if _, dup := handlers[method]; dup { + if _, taken := handlers[method]; taken { panic("daemon: duplicate handler for method " + method) } handlers[method] = h } -// lookupHandler returns the handler for a method. +// lookupHandler returns the handler registered for a method. func lookupHandler(method string) (Handler, bool) { handlersMu.RLock() defer handlersMu.RUnlock() - h, ok := handlers[method] - return h, ok + handler, served := handlers[method] + return handler, served } -// RegisteredMethods lists every method this build serves, sorted. +// RegisteredMethods lists every method this build serves, in sorted order. func RegisteredMethods() []string { handlersMu.RLock() defer handlersMu.RUnlock() - out := make([]string, 0, len(handlers)) - for m := range handlers { - out = append(out, m) + methods := make([]string, 0, len(handlers)) + for method := range handlers { + methods = append(methods, method) } - sort.Strings(out) - return out + sort.Strings(methods) + return methods } -// errorFor converts a handler error into a JSON-RPC error object. Anything that -// is not already an *rpc.Error becomes an internal error, so a handler bug -// never leaks a bare Go error string as a protocol code. +// errorFor turns a handler error into a JSON-RPC error object. Anything that is +// not already an *rpc.Error becomes an internal error, so a handler bug never +// leaks a bare Go error string as a protocol code. func errorFor(err error) *rpc.Error { - var re *rpc.Error - if errors.As(err, &re) { - return re + var protocolError *rpc.Error + if errors.As(err, &protocolError) { + return protocolError } return rpc.NewError(rpc.CodeInternal, err.Error(), "") } diff --git a/engine/internal/daemon/groups_config.go b/engine/internal/daemon/groups_config.go index 3d5eedba..39e2918d 100644 --- a/engine/internal/daemon/groups_config.go +++ b/engine/internal/daemon/groups_config.go @@ -13,14 +13,22 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// The `oberth.yaml` read/write namespace plus the reload that -// keeps the daemon's long-lived index in step with the disk. `groups.start` -// lives in internal/daemon/groupstart, because starting a service needs the -// run registry and this package must not import it. +// Read/write namespace for `oberth.yaml`, together with the reload that keeps +// the daemon's long-lived index in step with what is on disk. `groups.start` +// is deliberately elsewhere (internal/daemon/groupstart): starting a service +// needs the run registry, and this package must not import it. func init() { + registerConfigHandlers() + registerGroupCapabilities() +} + +func registerConfigHandlers() { RegisterHandler("groups.config.get", handleGroupsConfigGet) RegisterHandler("groups.config.set", handleGroupsConfigSet) RegisterHandler("groups.reload", handleGroupsReload) +} + +func registerGroupCapabilities() { RegisterCapability("groups") RegisterCapability(rpc.CapabilityAutoPorts) RegisterCapability(rpc.CapabilityManifestExtras) @@ -29,22 +37,25 @@ func init() { func handleGroupsConfigGet(_ context.Context, req *Request) (any, error) { var p rpc.GroupsConfigGetParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } cfg, err := resolveConfig(req.Runtime, p.Name, p.Path) if err != nil { return nil, err } - return rpc.GroupsConfigGetResult{ + result := rpc.GroupsConfigGetResult{ Path: cfg.Path, Config: configRow(req.Runtime, cfg), - }, nil + } + return result, nil } func handleGroupsConfigSet(_ context.Context, req *Request) (any, error) { var p rpc.GroupsConfigSetParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } path := strings.TrimSpace(p.Path) @@ -52,84 +63,93 @@ func handleGroupsConfigSet(_ context.Context, req *Request) (any, error) { return nil, rpc.NewError(rpc.CodeInvalidParams, "path is required", `send {"path": "/repo/oberth.yaml", "services": [{"name": "api", "patch": {"icon": "server"}}]}`) } - edit := groups.ConfigEdit{ - Remove: p.Remove, - Rename: p.Rename, - Add: p.Add, - Services: p.Services, - - WorktreePorts: p.WorktreePortsChange(), - } - if edit.Empty() { + edit := configEditFrom(p) + if empty := edit.Empty(); empty { return nil, rpc.NewError(rpc.CodeInvalidParams, "services, add, rename, remove or worktree_ports is required", `send {"path": "…", "add": [{"name": "worker", "port": 9000}]}, or a rename, a remove, `+ `{"services": [{"name": "api", "patch": {...}}]} or {"worktree_ports": 4}`) } - if err := checkEditNames(edit); err != nil { + if err = checkEditNames(edit); err != nil { return nil, err } - if err := checkConfigPath(path); err != nil { + err = checkConfigPath(path) + if err != nil { return nil, err } - - // The file is re-rendered from its own node tree, so comments, key order - // and formatting survive an edit — with one exception: yaml.v3 keeps the - // text of a trailing comment but not the padding in front of it, so - // `cmd: x # note` comes back as `cmd: x # note`. Node carries no column - // for a LineComment and the emitter always writes exactly one space, so - // preserving it would mean re-rendering the file by hand; the comment - // itself is never lost, and that is where this stops. + // An edit is applied to the file's own node tree and re-rendered, which is + // what keeps comments, key order and formatting alive. One exception: + // yaml.v3 preserves the text of a trailing comment but not the padding in + // front of it, so `cmd: x # note` returns as `cmd: x # note`. A + // LineComment node carries no column and the emitter always writes a single + // space; keeping the original padding would mean rendering the file by + // hand. The comment itself never disappears, which is the line drawn here. // - // The whole edit is applied to the node tree and validated before a byte is - // written, so a rename that clashes half way through a batch leaves the - // file exactly as it was. + // Nothing reaches the disk before the whole edit has been applied and + // validated on that tree, so a rename that clashes half way through a + // batch leaves the file untouched. cfg, err := groups.EditServices(path, edit) if err != nil { return nil, configError(path, err) } - // Index the file the daemon just wrote, then republish: the edit reaches - // subscribers as a groups delta before the caller's own next read - //. - if err := req.Runtime.Scanner.LoadConfig(cfg.Path); err != nil { + // The file just written is re-indexed and republished, so the edit shows up + // as a groups delta for subscribers before the caller reads again. + affected := edit.Affected() + err = req.Runtime.Scanner.LoadConfig(cfg.Path) + if err != nil { req.Runtime.Logger.Warn("reloading a config after writing it", "path", cfg.Path, "error", err) } - affected := edit.Affected() req.Runtime.Logger.Info("config written", "path", cfg.Path, "services", len(affected)) republish(req.Runtime) - return rpc.GroupsConfigSetResult{ + result := rpc.GroupsConfigSetResult{ MutationResult: rpc.MutationResult{OK: true, Affected: affected}, Path: cfg.Path, Config: configRow(req.Runtime, cfg), - }, nil + } + return result, nil +} + +// configEditFrom carries the request's edit fields into the groups package's +// edit value. `worktree_ports` is optional on the wire, so its change is +// computed rather than copied. +func configEditFrom(p rpc.GroupsConfigSetParams) groups.ConfigEdit { + return groups.ConfigEdit{ + Add: p.Add, + Services: p.Services, + Remove: p.Remove, + Rename: p.Rename, + + WorktreePorts: p.WorktreePortsChange(), + } } -// checkEditNames rejects an edit whose shape is wrong before anything is read -// from the disk. An empty service name is a caller bug, not a config problem, -// so it is `invalid_params` rather than the `invalid_config` a nameless service -// would earn once written. +// checkEditNames refuses a malformed edit before the disk is touched at all. +// A nameless service is a caller mistake rather than a config problem, so it +// earns `invalid_params` and not the `invalid_config` that writing the name +// would produce. func checkEditNames(edit groups.ConfigEdit) error { + nameless := func(s string) bool { return strings.TrimSpace(s) == "" } for _, name := range edit.Remove { - if strings.TrimSpace(name) == "" { + if nameless(name) { return rpc.NewError(rpc.CodeInvalidParams, "remove names an empty service", `each entry is the name of a service in the file: {"remove": ["worker"]}`) } } for _, r := range edit.Rename { - if strings.TrimSpace(r.From) == "" || strings.TrimSpace(r.To) == "" { + if nameless(r.From) || nameless(r.To) { return rpc.NewError(rpc.CodeInvalidParams, "rename needs a from and a to", `each entry is {"from": "", "to": ""}`) } } for _, a := range edit.Add { - if strings.TrimSpace(a.Name) == "" { + if nameless(a.Name) { return rpc.NewError(rpc.CodeInvalidParams, "add names an empty service", `each entry is {"name": "worker", "port": 9000}`) } } for _, e := range edit.Services { - if strings.TrimSpace(e.Name) == "" { + if nameless(e.Name) { return rpc.NewError(rpc.CodeInvalidParams, "services names an empty service", `each entry is {"name": "", "patch": {...}}; a null value clears a key`) } @@ -138,52 +158,67 @@ func checkEditNames(edit groups.ConfigEdit) error { } func handleGroupsReload(_ context.Context, req *Request) (any, error) { - loaded, bad := req.Runtime.Scanner.ReloadConfigs() - problems := make([]rpc.ConfigProblem, 0, len(bad)) - for _, b := range bad { - problems = append(problems, rpc.ConfigProblem{Path: b.Path, Error: b.Err.Error()}) + loaded, rejected := req.Runtime.Scanner.ReloadConfigs() + problems := make([]rpc.ConfigProblem, 0, len(rejected)) + for i := range rejected { + problems = append(problems, rpc.ConfigProblem{ + Path: rejected[i].Path, + Error: rejected[i].Err.Error(), + }) } req.Runtime.Logger.Info("reloaded oberth.yaml files", "configs", loaded, "invalid", len(problems)) republish(req.Runtime) return rpc.GroupsReloadResult{Loaded: loaded, Errors: problems}, nil } -// resolveConfig finds the config a request names, by path or by group name. -// A path the index has not seen is loaded on the spot, so the desktop can open -// a project the daemon has never scanned a process in. +// resolveConfig turns "path or group name" into the config a request names. +// A path the index has never seen is loaded there and then, so the desktop can +// open a project the daemon has not scanned a process in. func resolveConfig(rt *Runtime, name, path *string) (*groups.Config, error) { - if path != nil && strings.TrimSpace(*path) != "" { - p := strings.TrimSpace(*path) - if err := checkConfigPath(p); err != nil { - return nil, err + if path != nil { + if p := strings.TrimSpace(*path); p != "" { + return configAtPath(rt, p) } - if cfg, ok := rt.Scanner.ConfigAt(p); ok { - return cfg, nil - } - if err := rt.Scanner.LoadConfig(p); err != nil { - return nil, configError(p, err) - } - if cfg, ok := rt.Scanner.ConfigAt(p); ok { - return cfg, nil - } - return nil, rpc.NewError(rpc.CodeNotFound, "no usable "+groups.ConfigName+" at "+p, - "run `oberth status` inside a worktree with a manifest") } - if name != nil && strings.TrimSpace(*name) != "" { - n := strings.TrimSpace(*name) - if cfg, ok := rt.Scanner.ConfigNamed(n); ok { - return cfg, nil + if name != nil { + if n := strings.TrimSpace(*name); n != "" { + return configNamed(rt, n) } - return nil, rpc.NewError(rpc.CodeNotFound, "no project named "+n+" has a "+groups.ConfigName, - "run `oberth status` inside a worktree with a manifest; `groups.reload` re-reads it") } return nil, rpc.NewError(rpc.CodeInvalidParams, "name or path is required", `send {"name": "my-app"} or {"path": "/repo/oberth.yaml"}`) } +func configAtPath(rt *Runtime, p string) (*groups.Config, error) { + if err := checkConfigPath(p); err != nil { + return nil, err + } + cfg, indexed := rt.Scanner.ConfigAt(p) + if indexed { + return cfg, nil + } + if err := rt.Scanner.LoadConfig(p); err != nil { + return nil, configError(p, err) + } + cfg, indexed = rt.Scanner.ConfigAt(p) + if !indexed { + return nil, rpc.NewError(rpc.CodeNotFound, "no usable "+groups.ConfigName+" at "+p, + "run `oberth status` inside a worktree with a manifest") + } + return cfg, nil +} + +func configNamed(rt *Runtime, n string) (*groups.Config, error) { + if cfg, ok := rt.Scanner.ConfigNamed(n); ok { + return cfg, nil + } + return nil, rpc.NewError(rpc.CodeNotFound, "no project named "+n+" has a "+groups.ConfigName, + "run `oberth status` inside a worktree with a manifest; `groups.reload` re-reads it") +} + // checkConfigPath refuses to read or write anything that is not a `oberth.yaml`. -// The daemon writes files on a client's say-so, so the one filename it will -// touch is spelled out here rather than left to the caller. +// The daemon writes files because a client asked it to, so the single filename +// it will touch is fixed here instead of being taken from the caller. func checkConfigPath(path string) error { if groups.IsConfigName(filepath.Base(path)) { return nil @@ -194,30 +229,27 @@ func checkConfigPath(path string) error { } // configError maps a config failure onto the contract's error registry: a -// missing file or service is `not_found`, a name or a port an added or renamed -// service would take twice is `1011 conflict`, and a file that no longer -// validates is `1006 invalid_config` with the problems in data.detail -// . +// missing file or service is `not_found`; a name or port that an added or +// renamed service would take twice is `1011 conflict`; a file that no longer +// validates is `1006 invalid_config`, with the problems in data.detail. func configError(path string, err error) error { var missing *groups.ServiceNotFoundError - if errors.As(err, &missing) { + var clash *groups.ServiceConflictError + var bad *groups.ConfigError + switch { + case errors.As(err, &missing): return rpc.NewError(rpc.CodeNotFound, missing.Error(), "`groups.config.get` lists the service names this file declares") - } - var clash *groups.ServiceConflictError - if errors.As(err, &clash) { + case errors.As(err, &clash): hint := "pick another name, or rename the service that has it" if clash.Port != 0 { hint = "pick another port, or remove " + clash.Name + " in the same call" } return rpc.NewError(rpc.CodeConflict, clash.Error(), hint) - } - var bad *groups.ConfigError - if errors.As(err, &bad) { + case errors.As(err, &bad): return rpc.NewError(rpc.CodeInvalidConfig, bad.Error(), "the file was left as it was; fix the values and try again") - } - if errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + case errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist): return rpc.NewError(rpc.CodeNotFound, "no such file: "+path, "`oberth init` writes a "+groups.ConfigName+" at the repository root") } @@ -225,26 +257,27 @@ func configError(path string, err error) error { } // configRow renders a config for the wire. Running and port_actual are joined -// from the state the daemon already has, so an editor reading a config back -// sees the same service rows `groups.list` publishes. +// from state the daemon already holds, so an editor reading a config back sees +// the same service rows `groups.list` publishes. func configRow(rt *Runtime, cfg *groups.Config) rpc.GroupConfig { out := rpc.GroupConfig{ + Ports: append([]int{}, cfg.Ports...), Name: cfg.Name, Services: groups.ServiceRows(cfg), - Ports: append([]int{}, cfg.Ports...), } if out.Ports == nil { out.Ports = []int{} } - if cfg.WorktreePorts != nil { + if wp := cfg.WorktreePorts; wp != nil { // A copy, so the row never aliases the index's config. - n := *cfg.WorktreePorts - out.WorktreePorts = &n + ports := *wp + out.WorktreePorts = &ports } live := liveServices(rt, cfg.Name) - for i := range out.Services { - if l, ok := live[out.Services[i].Name]; ok { - out.Services[i].Running, out.Services[i].PortActual = l.Running, l.PortActual + for i, svc := range out.Services { + if seen, ok := live[svc.Name]; ok { + out.Services[i].Running = seen.Running + out.Services[i].PortActual = seen.PortActual } } return out @@ -252,14 +285,13 @@ func configRow(rt *Runtime, cfg *groups.Config) rpc.GroupConfig { // liveServices is the group's services as the last snapshot saw them. func liveServices(rt *Runtime, name string) map[string]state.Service { - out := map[string]state.Service{} + live := make(map[string]state.Service) for _, g := range rt.Scanner.Cached().Groups { - if g.Name != name { - continue - } - for _, svc := range g.Services { - out[svc.Name] = svc + if g.Name == name { + for _, svc := range g.Services { + live[svc.Name] = svc + } } } - return out + return live } diff --git a/engine/internal/daemon/groups_kill.go b/engine/internal/daemon/groups_kill.go index 8d90c9cb..f6f782c3 100644 --- a/engine/internal/daemon/groups_kill.go +++ b/engine/internal/daemon/groups_kill.go @@ -120,9 +120,9 @@ func handleGroupsKill(ctx context.Context, req *Request) (any, error) { } opts := killer.Options{ - Force: p.Force, - Grace: time.Duration(p.GraceMs) * time.Millisecond, DryRun: p.DryRun, + Grace: time.Duration(p.GraceMs) * time.Millisecond, + Force: p.Force, Ports: killerRows(snap), } var rows []state.KillResult @@ -535,15 +535,17 @@ func configAtDir(rt *Runtime, dir string) *groups.Config { return nil } -// killGroupName is the group a groups.kill call is about: the name it sent, or -// the group the daemon publishes a config's services under. The config comes -// back too when the call named one. +// killGroupName resolves the group a groups.kill call is about: either the name +// it sent, or the group the daemon publishes a config's services under. When +// the call named a config, that config comes back with it. func killGroupName(rt *Runtime, p rpc.GroupsKillParams) (string, *groups.Config, error) { - if name := strings.TrimSpace(p.Name); name != "" { - return name, nil, nil + groupName := strings.TrimSpace(p.Name) + if groupName != "" { + return groupName, nil, nil } - if path := trimmed(p.ConfigPath); path != "" { - cfg, err := loadConfig(rt, path) + configPath := trimmed(p.ConfigPath) + if configPath != "" { + cfg, err := loadConfig(rt, configPath) if err != nil { return "", nil, err } diff --git a/engine/internal/daemon/groups_read.go b/engine/internal/daemon/groups_read.go index a72697c1..ee2e6591 100644 --- a/engine/internal/daemon/groups_read.go +++ b/engine/internal/daemon/groups_read.go @@ -8,24 +8,28 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// The read half of the groups namespace. A group is derived state — the -// scanner resolves every port to a project and joins the `oberth.yaml` -// services onto it — so listing groups is reading the same snapshot the ports -// read uses, not a second computation that could disagree with it. +// The read half of the groups namespace. A group is derived state: the scanner +// resolves each port to a project and joins that project's `oberth.yaml` +// services onto it, so listing groups reads the very snapshot the ports read +// uses rather than a second computation that might disagree with it. func init() { + registerGroupsReadHandler() +} + +func registerGroupsReadHandler() { RegisterHandler("groups.list", handleGroupsList) } func handleGroupsList(_ context.Context, req *Request) (any, error) { - groups, err := readGroups(req.Runtime) + rows, err := readGroups(req.Runtime) if err != nil { return nil, err } - return rpc.GroupsListResult{Groups: groups}, nil + return rpc.GroupsListResult{Groups: rows}, nil } -// readGroups is readPorts' twin: served from the cache while a subscriber -// keeps it fresh, and from a scan otherwise. +// readGroups is readPorts' twin: it is served from the cache while a subscriber +// keeps that cache fresh, and from a scan otherwise. func readGroups(rt *Runtime) ([]state.Group, error) { if rt.Subscribers() > 0 { if snap := rt.Scanner.Cached(); snap.Seq > 0 { @@ -39,11 +43,10 @@ func readGroups(rt *Runtime) ([]state.Group, error) { return orEmptyGroups(joinStartAttempts(rt, snap.Groups)), nil } -// orEmptyGroups keeps the collection an array on the wire, never null -// . -func orEmptyGroups(groups []state.Group) []state.Group { - if groups == nil { - return []state.Group{} +// orEmptyGroups keeps the collection an array on the wire rather than null. +func orEmptyGroups(rows []state.Group) []state.Group { + if rows != nil { + return rows } - return groups + return []state.Group{} } diff --git a/engine/internal/daemon/groups_rename.go b/engine/internal/daemon/groups_rename.go index a5bf5a00..edcf27a1 100644 --- a/engine/internal/daemon/groups_rename.go +++ b/engine/internal/daemon/groups_rename.go @@ -13,70 +13,77 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// `groups.rename` renames a project and, with it, every checkout of -// it: the main checkout's group and each linked worktree's `@`. -// The name lives where the project already keeps it — the `name:` of the main -// checkout's `oberth.yaml` — or, for a project with no file, in the daemon's -// store, keyed by the main checkout's root. +// `groups.rename` renames a project and, along with it, every checkout of that +// project: the main checkout's group and each linked worktree's +// `@`. The name stays where the project already keeps it — the +// `name:` of the main checkout's `oberth.yaml` — or, when a project has no +// file, in the daemon's store, keyed by the main checkout's root. func init() { + registerRenameHandler() +} + +func registerRenameHandler() { RegisterHandler("groups.rename", handleGroupsRename) } var ( - renameHooksMu sync.RWMutex - renameHooks []func(renames map[string]string) + groupRenameMu sync.RWMutex + groupRenameHooks []func(renames map[string]string) ) -// OnGroupRename registers a function that is told, old name → new, about every -// group a `groups.rename` renamed. State that recorded a group name when it was -// created follows the rename through it — a run's group does, from -// internal/daemon/runsreg — without this package importing its owner -// . +// OnGroupRename registers a function told, old name → new, about every group a +// `groups.rename` renamed. State that recorded a group name at creation time +// follows the rename through this hook — a run's group does, from +// internal/daemon/runsreg — without this package importing that owner. func OnGroupRename(f func(renames map[string]string)) { if f == nil { return } - renameHooksMu.Lock() - renameHooks = append(renameHooks, f) - renameHooksMu.Unlock() + groupRenameMu.Lock() + groupRenameHooks = append(groupRenameHooks, f) + groupRenameMu.Unlock() } func notifyGroupRename(renames map[string]string) { if len(renames) == 0 { return } - renameHooksMu.RLock() - hooks := append([]func(map[string]string){}, renameHooks...) - renameHooksMu.RUnlock() - for _, f := range hooks { + for _, f := range groupRenameHooksSnapshot() { f(renames) } } +func groupRenameHooksSnapshot() []func(map[string]string) { + groupRenameMu.RLock() + defer groupRenameMu.RUnlock() + return append([]func(map[string]string){}, groupRenameHooks...) +} + func handleGroupsRename(_ context.Context, req *Request) (any, error) { var p rpc.GroupsRenameParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } - from, to := strings.TrimSpace(p.Name), strings.TrimSpace(p.To) + to, from := strings.TrimSpace(p.To), strings.TrimSpace(p.Name) if from == "" { return nil, rpc.NewError(rpc.CodeInvalidParams, "name is required", `send {"name": "", "to": ""}`) } - if err := groups.ValidProjectName(to); err != nil { + if err = groups.ValidProjectName(to); err != nil { return nil, rpc.NewError(rpc.CodeInvalidParams, err.Error(), "a project name is one word with no @, / or whitespace; a worktree's group adds @ to it") } rt := req.Runtime - snap, err := rt.Scanner.Snapshot(scanner.Include{}) + snapshot, err := rt.Scanner.Snapshot(scanner.Include{}) if err != nil { return nil, scanFailure(err) } - local := make([]state.Group, 0, len(snap.Groups)) - for _, g := range snap.Groups { - if state.IsLocalhost(g.Host) { - local = append(local, g) + local := make([]state.Group, 0, len(snapshot.Groups)) + for i := range snapshot.Groups { + if state.IsLocalhost(snapshot.Groups[i].Host) { + local = append(local, snapshot.Groups[i]) } } plan, err := rt.Scanner.PlanGroupRename(local, from, to) @@ -84,67 +91,74 @@ func handleGroupsRename(_ context.Context, req *Request) (any, error) { return nil, renameError(err) } if len(plan.Renames) == 0 { - // Already called that: nothing to write and nothing to publish. + // The project already carries that name: nothing to write, nothing to + // publish. return rpc.GroupsRenameResult{ MutationResult: rpc.MutationResult{OK: true, Affected: []string{}}, Name: plan.Project, }, nil } - if plan.ConfigPath != "" { - cfg, err := groups.SetConfigName(plan.ConfigPath, to) + if plan.ConfigPath == "" { + db := rt.Store + if db == nil { + return nil, errNoStore() + } + if err = db.SetGroupAlias(plan.Main, to); err != nil { + return nil, storeError("saving the project name", err) + } + } else { + renamed, err := groups.SetConfigName(plan.ConfigPath, to) if err != nil { return nil, configError(plan.ConfigPath, err) } - if err := rt.Scanner.LoadConfig(cfg.Path); err != nil { - rt.Logger.Warn("reloading a config after renaming its project", "path", cfg.Path, "error", err) + if err = rt.Scanner.LoadConfig(renamed.Path); err != nil { + rt.Logger.Warn("reloading a config after renaming its project", "path", renamed.Path, "error", err) } - // The file names the project from now on. An alias stored before the - // file existed would still outrank it, so it goes. + // From here on the file names the project. An alias stored before that + // file existed would still outrank it, so it is dropped. if st := rt.Store; st != nil && plan.Main != "" { if err := st.ClearGroupAlias(plan.Main); err != nil { rt.Logger.Warn("clearing a project alias", "root", plan.Main, "error", err) } } - } else { - st := rt.Store - if st == nil { - return nil, errNoStore() - } - if err := st.SetGroupAlias(plan.Main, to); err != nil { - return nil, storeError("saving the project name", err) - } } notifyGroupRename(plan.Renames) rt.Logger.Info("project renamed", "from", from, "to", to, "groups", len(plan.Renames)) - // Publish before replying, like every other write: the delta that removes - // the old names and adds the new ones is queued ahead of this response. + // Publishing happens before the reply, as for every other write: the delta + // removing the old names and adding the new ones is queued ahead of this + // response. republish(rt) - affected := make([]string, 0, len(plan.Renames)) - for _, next := range plan.Renames { - affected = append(affected, next) + names := make([]string, 0, len(plan.Renames)) + for _, renamed := range plan.Renames { + names = append(names, renamed) } - sort.Strings(affected) + sort.Strings(names) return rpc.GroupsRenameResult{ - MutationResult: rpc.MutationResult{OK: true, Affected: affected}, Name: plan.Project, + MutationResult: rpc.MutationResult{OK: true, Affected: names}, }, nil } -// renameError maps a refused rename onto the contract's error registry. +// renameError maps a refused rename onto the contract's error registry: the +// details (message and hint) belong to the groups package, only the code is +// decided here. func renameError(err error) error { - var re *groups.RenameError - if !errors.As(err, &re) { + var refusal *groups.RenameError + if !errors.As(err, &refusal) { return rpc.NewError(rpc.CodeInternal, err.Error(), "check `oberth daemon log`") } - switch re.Kind { - case groups.RenameNotFound: - return rpc.NewError(rpc.CodeNotFound, re.Msg, re.Hint) + return renameKindError(refusal) +} + +func renameKindError(refusal *groups.RenameError) error { + switch refusal.Kind { case groups.RenameConflict: - return rpc.NewError(rpc.CodeConflict, re.Msg, re.Hint) - default: - return rpc.NewError(rpc.CodeInvalidParams, re.Msg, re.Hint) + return rpc.NewError(rpc.CodeConflict, refusal.Msg, refusal.Hint) + case groups.RenameNotFound: + return rpc.NewError(rpc.CodeNotFound, refusal.Msg, refusal.Hint) } + return rpc.NewError(rpc.CodeInvalidParams, refusal.Msg, refusal.Hint) } diff --git a/engine/internal/daemon/handlers.go b/engine/internal/daemon/handlers.go index 6fa1046c..0f86ea91 100644 --- a/engine/internal/daemon/handlers.go +++ b/engine/internal/daemon/handlers.go @@ -11,62 +11,74 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// The core method set. Namespaces owned by other packages register -// their own handlers from their own init(), so this list only grows sideways. +// The core method set. Other packages own further namespaces and register +// those handlers from their own init(), so this list only ever grows sideways. func init() { + registerDaemonHandlers() + registerStateHandlers() + registerStreamHandlers() +} + +func registerDaemonHandlers() { RegisterHandler("daemon.hello", handleHello) RegisterHandler("daemon.status", handleStatus) RegisterHandler("daemon.shutdown", handleShutdown) RegisterHandler("daemon.schema", handleSchema) +} +func registerStateHandlers() { RegisterHandler("state.snapshot", handleSnapshot) RegisterHandler("state.subscribe", handleSubscribe) RegisterHandler("state.unsubscribe", handleUnsubscribe) +} +func registerStreamHandlers() { RegisterHandler("stream.cancel", handleStreamCancel) } // parseInclude turns the wire's ["stats","health"] into the scanner's struct. -// An unknown entry is a client error rather than a silent no-op, so a typo in -// `include` is caught immediately. +// An unknown entry is a client error, not a silent no-op, so a typo inside +// `include` surfaces immediately. func parseInclude(in rpc.Include) (scanner.Include, error) { - var out scanner.Include + wanted := scanner.Include{} for _, item := range in { - switch strings.ToLower(strings.TrimSpace(string(item))) { + switch name := strings.ToLower(strings.TrimSpace(string(item))); name { case "": continue case "stats": - out.Stats = true + wanted.Stats = true case "health": - out.Health = true + wanted.Health = true default: - return out, rpc.NewError(rpc.CodeInvalidParams, + return wanted, rpc.NewError(rpc.CodeInvalidParams, "unknown include "+item, `include accepts "stats" and "health"`) } } - return out, nil + return wanted, nil } func handleHello(ctx context.Context, req *Request) (any, error) { var p rpc.DaemonHelloParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } - if p.Client == "" { + unnamed := p.Client == "" + if unnamed { return nil, rpc.NewError(rpc.CodeInvalidParams, "client is required", `send {"client": "cli", "client_version": "..."}`) } req.Conn.setHello(p.Client, p.ClientVersion, p.Keepalive) - req.Runtime.Logger.Debug("hello", "conn", req.Conn.ID(), + rt := req.Runtime + rt.Logger.Debug("hello", "conn", req.Conn.ID(), "client", p.Client, "client_version", p.ClientVersion, "keepalive", p.Keepalive) - rt := req.Runtime return rpc.DaemonHelloResult{ + Capabilities: Capabilities(), ProtocolVersion: rpc.ProtocolVersion, DaemonVersion: rt.Version, PID: rt.PID, StartedAt: rt.StartedAt.Format(time.RFC3339), - Capabilities: Capabilities(), Socket: rt.Socket, BinaryPath: rt.BinaryPath, Keepalive: p.Keepalive, @@ -76,22 +88,22 @@ func handleHello(ctx context.Context, req *Request) (any, error) { func handleStatus(ctx context.Context, req *Request) (any, error) { rt := req.Runtime - st := rt.Scanner.Status() + status := rt.Scanner.Status() lastScan := "" - if !st.LastScanAt.IsZero() { - lastScan = st.LastScanAt.Format(time.RFC3339) + if !status.LastScanAt.IsZero() { + lastScan = status.LastScanAt.Format(time.RFC3339) } return rpc.DaemonStatusResult{ + Scans: status.Scans, + ScanIntervalMs: status.IntervalMs, + ScanBaseIntervalMs: status.BaseIntervalMs, + StatsIntervalMs: status.StatsIntervalMs, PID: rt.PID, Uptime: rt.Uptime().Round(time.Second).String(), Subscribers: rt.Subscribers(), LastScanAt: lastScan, Commit: buildinfo.Commit, Built: buildinfo.Date, - ScanIntervalMs: st.IntervalMs, - ScanBaseIntervalMs: st.BaseIntervalMs, - StatsIntervalMs: st.StatsIntervalMs, - Scans: st.Scans, DBPath: rt.DBPath(), Observation: rt.RefreshObservation(ctx), }, nil @@ -99,33 +111,35 @@ func handleStatus(ctx context.Context, req *Request) (any, error) { func handleShutdown(_ context.Context, req *Request) (any, error) { req.Runtime.Logger.Info("shutdown requested by client", "conn", req.Conn.ID()) - // The dispatcher stops the daemon once this reply is queued, so the client - // gets its {ok: true} before the socket goes away. + // The dispatcher stops the daemon only after this reply has been queued, so + // the client sees its {ok: true} before the socket goes away. req.Conn.requestShutdown() return rpc.OKResult{OK: true}, nil } func handleSchema(_ context.Context, _ *Request) (any, error) { - return rpc.DaemonSchemaResult{Schema: rpc.Marshal()}, nil + schema := rpc.DaemonSchemaResult{Schema: rpc.Marshal()} + return schema, nil } func handleSnapshot(_ context.Context, req *Request) (any, error) { var p rpc.StateSnapshotParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } include, err := parseInclude(p.Include) if err != nil { return nil, err } - snap, err := req.Runtime.Scanner.SnapshotAll(include) + snapshot, err := req.Runtime.Scanner.SnapshotAll(include) if err != nil { return nil, scanFailure(err) } // Attach in-flight start attempts on a copy. The cached snapshot stays // attempt-free, so a delta never carries one and a later scan cannot // mistake the attempt for a run. - snap.Groups = joinStartAttempts(req.Runtime, snap.Groups) + snapshot.Groups = joinStartAttempts(req.Runtime, snapshot.Groups) scope := scopeOf(p.Scope) if !scope.Empty() { if scope.Workspace != "" && !scope.HasSelectors() && !req.Runtime.Server().workspaceAvailable(scope.Workspace) { @@ -134,15 +148,16 @@ func handleSnapshot(_ context.Context, req *Request) (any, error) { "subscribe once with --workspace and an explicit --worktree or --repository") } scope = req.Runtime.Server().effectiveScope(scope) - snap.Ports = filterPorts(snap.Ports, include) - return state.StreamSnapshotFor(snap, scope), nil + snapshot.Ports = filterPorts(snapshot.Ports, include) + return state.StreamSnapshotFor(snapshot, scope), nil } - return filterSnapshot(snap, include, scope), nil + return filterSnapshot(snapshot, include, scope), nil } func handleSubscribe(_ context.Context, req *Request) (any, error) { var p rpc.StateSubscribeParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } include, err := parseInclude(p.Include) @@ -156,11 +171,11 @@ func handleSubscribe(_ context.Context, req *Request) (any, error) { "set events to true to receive state.changed notifications") } - // No scan here. subscribe replies from the cached snapshot and wakes the - // loop, so a subscriber that asked for stats or health is answered at once - // and gets those fields in the delta the woken tick publishes. Scanning - // first would hold the reply for the length of a stats collection — seconds - // on a busy machine — and the first snapshot would still be a delta behind + // Nothing is scanned here. subscribe answers from the cached snapshot and + // wakes the loop, so a subscriber wanting stats or health is answered at + // once and receives those fields in the delta the woken tick publishes. + // Scanning first would hold the reply for a whole stats collection — seconds + // on a busy machine — and the first snapshot would trail by a delta anyway // by the time it arrived. if err := req.Runtime.Server().subscribe(req.Conn, req.ID, include, p.Events, scope, p.AfterSeq); err != nil { return nil, err @@ -176,22 +191,30 @@ func scopeOf(scope *state.Scope) state.Scope { } func handleUnsubscribe(_ context.Context, req *Request) (any, error) { - req.Runtime.Server().unsubscribe(req.Conn) + srv := req.Runtime.Server() + srv.unsubscribe(req.Conn) return rpc.OKResult{OK: true}, nil } func handleStreamCancel(_ context.Context, req *Request) (any, error) { var p rpc.StreamCancel - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } if p.ID == "" { return nil, rpc.NewError(rpc.CodeInvalidParams, "id is required", "") } if !req.Conn.CancelStream(p.ID) { - return nil, rpc.NewError(rpc.CodeNotFound, - "no stream "+p.ID+" on this connection", - "streams end on their own with stream.end; cancelling twice is not an error worth retrying") + return nil, streamNotFound(p.ID) } return rpc.OKResult{OK: true}, nil } + +// streamNotFound is the reply for cancelling an id this connection never +// registered. +func streamNotFound(id string) error { + return rpc.NewError(rpc.CodeNotFound, + "no stream "+id+" on this connection", + "streams end on their own with stream.end; cancelling twice is not an error worth retrying") +} diff --git a/engine/internal/daemon/handlers_store.go b/engine/internal/daemon/handlers_store.go index 236fd2da..e7dbf9f3 100644 --- a/engine/internal/daemon/handlers_store.go +++ b/engine/internal/daemon/handlers_store.go @@ -11,100 +11,104 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/store" ) -// The write paths that live in the database, plus the settings file behind -// them. The read side of ports.* lives elsewhere and registers itself. +// The database-backed write paths, together with the settings file behind them. +// The read side of ports.* lives elsewhere and registers itself. func init() { + registerSettingsHandlers() +} + +func registerSettingsHandlers() { RegisterHandler("config.get", handleConfigGet) RegisterHandler("config.set", handleConfigSet) RegisterHandler("config.path", handleConfigPath) } -// errNoStore is what every database-backed method reports when the daemon came -// up without one. +// errNoStore is the reply of every database-backed method when the daemon came +// up with no database. func errNoStore() error { - return rpc.NewError(rpc.CodeInternal, - "this daemon has no database open", + return rpc.NewError(rpc.CodeInternal, "this daemon has no database open", "check `oberth daemon log` for the error opening option-berth.db, then `oberth daemon restart`") } func handleConfigGet(_ context.Context, _ *Request) (any, error) { - cfg, err := config.Map() + settings, err := config.Map() if err != nil { return nil, rpc.NewError(rpc.CodeInternal, err.Error(), "fix or remove "+config.Path()) } - // A `decide:` block left over from the retired judgment layer can hold an - // API key. Nothing reads it any more, so it is not sent either. - delete(cfg, "decide") - return rpc.ConfigGetResult{Config: cfg}, nil + // A leftover `decide:` block from the retired judgment layer may hold an API + // key. Nothing reads it any more, so it is not sent out either. + delete(settings, "decide") + return rpc.ConfigGetResult{Config: settings}, nil } func handleConfigSet(_ context.Context, req *Request) (any, error) { var p rpc.ConfigSetParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } if len(p.Patch) == 0 { return nil, rpc.NewError(rpc.CodeInvalidParams, "patch is required", `send {"patch": {"list": {"sort": "port"}}}; a null value clears a key`) } - cfg, err := config.Apply(p.Patch) + settings, err := config.Apply(p.Patch) if err != nil { return nil, rpc.NewError(rpc.CodeInvalidParams, err.Error(), "the config was left as it was; `oberth config path` shows the file") } req.Runtime.Logger.Info("config written", "path", config.Path()) - return rpc.ConfigSetResult{OK: true, Config: cfg}, nil + return rpc.ConfigSetResult{OK: true, Config: settings}, nil } func handleConfigPath(_ context.Context, _ *Request) (any, error) { - return rpc.ConfigPathResult{Path: config.Path()}, nil + path := config.Path() + return rpc.ConfigPathResult{Path: path}, nil } -// republish makes a write visible before its own reply is. It re-attributes -// the last scan and publishes synchronously, so by the time the handler -// returns the delta carrying the change is already queued on every -// subscriber's connection — ahead of this call's response, which the same -// writer queues afterwards — and the caller's own next read is served from a -// snapshot that has it. +// republish makes a write visible before its own reply is. The last scan is +// re-attributed and published synchronously, so by the time the handler returns +// the delta carrying the change is queued on every subscriber's connection — +// ahead of this call's response, which the same writer queues afterwards — and +// the caller's own next read comes from a snapshot that already has it. // -// Contract §18 only asks that a rename or an assign take effect "in the next +// Contract §18 asks only that a rename or an assign take effect "in the next // publish", with an immediate rescan so the caller sees it. Replying *after* // that publish is what turns "the next delta carries the rename" from a race -// into a guarantee: a subscriber cannot receive the acknowledgement before the -// delta that justifies it, and a client that reads straight after the reply -// cannot be served the state from before its own write. +// into a guarantee: a subscriber cannot receive the acknowledgement ahead of +// the delta that justifies it, and a client reading straight after the reply +// cannot be served state from before its own write. // -// It does not scan the machine. A rename, a group pin and a `oberth.yaml` edit -// change how the ports the daemon already knows are named and grouped, not -// which ports exist, so re-running attribution over the last scan's own rows -// answers the question — and it does so in microseconds instead of behind -// `lsof`, `ps` and `docker stats`. `Republish` wakes the loop, -// so a real scan follows. +// The machine is not scanned. A rename, a group pin and a `oberth.yaml` edit +// change how ports the daemon already knows are named and grouped, not which +// ports exist, so re-running attribution over the last scan's rows answers the +// question in microseconds instead of behind `lsof`, `ps` and `docker stats`. +// `Republish` wakes the loop, so a real scan follows. func republish(rt *Runtime) { - if err := rt.Scanner.Republish(); err != nil { + err := rt.Scanner.Republish() + if err != nil { rt.Logger.Warn("republishing after a write", "error", err) } } -// rescanAfterKill is republish's sibling for the one write that does change -// which ports exist. A kill has to be followed by a real scan: the ports that -// just went away are only gone from the snapshot once the OS has been asked +// rescanAfterKill is republish's sibling for the one write that really does +// change which ports exist. A kill must be followed by a real scan: the ports +// that just went away leave the snapshot only once the OS has been asked // again, and their port_down rows reach the history ring from that delta. func rescanAfterKill(rt *Runtime) (state.Snapshot, error) { - snap, err := rt.Scanner.Rescan(scanner.Include{}) + snapshot, err := rt.Scanner.Rescan(scanner.Include{}) if err != nil { rt.Logger.Warn("rescanning after a kill", "error", err) } - return snap, err + return snapshot, err } -// storeError wraps a database failure as an internal error with the path. -func storeError(what string, err error) error { - if errors.Is(err, store.ErrInvalidName) { - return rpc.NewError(rpc.CodeInvalidParams, err.Error(), +// storeError wraps a database failure as an internal error naming the path. +func storeError(operation string, cause error) error { + if errors.Is(cause, store.ErrInvalidName) { + return rpc.NewError(rpc.CodeInvalidParams, cause.Error(), "names are a single word: no whitespace, no / and no \\") } - return rpc.NewError(rpc.CodeInternal, what+": "+err.Error(), + return rpc.NewError(rpc.CodeInternal, operation+": "+cause.Error(), "check `oberth daemon log`") } diff --git a/engine/internal/daemon/kill_handlers.go b/engine/internal/daemon/kill_handlers.go index b06c5299..2e870f51 100644 --- a/engine/internal/daemon/kill_handlers.go +++ b/engine/internal/daemon/kill_handlers.go @@ -12,10 +12,9 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// The kill namespace. Both methods are thin: they turn wire selectors into -// killer targets against the daemon's latest snapshot and hand the work to -// killer.KillPorts, which is the same code path `oberth kill` takes, so the CLI, -// the app and MCP cannot drift apart. +// The kill namespace. These methods stay thin on purpose: wire selectors are +// resolved against the daemon's latest snapshot, then killer.KillPorts does the +// work — the very path `oberth kill` takes — so CLI, app and MCP cannot drift. func init() { RegisterHandler("ports.kill", handlePortsKill) RegisterHandler("groups.kill", handleGroupsKill) @@ -24,7 +23,8 @@ func init() { func handlePortsKill(ctx context.Context, req *Request) (any, error) { var p rpc.PortsKillParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } if len(p.Targets) == 0 { @@ -38,8 +38,8 @@ func handlePortsKill(ctx context.Context, req *Request) (any, error) { } targets := make([]killer.Target, 0, len(p.Targets)) - for _, sel := range p.Targets { - t, err := selectorTarget(sel, snap) + for i := range p.Targets { + t, err := selectorTarget(p.Targets[i], snap) if err != nil { return nil, killRPCError(err) } @@ -47,11 +47,11 @@ func handlePortsKill(ctx context.Context, req *Request) (any, error) { } opts := killer.Options{ - Tree: p.Tree, + DryRun: p.DryRun, Force: p.Force, - Grace: time.Duration(p.GraceMs) * time.Millisecond, Escalate: p.Escalate, - DryRun: p.DryRun, + Tree: p.Tree, + Grace: time.Duration(p.GraceMs) * time.Millisecond, Ports: killerRows(snap), } opts.OnSignal = stopSignalObserver(req.Runtime, snap, targets, opts.DryRun, "") @@ -60,36 +60,36 @@ func handlePortsKill(ctx context.Context, req *Request) (any, error) { return killEnvelope(rows), nil } -// afterKill rescans and publishes, so the ports that just went away are gone -// from the very next read and their port_down rows reach the history ring even -// when nobody is subscribed. Without it the caller's own `oberth list` would -// still be served from the snapshot taken before the kill. +// afterKill takes a fresh scan and publishes it, so ports that were just +// stopped are absent from the next read and their port_down rows reach the +// history ring even with no subscriber. Skipping it would leave the caller's +// own `oberth list` answering from the pre-kill snapshot. func afterKill(req *Request, dryRun bool) (state.Snapshot, error) { if dryRun { return state.Snapshot{}, nil } - // Reconcile exited imported runs before publishing the post-stop snapshot. - // They have no child reaper in this daemon; a stale Running row would - // otherwise make the next restart skip a worker that already exited. + // Imported runs that exited are reconciled before the post-stop snapshot is + // published: this daemon has no child reaper for them, and a stale Running + // row would make the next restart skip a worker that is already gone. req.Runtime.Runs().Prune() return rescanAfterKill(req.Runtime) } -// killSnapshot is the state selectors and group members are resolved against. +// killSnapshot is the state that selectors and group members resolve against. // -// It always scans: the cache may be up to CacheTTL old, and a group that gained -// a service in that window would be killed only in part — which is exactly what -// the direct `oberth kill` path, scanning at the moment it is asked, never does. -// A kill is rare and deliberate; one scan is the right price for acting on what -// is listening now. +// It never reads the cache: up to CacheTTL of age would let a group that gained +// a service in that window be killed only partly, which the direct +// `oberth kill` path — scanning at the moment it is asked — never does. A kill +// is rare and deliberate, so one scan is the right price for acting on what is +// listening right now. // -// The scan it asks for is bare. A kill needs the pid and port of what is -// listening, never a health verdict or a cpu reading, so it does not inherit a -// subscriber's `include` — the published snapshot carries those forward -// instead of collecting them again. That is the difference -// between a dry run answering in a moment and one waiting out `docker stats` -// and a round of health probes. Caller cancellation stops observation before -// selector resolution; it does not cancel publication of a kill already done. +// That scan is bare. A kill only needs the pid and port of each listener, not a +// health verdict or a cpu reading, so no subscriber's `include` is inherited; +// the published snapshot carries those readings forward instead of collecting +// them again. That is what separates a dry run that answers in a moment from +// one that waits out `docker stats` and a round of health probes. Caller +// cancellation stops observation before selectors resolve; it does not cancel +// publication of a kill that already happened. func killSnapshot(ctx context.Context, req *Request) (state.Snapshot, error) { snap, err := req.Runtime.Scanner.RescanContext(ctx, scanner.Include{}) if err := ctx.Err(); err != nil { @@ -101,89 +101,110 @@ func killSnapshot(ctx context.Context, req *Request) (state.Snapshot, error) { return snap, nil } -// killerRows hands the killer the scan the handler just took. +// killerRows hands the killer the scan the handler already took. // -// Without it killer.KillPorts scans the machine again from inside the RPC, so -// one `ports.kill` cost two full scans: the §25 rescan-before-select and the -// killer's own. The second one is pure duplication — the rows are the same -// enriched pipeline (ports.Scan + Docker + Enrich) the daemon's scanner ran a -// moment earlier — and it is what made a dry run of an unknown port take -// seconds. Every other caller of the killer (`oberth kill`, `kill-all`, `down`) -// already passes the snapshot it resolved its targets against; the daemon now -// does the same, which is also what keeps the two paths' output identical. +// Without it killer.KillPorts would scan the machine a second time from inside +// the RPC, so a single `ports.kill` cost two full scans: the §25 +// rescan-before-select plus the killer's own. The second is pure duplication — +// the rows come from the same enriched pipeline (ports.Scan + Docker + Enrich) +// the daemon's scanner ran a moment earlier — and it made a dry run of an +// unknown port take seconds. Every other caller of the killer (`oberth kill`, +// `kill-all`, `down`) already passes the snapshot its targets were resolved +// against; the daemon does the same now, which also keeps the two paths' +// output identical. func killerRows(snap state.Snapshot) []ports.ListeningPort { rows := state.ToListeningAll(snap.Ports) - if rows == nil { - // Never nil: a nil slice tells the killer to go and scan for itself. - rows = []ports.ListeningPort{} + if rows != nil { + return rows } - return rows + // Never nil: a nil slice would tell the killer to scan for itself. + return []ports.ListeningPort{} } // selectorTarget validates one wire selector and turns it into a killer target. -// A port that the snapshot binds to exactly one address is pinned to it, so a -// second listener appearing between the scan and the kill cannot widen the -// request; anything the snapshot does not know about is passed through and +// When the snapshot binds a port to exactly one address, the target is pinned +// to it so a second listener appearing between scan and kill cannot widen the +// request; whatever the snapshot does not know about is passed through and // comes back as a failed row rather than a failed call. func selectorTarget(sel rpc.Selector, snap state.Snapshot) (killer.Target, error) { - set := 0 - for _, on := range []bool{sel.Port != nil, sel.PID != nil, sel.RunID != nil} { - if on { - set++ + chosen := 0 + for _, present := range []bool{sel.Port != nil, sel.PID != nil, sel.RunID != nil} { + if present { + chosen++ } } - if set != 1 { - return killer.Target{}, &killer.CodedError{ - Code: killer.CodeInvalidSelector, - Detail: "a selector sets exactly one of port, pid and run_id", - Hint: `bind_address only disambiguates port, for example {"port": 3000, "bind_address": "127.0.0.1"}`, - } + if chosen != 1 { + return killer.Target{}, invalidSelector("a selector sets exactly one of port, pid and run_id", + `bind_address only disambiguates port, for example {"port": 3000, "bind_address": "127.0.0.1"}`) } - switch { - case sel.RunID != nil: + if sel.RunID != nil { return killer.Target{RunID: *sel.RunID}, nil - case sel.PID != nil: - if *sel.PID <= 0 { - return killer.Target{}, &killer.CodedError{ - Code: killer.CodeInvalidSelector, - Detail: "pid must be positive", - } + } + if sel.PID != nil { + if err := checkSelectorPID(*sel.PID); err != nil { + return killer.Target{}, err } return killer.Target{PID: *sel.PID}, nil } port := *sel.Port - if port <= 0 || port > 65535 { - return killer.Target{}, &killer.CodedError{ - Code: killer.CodeInvalidSelector, - Detail: "port must be between 1 and 65535", - } + if err := checkSelectorPort(port); err != nil { + return killer.Target{}, err } - bind := "" + bindAddr := "" if sel.BindAddress != nil { - bind = *sel.BindAddress + bindAddr = *sel.BindAddress } - if bind == "" { + if bindAddr == "" { if binds := bindAddresses(snap, port); len(binds) == 1 { - bind = binds[0] + bindAddr = binds[0] } } - return killer.Target{Port: port, BindAddress: bind}, nil + return killer.Target{Port: port, BindAddress: bindAddr}, nil +} + +// invalidSelector builds the coded error every malformed selector earns. +func invalidSelector(detail, hint string) error { + return &killer.CodedError{ + Code: killer.CodeInvalidSelector, + Detail: detail, + Hint: hint, + } +} + +func checkSelectorPID(pid int) error { + if pid <= 0 { + return &killer.CodedError{ + Code: killer.CodeInvalidSelector, + Detail: "pid must be positive", + } + } + return nil +} + +func checkSelectorPort(port int) error { + if port <= 0 || port > 65535 { + return &killer.CodedError{ + Code: killer.CodeInvalidSelector, + Detail: "port must be between 1 and 65535", + } + } + return nil } // bindAddresses lists the distinct addresses a port is bound to in the snapshot. func bindAddresses(snap state.Snapshot, port int) []string { - var out []string - seen := map[string]bool{} + var addresses []string + known := make(map[string]bool) for _, p := range snap.Ports { - if p.Port != port || seen[p.BindAddress] { + if p.Port != port || known[p.BindAddress] { continue } - seen[p.BindAddress] = true - out = append(out, p.BindAddress) + known[p.BindAddress] = true + addresses = append(addresses, p.BindAddress) } - return out + return addresses } // groupTargets resolves a group name against the snapshot. The resolved group @@ -196,9 +217,13 @@ func groupTargets(snap state.Snapshot, name string) []killer.Target { func groupTargetsFor(snap state.Snapshot, name string, only map[string]bool) []killer.Target { var out []killer.Target for _, p := range snap.Ports { - if inSnapshotGroup(p, name) && (only == nil || (p.Run != nil && only[p.Run.Name])) { - out = append(out, killer.Target{Port: p.Port, BindAddress: p.BindAddress}) + if !inSnapshotGroup(p, name) { + continue } + if only != nil && (p.Run == nil || !only[p.Run.Name]) { + continue + } + out = append(out, killer.Target{Port: p.Port, BindAddress: p.BindAddress}) } return out } @@ -210,17 +235,17 @@ func groupTargetsFor(snap state.Snapshot, name string, only map[string]bool) []k // name contradicts the expectation, is left alone as well — an unverifiable // identity cannot widen a stop. func expectedGroupTargets(snap state.Snapshot, name string, expect map[string]string) []killer.Target { - wanted := make(map[string]string, len(expect)) + byRun := make(map[string]string, len(expect)) for service, runID := range expect { - wanted[runID] = service + byRun[runID] = service } var out []killer.Target for _, p := range snap.Ports { if p.Run == nil || !inSnapshotGroup(p, name) { continue } - service, ok := wanted[p.Run.ID] - if !ok || (p.Run.Name != "" && p.Run.Name != service) { + service, known := byRun[p.Run.ID] + if !known || (p.Run.Name != "" && p.Run.Name != service) { continue } out = append(out, killer.Target{Port: p.Port, BindAddress: p.BindAddress}) @@ -229,52 +254,52 @@ func expectedGroupTargets(snap state.Snapshot, name string, expect map[string]st } func inSnapshotGroup(p state.Port, name string) bool { - candidates := []string{} + names := make([]string, 0, 5) if p.Group != nil { - candidates = append(candidates, *p.Group) + names = append(names, *p.Group) } if p.Run != nil { - candidates = append(candidates, p.Run.Group, p.Run.ID, p.Run.Name) + names = append(names, p.Run.Group, p.Run.ID, p.Run.Name) } if p.Docker != nil { - candidates = append(candidates, p.Docker.ComposeProject) + names = append(names, p.Docker.ComposeProject) } - for _, c := range candidates { - if c != "" && strings.EqualFold(c, name) { + for _, candidate := range names { + if candidate != "" && strings.EqualFold(candidate, name) { return true } } return false } -// killEnvelope wraps the killer's rows in the mutation result. -// affected lists the port key of every row that succeeded, once each; ok is -// true only when nothing failed. +// killEnvelope wraps the killer's rows in the mutation result. ok is true only +// when nothing failed, and affected lists the port key of every row that +// succeeded, once each. func killEnvelope(rows []state.KillResult) rpc.KillEnvelope { - if rows == nil { - rows = []state.KillResult{} + results := rows + if results == nil { + results = []state.KillResult{} } - env := rpc.KillEnvelope{Results: rows} + env := rpc.KillEnvelope{Results: results} env.OK = true env.Affected = []string{} - seen := map[string]bool{} - for _, r := range rows { - if !r.OK && r.Code != killer.CodeNotFound { - env.OK = false + seen := make(map[string]bool, len(results)) + for i := range results { + r := results[i] + if r.OK { + key := r.Key() + if key != "" && !seen[key] { + seen[key] = true + env.Affected = append(env.Affected, key) + } continue } // A not_found row is not a failure: the end state the caller asked for — - // the thing is not running — already holds, so the envelope stays ok - // (and the row is still in Results, saying so). - if !r.OK { - continue - } - key := r.Key() - if key == "" || seen[key] { - continue + // the thing is not running — already holds, so the envelope stays ok and + // the row still appears in Results saying so. + if r.Code != killer.CodeNotFound { + env.OK = false } - seen[key] = true - env.Affected = append(env.Affected, key) } return env } @@ -282,16 +307,21 @@ func killEnvelope(rows []state.KillResult) rpc.KillEnvelope { // killRPCError maps a killer error onto the contract's numeric error registry, // carrying its hint through to error.data.hint. func killRPCError(err error) error { - code := rpc.CodeInternal - switch killer.Code(err) { + return rpc.NewError(killErrorCode(killer.Code(err)), err.Error(), killer.Hint(err)) +} + +// killErrorCode translates the killer's classification into the numeric +// registry; an unrecognized one is an internal error. +func killErrorCode(class string) int { + switch class { case killer.CodeNotFound: - code = rpc.CodeNotFound + return rpc.CodeNotFound case killer.CodeAmbiguous: - code = rpc.CodeAmbiguous + return rpc.CodeAmbiguous case killer.CodePermissionDenied: - code = rpc.CodePermission + return rpc.CodePermission case killer.CodeInvalidSelector: - code = rpc.CodeInvalidSelector + return rpc.CodeInvalidSelector } - return rpc.NewError(code, err.Error(), killer.Hint(err)) + return rpc.CodeInternal } diff --git a/engine/internal/daemon/lifecycle.go b/engine/internal/daemon/lifecycle.go index 6a8933dc..3a6f1e84 100644 --- a/engine/internal/daemon/lifecycle.go +++ b/engine/internal/daemon/lifecycle.go @@ -17,28 +17,28 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/store" ) -// observationTTL bounds how often a daemon re-runs the self-probe. The answer -// changes only when the process's sandbox does, so a short cache is enough; a -// status read refreshes it, a hello reuses it. +// observationTTL bounds how often the self-probe is re-run. Its answer only +// changes when the process's sandbox does, so a short cache suffices: a status +// read refreshes it while a hello reuses it. const observationTTL = 30 * time.Second -// observationProbeTimeout keeps daemon.hello from waiting out a wedged -// collector. A probe that cannot answer in this budget is reported as blind, -// which is the safe direction: an unknown table must not look healthy. +// observationProbeTimeout stops daemon.hello from waiting out a wedged +// collector. A probe that cannot answer within this budget is reported as +// blind, which is the safe direction: an unknown table must not look healthy. const observationProbeTimeout = 2 * time.Second -// Runtime is what the daemon hands to extension packages. Step -// 1A.1 exposed the scanner, the logger and the process identity and 1A.4 the -// store; the process table, the port allocator and the spawn function are added -// by the steps that introduce them. +// Runtime is the handle the daemon hands to extension packages: the scanner, +// the logger and the process identity came first, the store followed; the +// process table, the port allocator and the spawn function are added by the +// steps that introduce them. type Runtime struct { - Version string - Socket string - BinaryPath string - PID int - StartedAt time.Time - Logger *slog.Logger Scanner *scanner.Loop + Logger *slog.Logger + StartedAt time.Time + PID int + BinaryPath string + Socket string + Version string // Store is the daemon's SQLite database: renames, group pins, the port // history ring and the known `oberth.yaml` roots. Nil when the database @@ -49,8 +49,8 @@ type Runtime struct { DB *sql.DB srv *Server - runsMu sync.RWMutex runs RunRegistry + runsMu sync.RWMutex // Active lifecycle holders and waiters only; idle entries are reclaimed. groupLocksMu sync.Mutex @@ -84,9 +84,9 @@ func (r *Runtime) RefreshObservation(ctx context.Context) *rpc.ObservationStatus Hint: result.Hint, CheckedAt: time.Now().UTC().Format(time.RFC3339), } - // A cancelled or timed-out probe is not a verdict about this host, so it - // must not be cached as one. Report the conservative result, but let the - // next caller ask again. + // A cancelled or timed-out probe says nothing about this host, so it is + // never cached as a verdict. The conservative result goes out, and the next + // caller is free to ask again. if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) { if r.observation != nil && errors.Is(result.Err, context.Canceled) { return r.observation @@ -98,112 +98,114 @@ func (r *Runtime) RefreshObservation(ctx context.Context) *rpc.ObservationStatus return status } -// RunRegistry is the set of processes `oberth start` spawned. The daemon carries -// it so the group resolver can attribute a listener to the run that owns it -// (group source `start`); internal/daemon/runsreg installs the implementation -// from its own OnStart hook, so this package never imports it. +// RunRegistry is the set of processes `oberth start` spawned. Carrying it on +// the runtime lets the group resolver attribute a listener to the run that owns +// it (group source `start`); internal/daemon/runsreg installs the real +// implementation from its own OnStart hook, so this package never imports it. type RunRegistry interface { + // Stopping marks runs option-berth is about to stop, so their exit is recorded + // as stopped rather than as a crash. + Stopping(pids []int) // Run reports the run that owns a port, matching groups.Registry so the // resolver can take it as-is. Run(p state.Port) (run state.Run, ok bool) - // Prune drops runs whose process is gone. - Prune() - // GroupPIDs lists the live runs recorded under a group. - GroupPIDs(group string) []int // GroupRuns lists the live runs recorded under a group, with the service // each belongs to. GroupRuns(group string) []state.Run + // GroupPIDs lists the live runs recorded under a group. + GroupPIDs(group string) []int // RunStart reports when the process behind a run pid started, so a stop // can hand the killer the identity it verifies before signalling. False // means no recorded start (unknown, never "reused"). RunStart(pid int) (time.Time, bool) - // Stopping marks runs option-berth is about to stop, so their exit is recorded - // as stopped rather than as a crash. - Stopping(pids []int) + // Prune drops runs whose process is gone. + Prune() } // noRuns is the stand-in used before a registry is installed, so callers never // have to nil-check. type noRuns struct{} -func (noRuns) Run(state.Port) (state.Run, bool) { return state.Run{}, false } -func (noRuns) Prune() {} func (noRuns) Stopping([]int) {} -func (noRuns) GroupPIDs(string) []int { return nil } +func (noRuns) Run(state.Port) (state.Run, bool) { return state.Run{}, false } func (noRuns) GroupRuns(string) []state.Run { return nil } +func (noRuns) GroupPIDs(string) []int { return nil } func (noRuns) RunStart(int) (time.Time, bool) { return time.Time{}, false } +func (noRuns) Prune() {} -// SetRuns installs the run registry. Called once, from an OnStart hook. +// SetRuns installs the run registry. It is called once, from an OnStart hook. func (r *Runtime) SetRuns(reg RunRegistry) { r.runsMu.Lock() - defer r.runsMu.Unlock() r.runs = reg + r.runsMu.Unlock() } -// Runs is the installed run registry, or a registry that knows nothing. +// Runs is the installed run registry, or a registry that knows nothing yet. func (r *Runtime) Runs() RunRegistry { r.runsMu.RLock() - defer r.runsMu.RUnlock() - if r.runs == nil { + reg := r.runs + r.runsMu.RUnlock() + if reg == nil { return noRuns{} } - return r.runs + return reg } -// RunRegistry is the installed run registry as the resolver sees it. The -// scanner reads it once per tick, so a registry installed after the daemon is -// up takes effect on the next scan. +// RunRegistry is the installed registry in the shape the resolver expects. The +// scanner reads it once per tick, so a registry installed while the daemon is +// already up takes effect on the next scan. func (r *Runtime) RunRegistry() groups.Registry { return r.Runs() } -// DBPath is the database file backing this daemon, or "" when there is none. +// DBPath names the database file behind this daemon, or "" when it has none. // daemon.status reports it. func (r *Runtime) DBPath() string { - if r.Store == nil { + db := r.Store + if db == nil { return "" } - return r.Store.DBPath() + return db.DBPath() } -// Server returns the running server. Handlers use it for subscriber counts and -// for shutdown. +// Server is the running server; handlers use it for subscriber counts and to +// trigger a shutdown. func (r *Runtime) Server() *Server { return r.srv } -// Subscribers is the number of connections currently subscribed to state. +// Subscribers counts the connections subscribed to state right now. func (r *Runtime) Subscribers() int { return r.srv.Subscribers() } -// Uptime is how long the daemon has been running. +// Uptime is the time since this daemon started. func (r *Runtime) Uptime() time.Duration { return time.Since(r.StartedAt) } var ( hooksMu sync.Mutex + extraCaps = map[string]bool{} startHooks []func(*Runtime) error shutdownHooks []func(graceful bool) disconnectHooks []func(conn uint64) - extraCaps = map[string]bool{} ) -// RegisterCapability adds a family name to daemon.hello's capabilities, the -// list clients feature-detect on. A package that registers handlers for a -// namespace announces it here from the same init(). +// RegisterCapability announces a family name in daemon.hello's capabilities — +// the list clients use for feature detection. A package holding handlers for a +// namespace announces that namespace here, from the same init(). func RegisterCapability(name string) { if name == "" { return } hooksMu.Lock() - defer hooksMu.Unlock() extraCaps[name] = true + hooksMu.Unlock() } -// registeredCapabilities returns the announced extras, sorted. +// registeredCapabilities lists the announced extras in sorted order. func registeredCapabilities() []string { hooksMu.Lock() - defer hooksMu.Unlock() - out := make([]string, 0, len(extraCaps)) - for name := range extraCaps { - out = append(out, name) + names := make([]string, 0, len(extraCaps)) + for capability := range extraCaps { + names = append(names, capability) } - sort.Strings(out) - return out + hooksMu.Unlock() + sort.Strings(names) + return names } // OnStart registers a callback run once the socket is listening and the @@ -220,45 +222,46 @@ func OnStart(f func(*Runtime)) { // after the underlying failure is resolved, never a half-recovered registry. func OnStartChecked(f func(*Runtime) error) { hooksMu.Lock() - defer hooksMu.Unlock() startHooks = append(startHooks, f) + hooksMu.Unlock() } -// OnDisconnect registers a callback run once for every client connection that -// goes away, with the connection's id. It is how an extension package drops -// per-connection state without this package knowing what that state is — -// internal/session uses it to forget a device flow whose terminal was closed, -// so an abandoned code is not what a later poll falls back onto. +// OnDisconnect registers a callback that runs once for each client connection +// going away, receiving that connection's id. An extension package uses it to +// drop per-connection state this package knows nothing about — internal/session +// forgets a device flow whose terminal was closed, so an abandoned code is not +// what a later poll falls back onto. // -// It runs on the closing connection's goroutine. Keep it short and never block. +// The callback runs on the closing connection's goroutine: keep it short, and +// never block in it. func OnDisconnect(f func(conn uint64)) { hooksMu.Lock() - defer hooksMu.Unlock() disconnectHooks = append(disconnectHooks, f) + hooksMu.Unlock() } func runDisconnectHooks(conn uint64) { - hooksMu.Lock() - hooks := append([]func(uint64){}, disconnectHooks...) - hooksMu.Unlock() - for _, f := range hooks { + for _, f := range disconnectHooksSnapshot() { f(conn) } } -// OnShutdown registers a callback run as the daemon stops. graceful is false -// when the daemon is unwinding after a failure. -func OnShutdown(f func(graceful bool)) { +func disconnectHooksSnapshot() []func(uint64) { hooksMu.Lock() defer hooksMu.Unlock() - shutdownHooks = append(shutdownHooks, f) + return append([]func(uint64){}, disconnectHooks...) } -func runStartHooks(rt *Runtime) error { +// OnShutdown registers a callback run as the daemon stops; graceful is false +// while the daemon is unwinding after a failure. +func OnShutdown(f func(graceful bool)) { hooksMu.Lock() - hooks := append([]func(*Runtime) error{}, startHooks...) + shutdownHooks = append(shutdownHooks, f) hooksMu.Unlock() - for _, h := range hooks { +} + +func runStartHooks(rt *Runtime) error { + for _, h := range startHooksSnapshot() { if err := h(rt); err != nil { return err } @@ -266,14 +269,24 @@ func runStartHooks(rt *Runtime) error { return nil } +func startHooksSnapshot() []func(*Runtime) error { + hooksMu.Lock() + defer hooksMu.Unlock() + return append([]func(*Runtime) error{}, startHooks...) +} + // runShutdownHooks runs the shutdown callbacks in reverse registration order, // so a package that set something up in OnStart tears it down after the // packages that depend on it. func runShutdownHooks(graceful bool) { - hooksMu.Lock() - hooks := append([]func(bool){}, shutdownHooks...) - hooksMu.Unlock() + hooks := shutdownHooksSnapshot() for i := len(hooks) - 1; i >= 0; i-- { hooks[i](graceful) } } + +func shutdownHooksSnapshot() []func(bool) { + hooksMu.Lock() + defer hooksMu.Unlock() + return append([]func(bool){}, shutdownHooks...) +} diff --git a/engine/internal/daemon/listen_unix.go b/engine/internal/daemon/listen_unix.go index 2da5ec81..c8e6dec0 100644 --- a/engine/internal/daemon/listen_unix.go +++ b/engine/internal/daemon/listen_unix.go @@ -10,68 +10,70 @@ import ( "syscall" ) -// listen binds the unix socket. The socket itself is always 0600: local, +// listen binds the unix socket. The socket is always 0600 — local and // same-user only, enforced by the filesystem (spec, "Transport details"). The -// umask is cleared around the bind because a restrictive umask cannot make a -// socket *more* permissive but a permissive one leaves the socket group- and +// umask is cleared around the bind: a restrictive umask cannot make a socket +// *more* permissive, but a permissive one would leave it group- and // world-readable on some systems. func listen(path string) (net.Listener, error) { if err := prepareSocketDir(filepath.Dir(path), OwnsSocketDir(path)); err != nil { return nil, err } - old := syscall.Umask(0o177) - ln, err := net.Listen("unix", path) - syscall.Umask(old) + previous := syscall.Umask(0o177) + listener, err := net.Listen("unix", path) + syscall.Umask(previous) if err != nil { return nil, err } if err := os.Chmod(path, 0o600); err != nil { - ln.Close() + listener.Close() return nil, fmt.Errorf("tightening socket: %w", err) } - return ln, nil + return listener, nil } // dial connects to the unix socket. -func dial(path string) (net.Conn, error) { return net.Dial("unix", path) } +func dial(path string) (net.Conn, error) { + return net.Dial("unix", path) +} -// removeStaleSocket deletes a socket file left behind by a daemon that died -// without cleaning up. Only ever called once the lock has been acquired, so -// the file provably belongs to no live daemon. +// removeStaleSocket deletes a socket file a daemon that died without cleaning +// up left behind. It is only called once the lock has been acquired, so the +// file provably belongs to no live daemon. func removeStaleSocket(path string) error { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { return err } return nil } // prepareSocketDir makes sure dir exists before the socket is bound. A -// directory option-berth owns (the default location) is created 0700 and tightened to -// 0700 even when it was already there. A directory the user pointed -// BERTH_SOCKET at is created 0700 when it is missing, but an existing one is +// directory option-berth owns (the default location) is created 0700 and +// tightened to 0700 even when it was already there. A directory the user +// pointed BERTH_SOCKET at is created 0700 when missing, but an existing one is // left exactly as it is: `BERTH_SOCKET=/tmp/x.sock` must neither fail on -// "chmod /tmp: operation not permitted" nor silently tighten /tmp -// . The socket file is 0600 either way, which is what actually -// keeps other users out. +// "chmod /tmp: operation not permitted" nor silently tighten /tmp. Either way +// the socket file itself is 0600, which is what keeps other users out. func prepareSocketDir(dir string, ours bool) error { - existed := true - if _, err := os.Stat(dir); err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("checking socket directory %s: %w", dir, err) - } - existed = false + _, statErr := os.Stat(dir) + existed := statErr == nil + if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("checking socket directory %s: %w", dir, statErr) } - if err := os.MkdirAll(dir, 0o700); err != nil { - return fmt.Errorf("creating socket directory: %w", err) + mkErr := os.MkdirAll(dir, 0o700) + if mkErr != nil { + return fmt.Errorf("creating socket directory: %w", mkErr) } if existed && !ours { return nil } - // MkdirAll applies the umask, and is a no-op on a directory we own that - // already exists, so set the mode explicitly. - if err := os.Chmod(dir, 0o700); err != nil { - return fmt.Errorf("tightening socket directory: %w", err) + // MkdirAll applies the umask and does nothing to a directory we own that + // already exists, so the mode is set explicitly. + chmodErr := os.Chmod(dir, 0o700) + if chmodErr != nil { + return fmt.Errorf("tightening socket directory: %w", chmodErr) } return nil } diff --git a/engine/internal/daemon/listen_windows.go b/engine/internal/daemon/listen_windows.go index 7a28d48d..226e4b05 100644 --- a/engine/internal/daemon/listen_windows.go +++ b/engine/internal/daemon/listen_windows.go @@ -12,30 +12,30 @@ import ( "golang.org/x/sys/windows" ) -// pipeBindTimeout bounds the retry below. It is generous on purpose: a restart -// is already waiting on the outgoing daemon's lock, so the only cost of an -// extra second here is a restart that succeeds instead of one that does not. +// pipeBindTimeout caps the retry in listen. It is generous on purpose: a +// restart is already waiting on the outgoing daemon's lock, so spending an +// extra second here only turns a failed restart into one that succeeds. const pipeBindTimeout = 5 * time.Second -// listen binds the named pipe with a security descriptor that grants full -// access to the current user and to SYSTEM, and to nobody else — the Windows -// equivalent of the 0600 unix socket (spec, "Transport details"). +// listen binds the named pipe under a security descriptor that grants full +// access to the current user and to SYSTEM and to nobody else — the Windows +// counterpart of the 0600 unix socket (spec, "Transport details"). // -// A pipe instance outlives the process that created it by the moment it takes -// Windows to tear the last instance down, so the daemon that replaces another -// one can meet "Access is denied" on a name that is about to be free. That is -// a race, not a refusal: retry it for a few seconds before giving up. +// A pipe instance outlives the process that created it for as long as Windows +// needs to tear the last instance down, so a daemon replacing another one can +// meet "Access is denied" on a name that is about to become free. That is a +// race rather than a refusal: retry for a few seconds before giving up. func listen(path string) (net.Listener, error) { sddl, err := userOnlySDDL() if err != nil { return nil, err } - cfg := &winio.PipeConfig{SecurityDescriptor: sddl} + pipeCfg := &winio.PipeConfig{SecurityDescriptor: sddl} deadline := time.Now().Add(pipeBindTimeout) for { - ln, err := winio.ListenPipe(path, cfg) + listener, err := winio.ListenPipe(path, pipeCfg) if err == nil { - return ln, nil + return listener, nil } if !pipeNameStillBusy(err) || !time.Now().Before(deadline) { return nil, err @@ -44,35 +44,35 @@ func listen(path string) (net.Listener, error) { } } -// pipeNameStillBusy reports whether creating the pipe failed because the -// previous owner has not finished letting go of the name. -func pipeNameStillBusy(err error) bool { - return errors.Is(err, windows.ERROR_ACCESS_DENIED) || - errors.Is(err, windows.ERROR_PIPE_BUSY) +// pipeNameStillBusy reports whether creating the pipe failed only because the +// previous owner has not finished releasing the name. +func pipeNameStillBusy(cause error) bool { + return errors.Is(cause, windows.ERROR_ACCESS_DENIED) || + errors.Is(cause, windows.ERROR_PIPE_BUSY) } -// dial connects to the named pipe. The short timeout matters because a pipe -// with no server returns immediately rather than blocking. +// dial connects to the named pipe. The short timeout matters: a pipe with no +// server answers immediately instead of blocking. func dial(path string) (net.Conn, error) { - timeout := 2 * time.Second - return winio.DialPipe(path, &timeout) + wait := 2 * time.Second + return winio.DialPipe(path, &wait) } -// removeStaleSocket is a no-op on Windows: a named pipe disappears with the -// process that created it, so there is nothing stale to clean up. +// removeStaleSocket does nothing on Windows: a named pipe vanishes together +// with the process that created it, so no stale entry can be left behind. func removeStaleSocket(string) error { return nil } // userOnlySDDL builds "D:P(A;;GA;;;)(A;;GA;;;SY)": a protected DACL -// granting generic-all to the daemon's own user and to LocalSystem. +// that grants generic-all to the daemon's own user and to LocalSystem. func userOnlySDDL() (string, error) { - token := windows.GetCurrentProcessToken() - user, err := token.GetTokenUser() + processToken := windows.GetCurrentProcessToken() + tokenUser, err := processToken.GetTokenUser() if err != nil { return "", fmt.Errorf("reading process token: %w", err) } - sid := user.User.Sid.String() - if sid == "" { + userSID := tokenUser.User.Sid.String() + if userSID == "" { return "", fmt.Errorf("process token has no user SID") } - return fmt.Sprintf("D:P(A;;GA;;;%s)(A;;GA;;;SY)", sid), nil + return fmt.Sprintf("D:P(A;;GA;;;%s)(A;;GA;;;SY)", userSID), nil } diff --git a/engine/internal/daemon/lock.go b/engine/internal/daemon/lock.go index ed4a2731..2ea21e91 100644 --- a/engine/internal/daemon/lock.go +++ b/engine/internal/daemon/lock.go @@ -10,77 +10,79 @@ import ( "time" ) -// ErrAlreadyRunning is returned by AcquireLock when another daemon holds the -// lock. Its message carries the holder's pid when the lock file could be read. +// ErrAlreadyRunning is what AcquireLock returns when another daemon holds the +// lock. When the lock file could be read, the message names the holder's pid. type ErrAlreadyRunning struct { - PID int Path string + PID int } func (e *ErrAlreadyRunning) Error() string { - if e.PID > 0 { - return fmt.Sprintf("daemon already running (pid %d)", e.PID) + if e.PID <= 0 { + return "daemon already running" } - return "daemon already running" + return fmt.Sprintf("daemon already running (pid %d)", e.PID) } -// IsAlreadyRunning reports whether err is (or wraps) ErrAlreadyRunning. +// IsAlreadyRunning reports whether err is, or wraps, ErrAlreadyRunning. func IsAlreadyRunning(err error) bool { - var e *ErrAlreadyRunning - return errors.As(err, &e) + var target *ErrAlreadyRunning + return errors.As(err, &target) } -// Lock is the single-instance lock: an advisory lock (flock on Unix, -// LockFileEx on Windows) on /daemon.lock, whose contents are the -// holder's pid so a second `oberth serve` can name the process it lost to. +// Lock is the single-instance lock: an advisory lock (flock on Unix, LockFileEx +// on Windows) over /daemon.lock, holding the holder's pid in its +// contents so a second `oberth serve` can name the process it lost to. // -// Unix locks the whole file; Windows locks one byte far past the end of it, -// because a Windows lock also blocks reads of the range it covers and the pid -// has to stay readable while the lock is held (see lock_windows.go). +// On Unix the whole file is locked; on Windows one byte far past its end is, +// since a Windows lock also blocks reads of the range it covers and the pid +// must stay readable while the lock is held (see lock_windows.go). type Lock struct { - path string f *os.File + path string } // AcquireLock takes the lock without blocking. It returns ErrAlreadyRunning // when another process holds it. func AcquireLock(path string) (*Lock, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + err := os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { return nil, fmt.Errorf("creating lock directory: %w", err) } f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { if isLockedOpenError(err) { - // Windows can refuse the open itself while another process has the - // file locked or opened without sharing, where Unix would let us - // open it and only refuse the lock. It is the same answer — - // somebody else has it — so it gets the same error, and callers + // Windows may refuse the open itself while another process holds + // the file locked or opened without sharing, where Unix would let + // us open it and refuse only the lock. The answer is the same — + // somebody else has it — so it earns the same error, and callers // that retry (WaitForLockRelease) keep retrying. return nil, &ErrAlreadyRunning{PID: LockHolderPID(path), Path: path} } return nil, fmt.Errorf("opening lock file: %w", err) } - // A daemon spawns children (`oberth start`, `oberth up`, autostart). None of - // them may inherit the lock handle: an inherited handle keeps the lock — - // and, on Windows, the file itself — alive after the daemon that took it - // has exited. + // A daemon spawns children (`oberth start`, `oberth up`, autostart), and + // none of them may inherit the lock handle: an inherited handle would keep + // the lock — and on Windows the file itself — alive after the daemon that + // took it has exited. markNotInheritable(f) - if err := lockFile(f); err != nil { - pid := readLockPID(f) + err = lockFile(f) + if err != nil { + holderPID := readLockPID(f) f.Close() if errors.Is(err, errLockBusy) { - return nil, &ErrAlreadyRunning{PID: pid, Path: path} + return nil, &ErrAlreadyRunning{PID: holderPID, Path: path} } return nil, fmt.Errorf("locking %s: %w", path, err) } - // The lock is ours: replace whatever pid was in there with our own. - if err := f.Truncate(0); err == nil { - if _, err := f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0); err != nil { + // The lock is ours now, so whatever pid was in the file is replaced by ours. + if err = f.Truncate(0); err == nil { + if _, writeErr := f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0); writeErr != nil { unlockFile(f) f.Close() - return nil, fmt.Errorf("writing lock file: %w", err) + return nil, fmt.Errorf("writing lock file: %w", writeErr) } _ = f.Sync() } @@ -112,42 +114,43 @@ func (l *Lock) Path() string { return l.path } // that it is free. File existence and its diagnostic PID do not prove lock // ownership; the kernel releases a crashed process's lock automatically. // -// On Windows the wait has to survive one more failure mode: opening a file the +// On Windows the wait survives one further failure mode: opening a file the // outgoing daemon still holds fails outright (ERROR_LOCK_VIOLATION or -// ERROR_SHARING_VIOLATION) rather than blocking. AcquireLock reports those as -// ErrAlreadyRunning, so they are retried here instead of aborting the restart. +// ERROR_SHARING_VIOLATION) instead of blocking. AcquireLock reports both as +// ErrAlreadyRunning, so this loop retries them rather than aborting the +// restart. func WaitForLockRelease(path string, timeout time.Duration) error { deadline := time.Now().Add(timeout) var last error for { lock, err := AcquireLock(path) - if err == nil { - _ = lock.Release() - return nil - } - if !IsAlreadyRunning(err) { - // Anything other than "someone holds it" (an unreadable directory, - // say) will not resolve itself by waiting. - return err + if err != nil { + if !IsAlreadyRunning(err) { + // Anything except "somebody holds it" (an unreadable + // directory, say) will not fix itself by waiting. + return err + } + last = err + if !time.Now().Before(deadline) { + return fmt.Errorf("the previous daemon still holds %s after %s: %w", path, timeout, last) + } + time.Sleep(25 * time.Millisecond) + continue } - last = err - - if !time.Now().Before(deadline) { - return fmt.Errorf("the previous daemon still holds %s after %s: %w", path, timeout, last) - } - time.Sleep(25 * time.Millisecond) + _ = lock.Release() + return nil } } -// LockHolderPID reads the pid recorded in a lock file. It returns 0 when the -// file is missing or unreadable; it does not prove the process is alive. +// LockHolderPID reads the pid a lock file records. A missing or unreadable +// file yields 0; the value does not prove the process is alive. func LockHolderPID(path string) int { - data, err := os.ReadFile(path) + raw, err := os.ReadFile(path) if err != nil { return 0 } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) if err != nil { return 0 } @@ -155,12 +158,12 @@ func LockHolderPID(path string) int { } func readLockPID(f *os.File) int { - buf := make([]byte, 32) - n, _ := f.ReadAt(buf, 0) + raw := make([]byte, 32) + n, _ := f.ReadAt(raw, 0) if n <= 0 { return 0 } - pid, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))) + pid, err := strconv.Atoi(strings.TrimSpace(string(raw[:n]))) if err != nil { return 0 } diff --git a/engine/internal/daemon/lock_windows.go b/engine/internal/daemon/lock_windows.go index 50db06dc..20e54d17 100644 --- a/engine/internal/daemon/lock_windows.go +++ b/engine/internal/daemon/lock_windows.go @@ -9,41 +9,41 @@ import ( "golang.org/x/sys/windows" ) -// errLockBusy marks the "another process holds it" case so AcquireLock can -// turn it into ErrAlreadyRunning. +// errLockBusy marks the "another process holds it" case, which AcquireLock +// turns into ErrAlreadyRunning. var errLockBusy = errors.New("lock held by another process") -// The lock is a single byte at offset 1<<62, past any byte the lock file will -// ever hold. +// The locked region is a single byte at offset 1<<62, beyond any byte the lock +// file will ever hold. // -// LockFileEx makes the range it locks unreadable to every other process, so -// locking the whole file hid the one thing the file exists to publish: with a -// daemon running, LockHolderPID and ErrAlreadyRunning.PID both came back 0 and -// os.ReadFile failed with "another process has locked a portion of the file". -// A range that cannot overlap the pid keeps the semantics we want from flock — -// the lock lives on the open handle and dies with it — and leaves offset 0 -// readable to anyone who wants to know whose daemon is running. +// LockFileEx makes the range it locks unreadable to every other process, and +// locking the whole file therefore hid the one thing the file exists to +// publish: while a daemon ran, LockHolderPID and ErrAlreadyRunning.PID both +// came back 0 and os.ReadFile failed with "another process has locked a +// portion of the file". A range that cannot overlap the pid keeps the flock +// semantics wanted here — the lock lives on the open handle and dies with it — +// while leaving offset 0 readable to anyone asking whose daemon is running. const ( - lockOffsetLow = 0 - lockOffsetHigh = 1 << 30 // (1<<30)<<32 == byte 1<<62 of the file - lockBytesLow = 1 lockBytesHigh = 0 + lockBytesLow = 1 + lockOffsetHigh = 1 << 30 // (1<<30)<<32 == byte 1<<62 of the file + lockOffsetLow = 0 ) -// lockRange is the region LockFileEx and UnlockFileEx both address. The offset +// lockRange is the region both LockFileEx and UnlockFileEx address: the offset // travels in the OVERLAPPED, the length in the byte-count arguments. func lockRange() *windows.Overlapped { return &windows.Overlapped{Offset: lockOffsetLow, OffsetHigh: lockOffsetHigh} } func lockFile(f *os.File) error { - err := windows.LockFileEx(windows.Handle(f.Fd()), + lockErr := windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, lockBytesLow, lockBytesHigh, lockRange()) - if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING) { + if errors.Is(lockErr, windows.ERROR_LOCK_VIOLATION) || errors.Is(lockErr, windows.ERROR_IO_PENDING) { return errLockBusy } - return err + return lockErr } func unlockFile(f *os.File) error { @@ -52,20 +52,19 @@ func unlockFile(f *os.File) error { } // isLockedOpenError reports whether opening the lock file failed because -// another process has it locked or open without sharing. Windows refuses the -// open where Unix would refuse only the lock, and a restart has to read that -// as "still held, try again" rather than as a broken lock directory. -func isLockedOpenError(err error) bool { - return errors.Is(err, windows.ERROR_LOCK_VIOLATION) || - errors.Is(err, windows.ERROR_SHARING_VIOLATION) +// another process holds it locked or open without sharing. Windows refuses the +// open itself where Unix refuses only the lock, and a restart must read that +// as "still held, try again" instead of as a broken lock directory. +func isLockedOpenError(cause error) bool { + return errors.Is(cause, windows.ERROR_LOCK_VIOLATION) || + errors.Is(cause, windows.ERROR_SHARING_VIOLATION) } -// markNotInheritable clears the handle's inherit flag, so a child started by -// the daemon cannot keep the lock (or the lock file) alive after the daemon -// exits. Go already opens files with O_CLOEXEC on Windows; saying it again -// here costs one syscall and makes the requirement explicit, because a -// spawned child holding this handle is exactly how a daemon that has exited -// goes on looking like it is running. +// markNotInheritable clears the handle's inherit flag, so no child the daemon +// starts can keep the lock (or, on Windows, the lock file) alive after the +// daemon exits. Go already opens files with O_CLOEXEC here; repeating it costs +// one syscall and states the requirement explicitly — a spawned child holding +// this handle is exactly how an exited daemon keeps looking like a running one. func markNotInheritable(f *os.File) { _ = windows.SetHandleInformation(windows.Handle(f.Fd()), windows.HANDLE_FLAG_INHERIT, 0) } diff --git a/engine/internal/daemon/log.go b/engine/internal/daemon/log.go index 1c7973b3..d0c120ea 100644 --- a/engine/internal/daemon/log.go +++ b/engine/internal/daemon/log.go @@ -10,48 +10,50 @@ import ( "sync" ) -// Log rotation policy from the spec's "Error handling" section: 5 MiB per file, -// three generations kept (daemon.log, daemon.log.1, daemon.log.2). +// Log rotation policy from the spec's "Error handling" section: each file holds +// 5 MiB and three generations are kept (daemon.log, daemon.log.1, +// daemon.log.2). const ( LogMaxBytes = 5 << 20 LogKeep = 3 ) -// RotatingWriter appends to a file and rotates it once it passes MaxBytes, -// keeping Keep generations. It is safe for concurrent writers. +// RotatingWriter appends to a file and rotates it once it grows past MaxBytes, +// keeping Keep generations. Concurrent writers may share one. type RotatingWriter struct { + Keep int Path string MaxBytes int64 - Keep int + size int64 mu sync.Mutex f *os.File - size int64 } -// NewRotatingWriter opens path for appending, creating its directory. +// NewRotatingWriter opens path for appending and creates its directory. func NewRotatingWriter(path string, maxBytes int64, keep int) (*RotatingWriter, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + err := os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { return nil, fmt.Errorf("creating log directory: %w", err) } - w := &RotatingWriter{Path: path, MaxBytes: maxBytes, Keep: keep} - if err := w.open(); err != nil { + w := &RotatingWriter{MaxBytes: maxBytes, Keep: keep, Path: path} + if err = w.open(); err != nil { return nil, err } return w, nil } func (w *RotatingWriter) open() error { - f, err := os.OpenFile(w.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + file, err := os.OpenFile(w.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return fmt.Errorf("opening log file: %w", err) } - info, err := f.Stat() + stat, err := file.Stat() if err != nil { - f.Close() + file.Close() return err } - w.f, w.size = f, info.Size() + w.f, w.size = file, stat.Size() return nil } @@ -59,79 +61,81 @@ func (w *RotatingWriter) open() error { func (w *RotatingWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() - if w.f == nil { + file := w.f + if file == nil { return 0, os.ErrClosed } if w.MaxBytes > 0 && w.size+int64(len(p)) > w.MaxBytes { if err := w.rotate(); err != nil { return 0, err } + file = w.f } - n, err := w.f.Write(p) - w.size += int64(n) - return n, err + written, err := file.Write(p) + w.size += int64(written) + return written, err } -// rotate shifts daemon.log.N-1 → daemon.log.N and starts a fresh daemon.log. -// The caller holds the mutex. +// rotate renames daemon.log.N-1 → daemon.log.N and opens a fresh daemon.log. +// w.mu is held by the caller. func (w *RotatingWriter) rotate() error { if err := w.f.Close(); err != nil { return err } for i := w.Keep - 1; i >= 1; i-- { - from := w.Path + source := w.Path if i > 1 { - from = fmt.Sprintf("%s.%d", w.Path, i-1) + source = fmt.Sprintf("%s.%d", w.Path, i-1) } - to := fmt.Sprintf("%s.%d", w.Path, i) - if _, err := os.Stat(from); err != nil { + target := fmt.Sprintf("%s.%d", w.Path, i) + if _, err := os.Stat(source); err != nil { continue } - _ = os.Rename(from, to) + _ = os.Rename(source, target) } w.size = 0 return w.open() } -// Close closes the underlying file. +// Close closes the file the writer holds open. func (w *RotatingWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.f == nil { return nil } - err := w.f.Close() + closeErr := w.f.Close() w.f = nil - return err + return closeErr } -// ParseLogLevel maps the config's daemon.log_level to a slog level. Anything -// unrecognised (including the empty string) is info. +// ParseLogLevel turns the config's daemon.log_level into a slog level. Every +// value it does not recognise — the empty string included — means info. func ParseLogLevel(s string) slog.Level { switch strings.ToLower(strings.TrimSpace(s)) { case "debug": return slog.LevelDebug - case "warn", "warning": - return slog.LevelWarn case "error": return slog.LevelError + case "warn", "warning": + return slog.LevelWarn default: return slog.LevelInfo } } // NewLogger builds the daemon's logger. It always writes to the rotated log -// file so `oberth daemon log` has something to show, and additionally to stderr -// when the daemon runs in the foreground. +// file, so `oberth daemon log` has something to show, and additionally to +// stderr when the daemon runs in the foreground. func NewLogger(path string, level slog.Level, alsoStderr bool) (*slog.Logger, io.Closer, error) { file, err := NewRotatingWriter(path, LogMaxBytes, LogKeep) if err != nil { return nil, nil, err } - var w io.Writer = file + var sink io.Writer = file if alsoStderr { - w = io.MultiWriter(file, os.Stderr) + sink = io.MultiWriter(file, os.Stderr) } - h := slog.NewTextHandler(w, &slog.HandlerOptions{Level: level}) - return slog.New(h), file, nil + handler := slog.NewTextHandler(sink, &slog.HandlerOptions{Level: level}) + return slog.New(handler), file, nil } diff --git a/engine/internal/daemon/ports.go b/engine/internal/daemon/ports.go index d73f0b5b..cf770e26 100644 --- a/engine/internal/daemon/ports.go +++ b/engine/internal/daemon/ports.go @@ -9,22 +9,22 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// The read half of the ports namespace. Every handler here answers from the -// scanner's shared snapshot rather than scanning for itself, so N clients -// reading at once cost the machine one scan, not N. See readPorts. +// The read half of the ports namespace. Each handler answers from the scanner's +// shared snapshot instead of scanning for itself, so N clients reading at once +// still cost the machine one scan (see readPorts). func init() { RegisterHandler("ports.logs", handlePortsLogs) } // readPorts serves the rows every read handler starts from. // -// While subscribers are connected the scan loop is already running, so the -// cached snapshot is never more than one scan interval old and a plain read is -// answered from it. That is what keeps the scan counter flat: a `oberth status` -// next to a running `oberth watch` costs nothing, and ten of them cost nothing -// either. A read that asks for stats or health cannot use the cache unless a -// subscriber is already collecting them, and a read with no loop behind it -// scans for itself (reusing a scan younger than scanner.CacheTTL). +// With subscribers connected the scan loop is already running, so the cached +// snapshot is at most one scan interval old and a plain read can be answered +// from it; that is what keeps the scan counter flat — an `oberth status` beside +// a running `oberth watch` costs nothing, and ten of them cost nothing either. +// A read asking for stats or health cannot use the cache unless a subscriber is +// already collecting them, and a read with no loop behind it scans for itself +// (reusing a scan younger than scanner.CacheTTL). func readPorts(rt *Runtime, include scanner.Include) ([]state.Port, error) { snap, err := readSnapshot(rt, include) if err != nil { @@ -33,9 +33,9 @@ func readPorts(rt *Runtime, include scanner.Include) ([]state.Port, error) { return snap.Ports, nil } -// readSnapshot is readPorts plus the observation that came with the rows: a -// caller deciding whether an absent port is free needs the table's coverage, -// not only the rows. +// readSnapshot is readPorts plus the observation the rows came with: a caller +// deciding whether an absent port is free needs the table's coverage, not just +// its rows. func readSnapshot(rt *Runtime, include scanner.Include) (state.Snapshot, error) { if rt.Subscribers() > 0 { if snap := rt.Scanner.Cached(); snap.Seq > 0 && cacheCovers(snap, include) { @@ -50,82 +50,102 @@ func readSnapshot(rt *Runtime, include scanner.Include) (state.Snapshot, error) } // cacheCovers reports whether the cached snapshot was collected with the -// enrichments this read asked for. An empty port table trivially covers -// everything: there is nothing to enrich. +// enrichments this read asks for. An empty port table covers everything +// trivially: there is nothing to enrich. // -// The question is about the snapshot, not about every row in it, and asking it -// row by row was wrong. `stats` is null for a process whose numbers are all -// zero — a listener ps cannot see, a container the daemon has no reading for — -// and `health` is null for anything that was not probed, which on a machine -// with one such row made *every* stats read miss the cache and scan the -// machine again, while a subscriber was already collecting stats every second -// . One row carrying the enrichment is what says the scan -// behind this snapshot collected it. +// The question is about the snapshot as a whole, not about every row in it, and +// asking it row by row was wrong. `stats` is null for a process whose numbers +// are all zero — a listener ps cannot see, a container the daemon has no +// reading for — and `health` is null for anything not probed, so on a machine +// with one such row every stats read missed the cache and scanned the machine +// again, while a subscriber was already collecting stats every second. One row +// carrying the enrichment is what proves the scan behind this snapshot +// collected it. func cacheCovers(snap state.Snapshot, include scanner.Include) bool { - if !include.Stats && !include.Health { + wantsStats, wantsHealth := include.Stats, include.Health + if !wantsStats && !wantsHealth { return true } if len(snap.Ports) == 0 { return true } - stats, health := false, false + var hasStats, hasHealth bool for i := range snap.Ports { - stats = stats || snap.Ports[i].Stats != nil - health = health || snap.Ports[i].Health != nil + hasStats = hasStats || snap.Ports[i].Stats != nil + hasHealth = hasHealth || snap.Ports[i].Health != nil } - return (!include.Stats || stats) && (!include.Health || health) + return (!wantsStats || hasStats) && (!wantsHealth || hasHealth) } -// resolvePort finds the one row a selector addresses. A port bound to several -// addresses without a bind_address is ambiguous (1002), not a silent pick. +// resolvePort finds the single row a selector addresses. A port bound to +// several addresses without a bind_address is ambiguous (1002), never a silent +// pick. func resolvePort(rows []state.Port, sel rpc.Selector) (state.Port, error) { - var matches []state.Port - switch { - case sel.PID != nil: - for _, row := range rows { - if row.PID == *sel.PID { - matches = append(matches, row) - } - } - if len(matches) == 0 { - return state.Port{}, rpc.NewError(rpc.CodeNotFound, - fmt.Sprintf("no listening port found for pid %d", *sel.PID), - "run `oberth status` to see what is listening") - } - return matches[0], nil - case sel.Port != nil: - for _, row := range rows { - if row.Port != *sel.Port { - continue - } - if sel.BindAddress != nil && *sel.BindAddress != "" && row.BindAddress != *sel.BindAddress { - continue - } - matches = append(matches, row) - } - default: + if sel.PID != nil { + return portForPID(rows, *sel.PID) + } + if sel.Port == nil { return state.Port{}, rpc.NewError(rpc.CodeInvalidParams, "a selector needs a port or a pid", `send {"port": 3000} or {"pid": 1234}`) } - + matches := rowsForPort(rows, *sel.Port, sel.BindAddress) switch len(matches) { case 0: - if sel.BindAddress != nil && *sel.BindAddress != "" { - return state.Port{}, rpc.NewError(rpc.CodeNotFound, - fmt.Sprintf("no process found listening on %s:%d", *sel.BindAddress, *sel.Port), "") + return state.Port{}, portNotFound(*sel.Port, sel.BindAddress) + case 1: + return matches[0], nil + } + return state.Port{}, ambiguousPort(*sel.Port, matches) +} + +// portForPID returns the first row owned by pid. +func portForPID(rows []state.Port, pid int) (state.Port, error) { + var matches []state.Port + for _, row := range rows { + if row.PID == pid { + matches = append(matches, row) } + } + if len(matches) == 0 { return state.Port{}, rpc.NewError(rpc.CodeNotFound, - fmt.Sprintf("no process found listening on port %d", *sel.Port), + fmt.Sprintf("no listening port found for pid %d", pid), "run `oberth status` to see what is listening") - case 1: - return matches[0], nil - default: - addrs := make([]string, 0, len(matches)) - for _, m := range matches { - addrs = append(addrs, m.BindAddress) + } + return matches[0], nil +} + +// rowsForPort selects the rows on port, narrowed to bindAddress when the +// selector names one. +func rowsForPort(rows []state.Port, port int, bindAddress *string) []state.Port { + var matches []state.Port + for _, row := range rows { + if row.Port != port { + continue } - return state.Port{}, rpc.NewError(rpc.CodeAmbiguous, - fmt.Sprintf("port %d is bound to multiple addresses: %s", *sel.Port, strings.Join(addrs, ", ")), - fmt.Sprintf("pass a bind address (e.g. --ip %s)", addrs[0])) + if bindAddress != nil && *bindAddress != "" && row.BindAddress != *bindAddress { + continue + } + matches = append(matches, row) + } + return matches +} + +func portNotFound(port int, bindAddress *string) error { + if bindAddress != nil && *bindAddress != "" { + return rpc.NewError(rpc.CodeNotFound, + fmt.Sprintf("no process found listening on %s:%d", *bindAddress, port), "") + } + return rpc.NewError(rpc.CodeNotFound, + fmt.Sprintf("no process found listening on port %d", port), + "run `oberth status` to see what is listening") +} + +func ambiguousPort(port int, matches []state.Port) error { + addrs := make([]string, 0, len(matches)) + for _, m := range matches { + addrs = append(addrs, m.BindAddress) } + return rpc.NewError(rpc.CodeAmbiguous, + fmt.Sprintf("port %d is bound to multiple addresses: %s", port, strings.Join(addrs, ", ")), + fmt.Sprintf("pass a bind address (e.g. --ip %s)", addrs[0])) } diff --git a/engine/internal/daemon/ports_logs.go b/engine/internal/daemon/ports_logs.go index 8e7ff935..932999f0 100644 --- a/engine/internal/daemon/ports_logs.go +++ b/engine/internal/daemon/ports_logs.go @@ -117,42 +117,43 @@ func descriptorPaths(pid int) []string { // as stream.chunk notifications. func handlePortsLogs(ctx context.Context, req *Request) (any, error) { var p rpc.PortsLogsParams - if err := req.Bind(&p); err != nil { + err := req.Bind(&p) + if err != nil { return nil, err } - lines := p.Lines - if lines <= 0 { - lines = defaultLogLines + requested := p.Lines + if requested <= 0 { + requested = defaultLogLines } - rows, err := readPorts(req.Runtime, scanner.Include{}) + listening, err := readPorts(req.Runtime, scanner.Include{}) if err != nil { return nil, err } - row, err := resolvePort(rows, p.Selector) + match, err := resolvePort(listening, p.Selector) if err != nil { return nil, err } - source, err := resolveLogSource(row, lines) + source, err := resolveLogSource(match, requested) if err != nil { return nil, err } if !p.Follow { - out, err := source.Tail(ctx, lines) + out, err := source.Tail(ctx, requested) if err != nil { return nil, err } return rpc.PortsLogsResult{ Source: source.Label(), Lines: out, - Truncated: len(out) >= lines, + Truncated: len(out) >= requested, }, nil } initial := rpc.PortsLogsResult{Source: source.Label(), Lines: []string{}} return StartStream(ctx, req, initial, func(ctx context.Context, s *Stream) (any, error) { - return nil, source.Follow(ctx, lines, func(line string) error { + return nil, source.Follow(ctx, requested, func(line string) error { return s.Send(rpc.PortsLogsChunk{Source: source.Label(), Line: line}) }) }) @@ -178,16 +179,16 @@ func streamCommand(ctx context.Context, argv []string, emit func(string) error) if _, err := exec.LookPath(argv[0]); err != nil { return rpc.NewError(rpc.CodeUnsupported, argv[0]+" is not on PATH", "") } - cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) - pipe, err := cmd.StdoutPipe() + command := exec.CommandContext(ctx, argv[0], argv[1:]...) + pipe, err := command.StdoutPipe() if err != nil { return rpc.NewError(rpc.CodeInternal, "opening the log pipe: "+err.Error(), "") } - cmd.Stderr = cmd.Stdout - if err := cmd.Start(); err != nil { + command.Stderr = command.Stdout + if err := command.Start(); err != nil { return rpc.NewError(rpc.CodeInternal, fmt.Sprintf("starting %s: %v", argv[0], err), "") } - defer func() { _ = cmd.Wait() }() + defer func() { _ = command.Wait() }() scanner := bufio.NewScanner(pipe) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) diff --git a/engine/internal/daemon/server.go b/engine/internal/daemon/server.go index 77e412b1..89f565d6 100644 --- a/engine/internal/daemon/server.go +++ b/engine/internal/daemon/server.go @@ -18,11 +18,11 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// DefaultIdleTimeout is the config default for daemon.idle_timeout. Zero means -// "never idle out". +// DefaultIdleTimeout is the config default for daemon.idle_timeout; zero means +// the daemon never idles out. const DefaultIdleTimeout = 30 * time.Minute -// idleCheckInterval bounds how often the idle watchdog wakes. +// idleCheckInterval caps how often the idle watchdog can wake. const idleCheckInterval = 5 * time.Second // stateReplayCapacity bounds the in-memory resume window. A cursor older than @@ -62,18 +62,18 @@ type Options struct { Scanner *scanner.Loop // an injected loop; nil means the OS scanner } -// Server is the daemon: a listener, a set of connections, the subscription -// fan-out and the process lifecycle. +// Server is the daemon: the listener it owns, the connections it tracks, the +// fan-out that feeds subscribers, and its own lifecycle. type Server struct { opts Options logger *slog.Logger socket string - ln net.Listener runtime *Runtime loop *scanner.Loop + ln net.Listener - lock *Lock ownLock bool + lock *Lock nextConnID atomic.Uint64 nextWorkspaceEvent atomic.Uint64 @@ -95,11 +95,11 @@ type Server struct { pendingMu sync.Mutex pending []state.Event + graceful atomic.Bool + wg sync.WaitGroup stopOnce sync.Once stopping chan struct{} done chan struct{} - graceful atomic.Bool - wg sync.WaitGroup // producers counts stream goroutines across every connection. They run // after their handler returns and outlive the connection entry, which // removeConn deletes, so they are counted on the server rather than the @@ -135,18 +135,18 @@ func New(opts Options) *Server { return server } -// withServerDefaults fills in what a caller may leave empty, so the rest of the -// constructor never has to ask. +// withServerDefaults fills in whatever a caller left empty, so no later step of +// construction has to ask. func withServerDefaults(opts Options) Options { if opts.Logger == nil { opts.Logger = slog.New(slog.DiscardHandler) } - if opts.Socket == "" { - opts.Socket = SocketPath() - } if opts.Version == "" { opts.Version = "dev" } + if opts.Socket == "" { + opts.Socket = SocketPath() + } return opts } @@ -174,11 +174,11 @@ func (s *Server) buildRuntime(opts Options) *Runtime { binary, _ = os.Executable() } return &Runtime{ + StartedAt: time.Now(), Version: opts.Version, Socket: s.socket, BinaryPath: binary, PID: os.Getpid(), - StartedAt: time.Now(), Logger: opts.Logger, Scanner: s.loop, srv: s, @@ -214,9 +214,9 @@ func (s *Server) releaseSingleInstance() { } } -// Serve takes the single-instance lock, listens, and accepts connections until -// ctx is cancelled or Shutdown is called. It returns ErrAlreadyRunning when -// another daemon holds the lock. +// Serve takes the single-instance lock, listens, then accepts connections until +// ctx is cancelled or Shutdown is called. A second daemon on the same socket +// gets ErrAlreadyRunning. func (s *Server) Serve(ctx context.Context) error { defer close(s.done) @@ -225,23 +225,23 @@ func (s *Server) Serve(ctx context.Context) error { } defer s.releaseSingleInstance() - // The lock is ours, so any socket file still on disk belongs to a daemon - // that died without cleaning up. - if err := removeStaleSocket(s.socket); err != nil { + // The lock is ours, so a socket file still on disk was left behind by a + // daemon that died without cleaning up. + err := removeStaleSocket(s.socket) + if err != nil { return fmt.Errorf("removing stale socket %s: %w", s.socket, err) } - ln, err := listen(s.socket) + s.ln, err = listen(s.socket) if err != nil { return fmt.Errorf("listening on %s: %w", s.socket, err) } - s.ln = ln ctx, cancel := context.WithCancel(ctx) defer cancel() - // The store comes up before the scanner so the first tick already resolves - // groups with the stored pins and publishes the stored names. + // Opening the store before the scanner lets the very first tick resolve + // groups with the stored pins and publish the stored names. s.openStore() defer s.closeStore() @@ -249,7 +249,7 @@ func (s *Server) Serve(ctx context.Context) error { // Optional hooks retain their existing adapter, but failed required work // must not be presented as an empty successful daemon. if err := runStartHooks(s.runtime); err != nil { - _ = ln.Close() + _ = s.ln.Close() runShutdownHooks(false) _ = removeStaleSocket(s.socket) return fmt.Errorf("starting daemon: %w", err) @@ -262,13 +262,7 @@ func (s *Server) Serve(ctx context.Context) error { "socket", s.socket, "pid", s.runtime.PID, "version", s.opts.Version, "idle_timeout", s.opts.IdleTimeout.String()) - go func() { - select { - case <-ctx.Done(): - s.Shutdown() - case <-s.stopping: - } - }() + go s.shutdownWhenDone(ctx) acceptErr := s.accept(ctx) @@ -300,6 +294,16 @@ func (s *Server) runBackground(work func()) { }() } +// shutdownWhenDone stops the daemon when the serve context ends. A shutdown +// that is already under way needs nothing from here. +func (s *Server) shutdownWhenDone(ctx context.Context) { + select { + case <-ctx.Done(): + s.Shutdown() + case <-s.stopping: + } +} + // accept is the accept loop. // // A failure here is only a real failure when the listener was not supposed to @@ -337,20 +341,18 @@ func (s *Server) startConn(ctx context.Context, nc net.Conn) { s.touch() s.logger.Debug("client connected", "conn", c.id) - // The writer is tracked on the connection, not on the server: it has to be + // The writer is tracked on the connection, not on the server: it must be // known to be finished before the socket is closed, and that ordering is // the connection's own business. c.writerWG.Add(1) - go c.writeLoop() + go func() { c.writeLoop() }() - s.wg.Add(1) - go func() { - defer s.wg.Done() + s.runBackground(func() { // Closing here means a client that stops sending tears down its own // connection, whether it disconnected or hit an error. defer c.Close() c.readLoop(ctx) - }() + }) } // registerConn adds a connection to the registry under the lock the publisher @@ -369,16 +371,22 @@ func (s *Server) removeConn(c *Conn, reason string) { s.broadcastWorkspaceChanged(workspace, nil) } - // The keepalive count has to be recomputed before anything else can ask - // whether an idle daemon may stop, and the wake makes the next scan see the - // ports this client was watching go quiet. - s.recountKeepalive() - s.touch() + // The keepalive count is recomputed before anything can ask whether an idle + // daemon may stop, and the wake makes the next scan see the ports this + // client was watching go quiet. + s.markDisconnected() s.logger.Debug("client disconnected", "conn", c.id, "reason", reason) runDisconnectHooks(c.id) s.loop.Wake() } +// markDisconnected records that a client went away: the keepalive count drops +// first, then the idle clock restarts. +func (s *Server) markDisconnected() { + s.recountKeepalive() + s.touch() +} + // forgetConn drops a connection from the registry, detaching any workspace // relation it held on the way out. The workspace is returned rather than // broadcast here, because the broadcast must not run under subsMu. @@ -410,9 +418,9 @@ func (s *Server) closeAllConns(reason string) { } } -// Shutdown stops the daemon. It broadcasts state.event{daemon_stopping} to -// every subscriber first (spec, daemon.shutdown), gives the writers a moment to -// flush, and then closes the listener. +// Shutdown stops the daemon. Subscribers first get +// state.event{daemon_stopping} (spec, daemon.shutdown), the writers then get a +// moment to flush, and only after that does the listener close. func (s *Server) Shutdown() { s.stopOnce.Do(func() { s.graceful.Store(true) @@ -437,9 +445,9 @@ func (s *Server) announceStopping() { // Done is closed once Serve has returned. func (s *Server) Done() <-chan struct{} { return s.done } -// drainQueues waits, up to timeout, for every connection's outbound queue to -// empty, so a client sees daemon_stopping (and the reply to daemon.shutdown) -// before the socket goes away. +// drainQueues gives every connection's outbound queue up to timeout to empty, +// so daemon_stopping (and the reply to daemon.shutdown) reaches a client before +// the socket goes away. func (s *Server) drainQueues(timeout time.Duration) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { @@ -475,17 +483,19 @@ func (s *Server) Subscribers() int { return subscribers } -// Clients counts open connections, subscribed or not. +// Clients is the number of open connections, whether subscribed or not. func (s *Server) Clients() int { s.subsMu.RLock() defer s.subsMu.RUnlock() return len(s.conns) } -// demand is the scanner's Demand callback: how many subscribers there are and -// the union of what they asked to have collected. +// demand is the scanner's Demand callback: the subscriber count and the union +// of what those subscribers asked to have collected. func (s *Server) demand() (int, scanner.Include) { s.subsMu.RLock() + // Read under the same lock the publisher takes, so the count and the + // include set describe one and the same registry. defer s.subsMu.RUnlock() var wanted scanner.Include @@ -528,12 +538,12 @@ var marshalSnapshot = json.Marshal // the publish lock, so no delta can slip in between the opening frame and the // live stream. // -// The reply never waits for a scan: the snapshot is whatever the loop last -// published — with stats and health null when the loop has not been collecting -// them — and Wake makes the loop scan immediately, so the fields the subscriber -// asked for arrive in the first delta rather than delaying the reply by a scan. -// A scoped view keeps the daemon's global sequence; gaps are expected when an -// intervening publish changed only rows outside the selected scope. +// The reply never waits on a scan: it carries whatever the loop last published, +// with stats and health null while the loop is not collecting them. Wake makes +// the loop scan at once, so the fields a subscriber asked for arrive in the +// first delta instead of holding the reply back by a scan. A scoped view keeps +// the daemon's global sequence; gaps are expected when an intervening publish +// changed only rows outside the selected scope. // // Nothing is registered until the opening frame has been encoded: the old // workspace relation is detached, the new one is validated and the reply is @@ -1248,11 +1258,11 @@ func filterPort(row state.Port, include scanner.Include) state.Port { // ------------------------------------------------------------------- idle --- -// touch records client activity, resetting the idle countdown. +// touch records client activity and so resets the idle countdown. func (s *Server) touch() { s.lastActive.Store(time.Now().UnixNano()) } -// recountKeepalive recomputes how many connected clients asked the daemon to -// stay up (daemon.hello{keepalive:true}). +// recountKeepalive recomputes the number of connected clients that asked the +// daemon to stay up (daemon.hello{keepalive:true}). func (s *Server) recountKeepalive() { s.keepalives.Store(s.countKeepalive()) } @@ -1287,33 +1297,40 @@ func (s *Server) idleCheckEvery() time.Duration { return tick } -// watchIdle stops the daemon once it has been idle for IdleTimeout. A -// keepalive client or any subscriber counts as activity, so the desktop app -// keeps the daemon alive simply by staying connected. +// watchIdle stops the daemon after IdleTimeout without activity. A keepalive +// client or any subscriber counts as activity, so the desktop app keeps the +// daemon alive just by staying connected. func (s *Server) watchIdle(ctx context.Context) { if s.opts.IdleTimeout <= 0 { return } - t := time.NewTicker(s.idleCheckEvery()) - defer t.Stop() + ticker := time.NewTicker(s.idleCheckEvery()) + defer ticker.Stop() for { select { case <-ctx.Done(): return case <-s.stopping: return - case <-t.C: - if s.keepalives.Load() > 0 || s.Subscribers() > 0 { + case <-ticker.C: + if s.watched() { s.touch() continue } idle := time.Since(time.Unix(0, s.lastActive.Load())) - if idle >= s.opts.IdleTimeout { - s.logger.Info("idle timeout reached, stopping", - "idle", idle.Round(time.Second).String()) - s.Shutdown() - return + if idle < s.opts.IdleTimeout { + continue } + s.logger.Info("idle timeout reached, stopping", + "idle", idle.Round(time.Second).String()) + s.Shutdown() + return } } } + +// watched reports whether a keepalive client or a subscriber is still watching, +// which is what keeps an idle daemon up. +func (s *Server) watched() bool { + return s.keepalives.Load() > 0 || s.Subscribers() > 0 +} diff --git a/engine/internal/daemon/service_ports.go b/engine/internal/daemon/service_ports.go index ace57087..01ff7905 100644 --- a/engine/internal/daemon/service_ports.go +++ b/engine/internal/daemon/service_ports.go @@ -44,13 +44,14 @@ func AcquireServicePort(rt *Runtime, dir, service string) (int, error) { } // configForGroup is the oberth.yaml whose services are published under a group -// name, or nil for a group no config describes. +// name; a group no config describes yields nil. func configForGroup(rt *Runtime, name string) *groups.Config { if rt.Scanner == nil { return nil } - for _, cfg := range rt.Scanner.Configs() { - if rt.Scanner.GroupOf(cfg) == name { + configs := rt.Scanner.Configs() + for _, cfg := range configs { + if name == rt.Scanner.GroupOf(cfg) { return cfg } } diff --git a/engine/internal/daemon/socket.go b/engine/internal/daemon/socket.go index af1117ab..ff828b7a 100644 --- a/engine/internal/daemon/socket.go +++ b/engine/internal/daemon/socket.go @@ -54,21 +54,25 @@ func LockPath() string { return lockPathFor(SocketPath()) } func lockPathFor(socket string) string { return paths.Lock(socket) } -// Dial connects to a listening daemon at path. It is the one place clients -// learn whether the address is a unix socket or a named pipe. -func Dial(path string) (net.Conn, error) { return dial(path) } +// Dial connects to a listening daemon at path. Clients learn from this one +// place whether the address is a unix socket or a named pipe. +func Dial(path string) (net.Conn, error) { + return dial(path) +} -// Listen binds the daemon's socket at path with the platform's same-user-only -// permissions: a 0700 directory holding a 0600 socket on Unix, a pipe whose -// DACL grants only the current user and SYSTEM on Windows. -func Listen(path string) (net.Listener, error) { return listen(path) } +// Listen binds the daemon's socket at path under the platform's +// same-user-only permissions: on Unix a 0700 directory holding a 0600 socket, +// on Windows a pipe whose DACL grants only the current user and SYSTEM. +func Listen(path string) (net.Listener, error) { + return listen(path) +} -// SocketAlive reports whether something is accepting connections at path. +// SocketAlive reports whether something accepts connections at path. func SocketAlive(path string) bool { - c, err := dial(path) + conn, err := dial(path) if err != nil { return false } - _ = c.Close() + _ = conn.Close() return true } diff --git a/engine/internal/daemon/store.go b/engine/internal/daemon/store.go index bc846d55..04a45d66 100644 --- a/engine/internal/daemon/store.go +++ b/engine/internal/daemon/store.go @@ -7,82 +7,84 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/store" ) -// openStore opens the daemon's SQLite database and hands it to the runtime and -// the scanner. A database that cannot be opened at all is not fatal: the daemon -// keeps serving live state, only renames, pins and history are unavailable — -// which is strictly better than refusing to report ports because of a disk -// problem. +// openStore opens the daemon's SQLite database and installs it on the runtime +// and the scanner. A database that cannot be opened is not fatal: live state is +// still served, only renames, pins and history go missing — strictly better +// than refusing to report ports over a disk problem. // // A database that was unusable and had to be recreated raises -// state.event{kind: "db_reset"}, queued so the first subscriber sees it even -// though nobody is connected this early (daemon spec, "Error handling"). +// state.event{kind: "db_reset"}, queued so that the first subscriber sees it +// even though nobody is connected this early (daemon spec, "Error handling"). func (s *Server) openStore() { path := s.opts.DBPath if path == "" { path = store.Path() } - st, err := store.Open(path) + db, err := store.Open(path) if err != nil { s.logger.Error("opening the database, continuing without renames, pins and history", "path", path, "error", err) return } - s.setStore(st) - s.logger.Info("database open", "path", st.DBPath()) + s.setStore(db) + s.logger.Info("database open", "path", db.DBPath()) - if reset := st.Reset(); reset != nil { - s.logger.Warn("database was unusable and has been recreated", - "path", reset.Path, "moved_to", reset.MovedTo, "reason", reset.Reason) - s.queueEvent(state.Event{ - Kind: "db_reset", - At: time.Now().Format(time.RFC3339), - Data: map[string]any{ - "path": reset.Path, - "moved_to": reset.MovedTo, - "reason": reset.Reason, - "recovered": true, - }, - }) + reset := db.Reset() + if reset == nil { + return } + s.logger.Warn("database was unusable and has been recreated", + "path", reset.Path, "moved_to", reset.MovedTo, "reason", reset.Reason) + s.queueEvent(state.Event{ + Data: map[string]any{ + "recovered": true, + "path": reset.Path, + "moved_to": reset.MovedTo, + "reason": reset.Reason, + }, + Kind: "db_reset", + At: time.Now().Format(time.RFC3339), + }) } -// setStore installs an open store on the runtime and the scan loop. Tests use -// it to hand a harness a temp database. -func (s *Server) setStore(st *store.Store) { - s.runtime.Store = st - s.runtime.DB = st.DB() - s.loop.SetStore(st) +// setStore installs an open store on the runtime and the scan loop. Tests hand +// a harness its temp database through it. +func (s *Server) setStore(db *store.Store) { + s.runtime.Store = db + s.runtime.DB = db.DB() + s.loop.SetStore(db) } -// closeStore releases the database on shutdown. +// closeStore releases the database when the daemon shuts down. func (s *Server) closeStore() { - if s.runtime.Store == nil { + db := s.runtime.Store + if db == nil { return } - if err := s.runtime.Store.Close(); err != nil { + if err := db.Close(); err != nil { s.logger.Warn("closing the database", "error", err) } s.runtime.Store, s.runtime.DB = nil, nil } -// queueEvent holds an event until the next publish carries it out. The daemon -// raises events before anyone can be listening — db_reset happens while the -// socket is still coming up — and dropping them would make the one condition a -// client most needs to know about invisible. +// queueEvent holds an event until the next publish carries it out. Events can +// be raised before anyone is able to listen — db_reset happens while the socket +// is still coming up — and dropping them would hide the one condition a client +// most needs to know about. func (s *Server) queueEvent(ev state.Event) { s.pendingMu.Lock() - defer s.pendingMu.Unlock() s.pending = append(s.pending, ev) + s.pendingMu.Unlock() } // takePending drains the queued events for the next publish. func (s *Server) takePending() []state.Event { s.pendingMu.Lock() defer s.pendingMu.Unlock() - if len(s.pending) == 0 { + pending := s.pending + if len(pending) == 0 { return nil } - out := s.pending s.pending = nil - return out + return pending } diff --git a/engine/internal/daemon/stream.go b/engine/internal/daemon/stream.go index 9e6fa3df..ee8c4465 100644 --- a/engine/internal/daemon/stream.go +++ b/engine/internal/daemon/stream.go @@ -11,104 +11,111 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" ) -// streamSeq numbers subscriptions for the life of the process. Ids are unique -// across connections so a log line can name one without also naming a client. +// streamSeq numbers subscriptions for the process's whole life. Ids are unique +// across connections, so a log line can name one without naming a client too. var streamSeq atomic.Uint64 -// nextStreamID allocates the id a stream's chunks carry. -func nextStreamID() string { return fmt.Sprintf("sub-%d", streamSeq.Add(1)) } +// nextStreamID allocates the id carried by a stream's chunks. +func nextStreamID() string { + n := streamSeq.Add(1) + return fmt.Sprintf("sub-%d", n) +} -// Stream is one running streaming method: the daemon replies to -// the call with a subscription id, pushes `stream.chunk {id, data}` -// notifications, and finishes with `stream.end {id, data?, error?}`. +// Stream is a running streaming method: the daemon answers the call with a +// subscription id, pushes `stream.chunk {id, data}` notifications, and closes +// with `stream.end {id, data?, error?}`. // // A stream ends when its producer returns, when the client sends -// `stream.cancel {id}`, or when the connection drops — the last two both cancel -// the producer's context, so a producer only has to honour ctx. +// `stream.cancel {id}`, or when the connection drops — the latter two both +// cancel the producer's context, so honouring ctx is all a producer needs. type Stream struct { - id string conn *Conn + id string - ctx context.Context cancel context.CancelFunc + ctx context.Context once sync.Once } -// ID is the subscription id the client was handed. +// ID is the subscription id handed to the client. func (s *Stream) ID() string { return s.id } -// Context is cancelled by stream.cancel, by the client disconnecting and by +// Context is cancelled by stream.cancel, by the client disconnecting and by a // daemon shutdown. Producers must select on it. func (s *Stream) Context() context.Context { return s.ctx } -// Done is closed when the stream is cancelled. +// Done is closed once the stream is cancelled. func (s *Stream) Done() <-chan struct{} { return s.ctx.Done() } -// Send pushes one chunk. It reports the cancellation cause once the stream is -// over, so a producer that ignores Context still stops on the first Send. +// Send pushes one chunk. Once the stream is over it reports the cancellation +// cause, so a producer that ignores Context still stops at the first Send. func (s *Stream) Send(data any) error { - if err := s.ctx.Err(); err != nil { + err := s.ctx.Err() + if err != nil { return err } - raw, err := json.Marshal(data) + encoded, err := json.Marshal(data) if err != nil { return fmt.Errorf("marshalling stream chunk: %w", err) } - return s.conn.Notify(rpc.MethodStreamChunk, rpc.StreamChunk{ID: s.id, Data: raw}) + return s.conn.Notify(rpc.MethodStreamChunk, rpc.StreamChunk{ID: s.id, Data: encoded}) } // finish deregisters the stream and sends stream.end exactly once. A cancelled -// stream still gets an end notification, without an error: the client asked for -// it, so it is not a failure. +// stream still gets its end notification, without an error: the client asked +// for the cancellation, so it is not a failure. func (s *Stream) finish(data any, err error) { - s.once.Do(func() { - s.conn.CancelStream(s.id) - end := rpc.StreamEnd{ID: s.id} - if data != nil { - if raw, mErr := json.Marshal(data); mErr == nil { - end.Data = raw - } - } - if err != nil && !errors.Is(err, context.Canceled) { - end.Error = errorFor(err) + s.once.Do(func() { s.sendEnd(data, err) }) +} + +func (s *Stream) sendEnd(data any, failure error) { + s.conn.CancelStream(s.id) + ending := rpc.StreamEnd{ID: s.id} + if data != nil { + if encoded, mErr := json.Marshal(data); mErr == nil { + ending.Data = encoded } - _ = s.conn.Notify(rpc.MethodStreamEnd, end) - }) + } + if failure != nil && !errors.Is(failure, context.Canceled) { + ending.Error = errorFor(failure) + } + _ = s.conn.Notify(rpc.MethodStreamEnd, ending) } -// StreamFunc produces a stream's chunks. It returns the payload of stream.end -// (nil for a stream with no final result) and an error, which becomes -// stream.end's error object. Returning ctx.Err() after a cancellation is not an -// error and is reported as a clean end. +// StreamFunc produces a stream's chunks. Its returned payload becomes +// stream.end's body (nil for a stream with no final result), and its error +// becomes stream.end's error object. Returning ctx.Err() after a cancellation +// is not an error: it is reported as a clean end. type StreamFunc func(ctx context.Context, s *Stream) (any, error) // StartStream turns a handler into a streaming method. It allocates a // subscription id, replies to the call with `initial` plus that id, registers -// the stream so stream.cancel and disconnect can stop it, and runs produce in -// its own goroutine. +// the stream so stream.cancel and a disconnect can stop it, and finally runs +// produce on its own goroutine. // -// initial must marshal to a JSON object (or be nil, for a bare -// {subscription_id}). The reply is queued before produce starts, so no chunk -// can overtake the id it belongs to. +// initial has to marshal to a JSON object; nil stands for a bare +// {subscription_id} reply. The reply is queued before produce starts, so no +// chunk can overtake the id it belongs to. // -// Handlers return its two values directly: it always reports ErrResponseSent. +// A handler returns its two values directly: the error is always +// ErrResponseSent. func StartStream(ctx context.Context, req *Request, initial any, produce StreamFunc) (any, error) { id := nextStreamID() - result, err := withSubscriptionID(initial, id) + reply, err := withSubscriptionID(initial, id) if err != nil { return nil, err } sctx, cancel := context.WithCancel(ctx) - s := &Stream{id: id, conn: req.Conn, ctx: sctx, cancel: cancel} + s := &Stream{conn: req.Conn, id: id, ctx: sctx, cancel: cancel} req.Conn.RegisterStream(id, cancel) - req.Conn.replyResult(req.ID, result) + req.Conn.replyResult(req.ID, reply) - // A producer outlives the handler that started it and the connection entry, - // which removeConn deletes, so it is counted on the server: it writes run - // and exit facts, and the store closes as soon as that wait returns. + // A producer outlives the handler that started it and the connection entry + // removeConn deletes, so it is counted on the server: it writes run and exit + // facts, and the store closes as soon as that wait returns. srv := req.Conn.srv if srv != nil { srv.producers.Add(1) @@ -118,30 +125,31 @@ func StartStream(ctx context.Context, req *Request, initial any, produce StreamF defer srv.producers.Done() } defer cancel() - end, perr := produce(sctx, s) - s.finish(end, perr) + payload, produceErr := produce(sctx, s) + s.finish(payload, produceErr) }() return nil, ErrResponseSent } // withSubscriptionID merges the subscription id into a streaming method's -// initial result. A non-object initial payload is a programming error in a -// handler, not something a client can provoke, so it surfaces as an internal -// error rather than invalid params. +// initial result. An initial payload that is not an object is a handler's +// programming error — no client can provoke it — so it surfaces as an internal +// error instead of invalid params. func withSubscriptionID(initial any, id string) (json.RawMessage, error) { if initial == nil { return json.Marshal(rpc.StreamStart{SubscriptionID: id}) } - raw, err := json.Marshal(initial) + encoded, err := json.Marshal(initial) if err != nil { return nil, rpc.NewError(rpc.CodeInternal, "marshalling stream reply: "+err.Error(), "") } - fields := map[string]json.RawMessage{} - if err := json.Unmarshal(raw, &fields); err != nil { + object := map[string]json.RawMessage{} + err = json.Unmarshal(encoded, &object) + if err != nil { return nil, rpc.NewError(rpc.CodeInternal, - fmt.Sprintf("a streaming method must reply with a JSON object, got %s", raw), "") + fmt.Sprintf("a streaming method must reply with a JSON object, got %s", encoded), "") } - fields["subscription_id"], _ = json.Marshal(id) - return json.Marshal(fields) + object["subscription_id"], _ = json.Marshal(id) + return json.Marshal(object) } diff --git a/engine/internal/daemon/testguard.go b/engine/internal/daemon/testguard.go index c0b255cf..c8444972 100644 --- a/engine/internal/daemon/testguard.go +++ b/engine/internal/daemon/testguard.go @@ -6,46 +6,48 @@ import ( "strings" ) -// A Go test binary must never become a oberth daemon. +// A Go test binary must never turn into a oberth daemon. // -// `go test` links each package into an executable named .test. Handing -// that executable the arguments `serve --detach` does not start a daemon: Go's -// flag package stops parsing at the first non-flag argument, so `serve` and -// `--detach` are ignored and the binary runs the whole test suite again, in -// the background, against whatever HOME it inherited. A test that reaches the -// autostart path therefore forks a copy of itself every time it runs, and any -// test in that copy which reaches the same path forks again — which is how one -// afternoon of `go test` left 2,150 `cmd.test serve --detach` processes behind -// and exhausted swap. +// `go test` links each package into an executable named .test. Passing +// that executable `serve --detach` does not start a daemon: Go's flag package +// stops parsing at the first non-flag argument, so `serve` and `--detach` are +// ignored and the binary re-runs the whole test suite in the background +// against whatever HOME it inherited. Any test reaching the autostart path +// thus forks a copy of itself on every run, and any test in that copy reaching +// the same path forks again — one afternoon of `go test` left 2,150 +// `cmd.test serve --detach` processes behind and exhausted swap that way. // -// The guard lives here because both ends need it: the client that would spawn -// the daemon (internal/daemon/client.Autostart) and the `oberth serve` that -// would become one (internal/cmd). +// Both ends need the guard, which is why it lives here: the client that would +// spawn the daemon (internal/daemon/client.Autostart) and the `oberth serve` +// that would become one (internal/cmd). // AllowTestDaemonEnv opts a test binary back in to acting as a daemon. It -// exists so the guard can be exercised, and so a future harness that genuinely -// wants a test binary as its daemon can say so out loud. Nothing in the tree -// sets it. +// exists so the guard itself can be exercised, and so a future harness that +// really wants a test binary as its daemon can say so out loud. Nothing in the +// tree sets it. const AllowTestDaemonEnv = "BERTH_ALLOW_TEST_DAEMON" // IsTestBinary reports whether path names a Go test binary. `go test` always -// names one .test (.test.exe on Windows), whether it runs it from -// the build cache or `go test -c` writes it out. +// names one .test (.test.exe on Windows), whether it runs it out of +// the build cache or `go test -c` writes it to disk. func IsTestBinary(path string) bool { - base := strings.ToLower(filepath.Base(path)) - base = strings.TrimSuffix(base, ".exe") - return strings.HasSuffix(base, ".test") + name := filepath.Base(path) + name = strings.ToLower(name) + name = strings.TrimSuffix(name, ".exe") + return strings.HasSuffix(name, ".test") } // TestDaemonAllowed reports whether AllowTestDaemonEnv opts this process in. -func TestDaemonAllowed() bool { return EnvEnabled(os.Getenv(AllowTestDaemonEnv)) } +func TestDaemonAllowed() bool { + return EnvEnabled(os.Getenv(AllowTestDaemonEnv)) +} -// EnvEnabled reads a boolean environment variable the way the shell reads one: -// unset, empty, "0", "false" and "no" are off, anything else is on. option-berth's -// env switches are all opt-in flags, so "set to anything" has to mean on. +// EnvEnabled reads a boolean environment variable the way a shell does: unset, +// empty, "0", "false" and "no" are off; everything else is on. option-berth's +// environment switches are all opt-in flags, so "set at all" has to mean on. func EnvEnabled(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { - case "", "0", "false", "no": + case "0", "no", "", "false": return false } return true From ce13c42788ff5bda6ea0d8c7413948cd4646a4d5 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 16:32:20 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor(engine):=20daemon=20=E5=AD=90?= =?UTF-8?q?=E5=8C=85=E6=8C=89=E5=A5=91=E7=BA=A6=E9=87=8D=E8=BF=B0=EF=BC=8C?= =?UTF-8?q?schema=20=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rpc 的线上结构与 schema、runsreg 的注册/退出与改名、groupstart 的地址 登记按字段语义重述。JSON tag 序列、结构体字段顺序、错误码与判定顺序未 变;协议 schema 的逐字节回归通过。生产代码复制段归零。 Co-Authored-By: Claude Code --- engine/internal/daemon/groupstart/start.go | 12 +- engine/internal/daemon/rpc/methods.go | 217 +++++++++++++-------- engine/internal/daemon/rpc/schema.go | 34 ++-- engine/internal/daemon/rpc/types.go | 99 +++++++--- engine/internal/daemon/runsreg/exits.go | 164 ++++++++++------ engine/internal/daemon/runsreg/handlers.go | 135 ++++++++----- engine/internal/daemon/runsreg/registry.go | 193 +++++++++++------- 7 files changed, 543 insertions(+), 311 deletions(-) diff --git a/engine/internal/daemon/groupstart/start.go b/engine/internal/daemon/groupstart/start.go index 6809c0c7..13810098 100644 --- a/engine/internal/daemon/groupstart/start.go +++ b/engine/internal/daemon/groupstart/start.go @@ -37,12 +37,12 @@ import ( // start: a slow database must not mean nothing came up. const DependencyTimeout = 30 * time.Second -// dependencyTimeout is the value actually used, so tests do not have to wait -// out the real one to see a timeout reported. +// dependencyTimeout is what the wait actually uses. Tests shorten it so a +// timeout is observable without sitting out the real budget. var dependencyTimeout = DependencyTimeout -// dependencyPoll is how often the wait re-reads the daemon's state. The -// scanner's own cache means a poll costs nothing until it is stale. +// dependencyPoll paces the re-reads of the daemon's state while the wait for a +// dependency runs. A poll costs nothing until the scanner's cache goes stale. const dependencyPoll = 250 * time.Millisecond // startupCheckWindow catches commands that fail before they can become a @@ -748,10 +748,10 @@ func newAddressBook(ctx context.Context, rt *daemon.Runtime, cfg *groups.Config, // one that is not gets its claim. Both answers are memoized, including the // error, so one start asks the allocator once per name. func (b *addressBook) port(name string) (int, error) { - if port, ok := b.ports[name]; ok { + if port, known := b.ports[name]; known { return port, nil } - if err, ok := b.errs[name]; ok { + if err, failed := b.errs[name]; failed { return 0, err } port, err := b.resolve(name) diff --git a/engine/internal/daemon/rpc/methods.go b/engine/internal/daemon/rpc/methods.go index 5444d8f1..faf70adf 100644 --- a/engine/internal/daemon/rpc/methods.go +++ b/engine/internal/daemon/rpc/methods.go @@ -134,6 +134,9 @@ type DaemonHelloResult struct { } type DaemonStatusResult struct { + // PID and Uptime identify the process and say how long it has been + // answering; Subscribers counts the live state subscriptions, and LastScanAt + // is when the port scanner last finished a pass. PID int `json:"pid"` Uptime string `json:"uptime"` Subscribers int `json:"subscribers"` @@ -175,7 +178,8 @@ type DoctorCheck struct { // ID names the check. A check that exists once per external tool is dotted // with that tool: `mcp_registered.claude_code`. ID string `json:"id"` - // Status is ok, warn, fail or skip. Only `fail` makes the run not ok. + // Status is the verdict: ok, warn, fail or skip. Only `fail` makes the whole + // run not ok. Status string `json:"status"` // Summary is the one line a table row shows. Summary string `json:"summary"` @@ -200,14 +204,15 @@ type DaemonDoctorParams struct { } // DaemonDoctorResult is what both `oberth doctor --json` and `daemon.doctor` -// return. OK is false when any check failed. +// return. OK is false when at least one check failed. type DaemonDoctorResult struct { OK bool `json:"ok"` Checks []DoctorCheck `json:"checks"` // Version is the version of the process that ran the checks: the CLI for // `oberth doctor`, the daemon for `daemon.doctor`. Version string `json:"version"` - // DaemonVersion is the daemon's version, empty when it is not reachable. + // DaemonVersion is the version the daemon answered with, empty when no + // daemon is reachable: `oberth doctor` still runs with none up. DaemonVersion string `json:"daemon_version"` } @@ -265,12 +270,19 @@ type StateSubscribeParams struct { // ----------------------------------------------------------------- ports --- type PortsKillParams struct { - Targets []Selector `json:"targets"` - Tree bool `json:"tree,omitempty"` - Force bool `json:"force,omitempty"` - GraceMs int `json:"grace_ms,omitempty"` - Escalate *bool `json:"escalate,omitempty"` - DryRun bool `json:"dry_run,omitempty"` + Targets []Selector `json:"targets"` + // Tree stops each target's whole process tree. A listener an + // option-berth-started run owns is stopped as a tree either way. + Tree bool `json:"tree,omitempty"` + // Force is already the strongest signal, so it skips the wait; GraceMs is + // the SIGTERM to SIGKILL window, zero meaning the engine's default. + Force bool `json:"force,omitempty"` + GraceMs int `json:"grace_ms,omitempty"` + // Escalate is three-valued: nil escalates after the grace window, an + // explicit false stops at SIGTERM. DryRun plans the kill and performs none + // of it. + Escalate *bool `json:"escalate,omitempty"` + DryRun bool `json:"dry_run,omitempty"` } type PortsLogsParams struct { @@ -282,12 +294,18 @@ type PortsLogsParams struct { // PortsLogsResult answers a one-shot call. With follow set, the reply also // carries a subscription_id and the lines keep arriving as PortsLogsChunk. type PortsLogsResult struct { - Source string `json:"source"` - Lines []string `json:"lines"` - Truncated bool `json:"truncated"` - SubscriptionID string `json:"subscription_id,omitempty"` -} - + Source string `json:"source"` + Lines []string `json:"lines"` + // Truncated says the tail filled the requested line count, so Lines is a + // window onto the end of the log rather than the whole of it. + Truncated bool `json:"truncated"` + // SubscriptionID is set only on a follow, which keeps sending the rest of + // the output as PortsLogsChunk. + SubscriptionID string `json:"subscription_id,omitempty"` +} + +// PortsLogsChunk is one line pushed by a following ports.logs. Source is +// repeated on every chunk so a multiplexed reader can tell two logs apart. type PortsLogsChunk struct { Source string `json:"source"` Line string `json:"line"` @@ -454,8 +472,8 @@ type GroupsStartChunk struct { WaitingOn []string `json:"waiting_on,omitempty"` LastPrepareMS *int64 `json:"last_prepare_ms,omitempty" jsonschema:"nullable"` // LogOffset is how long log_path already was when this service started. The - // file is appended to across runs, so a client following it begins here to - // show this run and not the ones before. + // file is appended to across runs, so a client that follows it begins here, + // which shows this run and not the generations before it. LogOffset int64 `json:"log_offset,omitempty"` Skipped bool `json:"skipped,omitempty"` Reason string `json:"reason,omitempty"` @@ -517,40 +535,41 @@ type GroupConfig struct { Name string `json:"name"` Services []state.Service `json:"services"` Ports []int `json:"ports"` - // WorktreePorts is the file's worktree_ports: how many ports a claim for - // this project takes when it names no count. Null when the - // file has no such key. + // WorktreePorts is the file's worktree_ports: the number of ports a claim for + // this project takes when it names no count. Null when the key is absent. WorktreePorts *int `json:"worktree_ports" jsonschema:"nullable"` } type GroupsConfigGetParams struct { + // Name and Path name the file to read: the group as the daemon publishes it, + // or the oberth.yaml itself. A path wins when both are sent. Name *string `json:"name,omitempty"` Path *string `json:"path,omitempty"` } +// GroupsConfigGetResult is where the file was found and what it declares. type GroupsConfigGetResult struct { Path string `json:"path"` Config GroupConfig `json:"config"` } // GroupsConfigSetParams is one atomic edit of an `oberth.yaml`. The four lists -// may be combined in a single call and -// are applied in this order — Remove, Rename, Add, then the Services metadata -// patches — with each list seeing the file as the previous ones left it. -// Nothing is written unless the whole edit succeeds and the result still -// validates, so a call that fails leaves the file byte-identical. +// may be combined in a single call, and they are applied in this order — +// Remove, Rename, Add, then the Services metadata patches — each list seeing the +// file as the previous ones left it. Nothing is written unless the whole edit +// succeeds and the result still validates, so a failed call leaves the file +// byte-identical. type GroupsConfigSetParams struct { Path string `json:"path"` - // Services patches the metadata of services that already exist. + // Services patches the metadata of services that already exist; Add appends + // new ones, where a name or a port the file already uses is `conflict`. Services []groups.ServiceEdit `json:"services,omitempty"` - // Add appends services. A name or a port the file already uses is - // `conflict`. - Add []groups.ServiceAdd `json:"add,omitempty"` + Add []groups.ServiceAdd `json:"add,omitempty"` // Rename renames services everywhere in the file, depends_on references - // included. An unknown `from` is `not_found`, a `to` already in the file is + // included. An unknown `from` is `not_found`; a `to` already in the file is // `conflict`. Rename []groups.ServiceRename `json:"rename,omitempty"` - // Remove deletes services and drops them from every other service's + // Remove deletes services, and drops them from every other service's // depends_on. An unknown name is `not_found`. Remove []string `json:"remove,omitempty"` // WorktreePorts edits the top-level worktree_ports key: omitted leaves it @@ -559,14 +578,14 @@ type GroupsConfigSetParams struct { // invalid. WorktreePorts *int `json:"worktree_ports,omitempty" jsonschema:"nullable"` - // WorktreePortsSent records that worktree_ports was present, null - // included, because a pointer alone cannot tell null from absent. - // UnmarshalJSON fills it; a Go caller sets it by hand. + // WorktreePortsSent records that worktree_ports was present, null included: + // a pointer alone cannot tell null from absent. UnmarshalJSON fills it in; a + // Go caller sets it by hand. WorktreePortsSent bool `json:"-" jsonschema:"-"` } -// UnmarshalJSON decodes the params and remembers whether worktree_ports was -// sent at all. +// UnmarshalJSON decodes the params and remembers whether worktree_ports was sent +// at all. func (p *GroupsConfigSetParams) UnmarshalJSON(data []byte) error { // The alias drops this type's own UnmarshalJSON, so decoding it cannot // recurse back here. @@ -607,6 +626,8 @@ type GroupsConfigSetResult struct { Config GroupConfig `json:"config"` } +// GroupsReloadResult reports a reload of every known manifest: how many files +// loaded, and the ones that could not be used. type GroupsReloadResult struct { Loaded int `json:"loaded"` Errors []ConfigProblem `json:"errors"` @@ -619,15 +640,16 @@ type ConfigProblem struct { } // GroupsInitParams asks for a proposed `oberth.yaml` for the checkout at -// RootDir. Write is the contract's opt-in to actually writing it, so the -// default is a preview; Force overwrites an existing file, while Merge appends -// into one instead. Force and Merge are mutually -// exclusive. +// RootDir. type GroupsInitParams struct { RootDir string `json:"root_dir"` - Write bool `json:"write,omitempty"` - Force bool `json:"force,omitempty"` - Merge bool `json:"merge,omitempty"` + // Write is the contract's opt-in to actually writing the file, so the + // default is a preview. + Write bool `json:"write,omitempty"` + // Force overwrites an existing file and Merge appends into one instead; the + // two are mutually exclusive. + Force bool `json:"force,omitempty"` + Merge bool `json:"merge,omitempty"` // Services replaces the proposed service list with the caller's own, so a // curated file can be written in one call. The entries are taken as given: // nothing is copied onto them from what happens to be listening on their @@ -643,8 +665,12 @@ type GroupsInitParams struct { YAML string `json:"yaml,omitempty"` } +// GroupsInitResult is the file as it now stands, together with the proposal it +// was written from. type GroupsInitResult struct { MutationResult + // Path is where the file now is, YAML what it holds, and Proposal the parsed + // rows a client renders its preview from. Path string `json:"path"` YAML string `json:"yaml"` Proposal state.Group `json:"proposal"` @@ -652,16 +678,24 @@ type GroupsInitResult struct { // ------------------------------------------------------------------ runs --- +// RunsRegisterParams is what a caller that started a process itself reports: +// which process it is, what it was started as, and where. type RunsRegisterParams struct { - PID int `json:"pid"` - PPID int `json:"ppid"` - Group string `json:"group"` - Name string `json:"name"` - Cmd string `json:"cmd"` - Cwd string `json:"cwd"` - PortHint *int `json:"port_hint,omitempty"` - StartedAt string `json:"started_at"` - ID *string `json:"id,omitempty"` + // PID is the process the caller started and PPID its parent; Group and Name + // are the manifest identity it was started under. + PID int `json:"pid"` + PPID int `json:"ppid"` + Group string `json:"group"` + Name string `json:"name"` + // Cmd and Cwd are what it was started with, and StartedAt when it began; + // PortHint is the port it is expected to bind, if the caller knows one. + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + PortHint *int `json:"port_hint,omitempty"` + StartedAt string `json:"started_at"` + // ID lets the caller name the run itself. Absent, the registry gives it one, + // which is what keeps a recycled pid attributable. + ID *string `json:"id,omitempty"` // Session is the agent session that asked for this run. The caller detects // it: `oberth start` reads its own environment, which is the agent's, while // the daemon's is not. @@ -682,35 +716,39 @@ type RunsUnregisterParams struct { // ExitCode is how the run ended, for a caller that waited on it — // `oberth start` without --detach. Absent simply forgets the run. ExitCode *int `json:"exit_code,omitempty"` - // Stopped says the run was asked to stop (a Ctrl+C), so a non-zero exit + // Stopped says the run was asked to stop (a Ctrl+C, say), so a non-zero exit // code is not a crash. Stopped bool `json:"stopped,omitempty"` } type RunsListResult struct { Runs []RunRecord `json:"runs"` - // Exited is the runs that have ended, newest first, with their exit code - // and the last lines they logged. The daemon keeps a bounded history in its - // store and restores it when it restarts. + // Exited is the runs that have ended, newest first, with the code each one + // exited on and the last lines it logged. The daemon keeps a bounded history + // in its store and restores it when it restarts. Exited []RunRecord `json:"exited"` } type RunRecord struct { - ID string `json:"id"` - PID int `json:"pid"` - Group string `json:"group"` - Name string `json:"name"` - Cmd string `json:"cmd"` - Cwd string `json:"cwd"` + ID string `json:"id"` + PID int `json:"pid"` + // Group and Name are the manifest identity the run was started under; Cmd + // and Cwd are what it was started with. + Group string `json:"group"` + Name string `json:"name"` + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + // StartedAt is RFC 3339, and Ports are the ones the last scan saw this run + // holding. StartedAt string `json:"started_at"` Ports []int `json:"ports"` // PortHint is the port `oberth start --port` said this run would bind, or // the one groups.start assigned a `port: auto` service. PortHint *int `json:"port_hint,omitempty"` - // URL is where that port answers, for a run that has one. + // URL is the address that port answers on, for a run that has one. URL string `json:"url,omitempty"` - // Status is "starting" while a run with a port hint has not bound it yet, - // "running" otherwise, and "exited" for a run in the exited list. + // Status is "starting" while a port-hinted run has not bound its port yet, + // "running" otherwise, and "exited" for a run that is in the exited list. Status string `json:"status"` // ConfigPath, StartID and Origin say where the run came from: the // oberth.yaml it was started from, the `groups.start` that started it with @@ -749,45 +787,66 @@ type RunRecord struct { } type RunsSpawnParams struct { - Argv []string `json:"argv"` - Cwd string `json:"cwd"` - Env map[string]string `json:"env,omitempty"` - Group *string `json:"group,omitempty"` - Name *string `json:"name,omitempty"` - PortHint *int `json:"port_hint,omitempty"` - Session *state.Session `json:"session,omitempty"` - AllowOutsideHome bool `json:"allow_outside_home,omitempty"` + Argv []string `json:"argv"` + // Cwd is where the command runs. Omitted, the daemon resolves its own + // working directory. + Cwd string `json:"cwd"` + // Env is layered over the daemon's own environment. + Env map[string]string `json:"env,omitempty"` + // Group and Name are the identity the caller wants the run recorded under; + // they are resolved, not taken literally. + Group *string `json:"group,omitempty"` + Name *string `json:"name,omitempty"` + // PortHint is the port the caller expects this run to bind. + PortHint *int `json:"port_hint,omitempty"` + // Session is the agent session that asked for the run, and AllowOutsideHome + // opts out of the daemon's home-directory refusal. + Session *state.Session `json:"session,omitempty"` + AllowOutsideHome bool `json:"allow_outside_home,omitempty"` } +// RunsSpawnResult is the started run: its id, the process behind it, and the +// file its output goes to. type RunsSpawnResult struct { MutationResult - RunID string `json:"run_id"` - PID int `json:"pid"` + RunID string `json:"run_id"` + PID int `json:"pid"` + // LogPath is where the detached process appends what it prints. LogPath string `json:"log_path"` } +// SessionsKillParams stops what a session started. ID names the session; Tree, +// Force and DryRun mean what they mean for a port kill. type SessionsKillParams struct { - ID string `json:"id"` - Tree bool `json:"tree,omitempty"` - Force bool `json:"force,omitempty"` - DryRun bool `json:"dry_run,omitempty"` + ID string `json:"id"` + Tree bool `json:"tree,omitempty"` + // Force skips the polite signal, and DryRun only reports what would be + // stopped. + Force bool `json:"force,omitempty"` + DryRun bool `json:"dry_run,omitempty"` } // ---------------------------------------------------------------- config --- +// ConfigGetResult is the daemon's config file as a plain map, so a key this +// build does not know survives a round trip through it. type ConfigGetResult struct { Config map[string]any `json:"config"` } +// ConfigSetParams carries the keys to change. They are merged into the file as +// written, which is why the same map type comes back. type ConfigSetParams struct { Patch map[string]any `json:"patch"` } +// ConfigSetResult is the config as it stands after the write. type ConfigSetResult struct { OK bool `json:"ok"` Config map[string]any `json:"config"` } +// ConfigPathResult is where the daemon reads and writes its config. type ConfigPathResult struct { Path string `json:"path"` } diff --git a/engine/internal/daemon/rpc/schema.go b/engine/internal/daemon/rpc/schema.go index 3dc6a0b5..bf220c9f 100644 --- a/engine/internal/daemon/rpc/schema.go +++ b/engine/internal/daemon/rpc/schema.go @@ -48,15 +48,10 @@ var ( // Describe registers a method's wire contract. The packages that own a // namespace call it from their own init(). func Describe(method string, params, result, chunk, end any) { - registry[method] = Description{ // keyed by method: a later registration of - // the same name replaces the earlier one, which is how an owning package - // can correct a definition the core set declared - Method: method, - Params: params, - Result: result, - Chunk: chunk, - End: end, - } + // The registry is keyed by method, so a later registration of the same name + // replaces the earlier one: that is how an owning package corrects a + // definition the core set declared. + registry[method] = Description{Method: method, Params: params, Result: result, Chunk: chunk, End: end} } // DescribeNotification registers a notification the daemon pushes. Without it @@ -121,14 +116,19 @@ type NotificationSchema struct { // Document is the bundle `oberth daemon schema` prints and // docs/schema/protocol.schema.json holds. type Document struct { - Schema string `json:"$schema"` - ID string `json:"$id"` - Title string `json:"title"` - Description string `json:"description"` - ProtocolVersion string `json:"protocol_version"` - Definitions map[string]json.RawMessage `json:"definitions"` - Methods map[string]MethodSchema `json:"methods"` - Notifications map[string]NotificationSchema `json:"notifications"` + Schema string `json:"$schema"` + ID string `json:"$id"` + Title string `json:"title"` + // Description is the first thing a reader of the file meets; it says where + // the document came from and that it is generated rather than written. + Description string `json:"description"` + ProtocolVersion string `json:"protocol_version"` + // The collections a client generates from: the named data-model types, each + // method's params and result (with the two stream payloads), and the + // notifications the daemon pushes. + Definitions map[string]json.RawMessage `json:"definitions"` + Methods map[string]MethodSchema `json:"methods"` + Notifications map[string]NotificationSchema `json:"notifications"` } // schemaID is the document's canonical $id. It is a location, not a fetchable diff --git a/engine/internal/daemon/rpc/types.go b/engine/internal/daemon/rpc/types.go index 91fa08b6..86446ad7 100644 --- a/engine/internal/daemon/rpc/types.go +++ b/engine/internal/daemon/rpc/types.go @@ -25,55 +25,94 @@ const ProtocolVersion = "2.1.0" // Request is a client call. ID is raw so a client can use numbers or strings. type Request struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id,omitempty"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` + JSONRPC string `json:"jsonrpc"` + // ID is echoed back on the reply. Without one the message is the + // notification shape instead of a call. + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` } // Response is the reply to a Request. Exactly one of Result and Error is set. type Response struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` - Result json.RawMessage `json:"result,omitempty"` - Error *Error `json:"error,omitempty"` + // Result carries the method's result type; Error takes its place when the + // call failed. + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` } // Notification is a server-initiated message with no reply. type Notification struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` + JSONRPC string `json:"jsonrpc"` + // Method names what happened; Params is that notification's own payload + // type, one per name. + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` } -// Message is the union the decoder produces: any of the three above. Callers -// classify it with IsRequest / IsResponse / IsNotification. +// Message is the union the decoder produces: whichever of the three above +// arrived. Callers classify it with IsRequest, IsResponse and IsNotification, +// which share one classifier so the three cannot disagree. type Message struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *Error `json:"error,omitempty"` + // Params, Result and Error are whichever of the three shapes the message + // turned out to be; the classifier below is what decides. + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` } +// messageKind is what a decoded Message turned out to be. A message that is +// neither a call, a notification nor a reply has no kind at all, and none of the +// three predicates answers true for it. +type messageKind int + +const ( + kindUnknown messageKind = iota + kindRequest + kindNotification + kindResponse +) + // IsNotification reports whether the message has a method but no id. -func (m Message) IsNotification() bool { return m.ID == nil && m.Method != "" } +func (m Message) IsNotification() bool { return m.kind() == kindNotification } // IsRequest reports whether the message has both an id and a method. -func (m Message) IsRequest() bool { return m.ID != nil && m.Method != "" } +func (m Message) IsRequest() bool { return m.kind() == kindRequest } // IsResponse reports whether the message is a reply (id, no method). -func (m Message) IsResponse() bool { return m.ID != nil && m.Method == "" } +func (m Message) IsResponse() bool { return m.kind() == kindResponse } + +// kind classifies the message once, so the three predicates cannot drift apart: +// an id together with a method is a call, a method alone is a notification, and +// an id alone is a reply. +func (m Message) kind() messageKind { + switch { + case m.Method == "" && m.ID == nil: + return kindUnknown + case m.Method == "": + return kindResponse + case m.ID == nil: + return kindNotification + default: + return kindRequest + } +} -// Streaming and broadcast notification names. Every streaming -// method replies with a subscription id and then pushes stream.chunk -// notifications until stream.end; state.subscribe is the exception and keeps -// its own broadcast names. +// The notification names. Every streaming method replies with a subscription id +// and then pushes stream.chunk messages until stream.end; state.subscribe is the +// exception and keeps its own broadcast names. const ( MethodStreamChunk = "stream.chunk" MethodStreamEnd = "stream.end" MethodStreamCancel = "stream.cancel" + + // The state broadcasts: delta and event are what an unscoped subscriber + // reads, changed is the redacted scoped stream. MethodStateDelta = "state.delta" MethodStateEvent = "state.event" MethodStateChanged = "state.changed" @@ -81,27 +120,29 @@ const ( // StreamChunk is the payload of a stream.chunk notification. type StreamChunk struct { - ID string `json:"id"` + ID string `json:"id"` + // Data is one chunk of the method's own chunk type, left undecoded here: a + // client reads it against the stream_chunk entry of the method it called. Data json.RawMessage `json:"data"` } -// StreamEnd is the payload of a stream.end notification. Data carries the -// method's final result (ports.wait's {ready, timed_out}, for example) and is -// absent for streams that have nothing to say at the end. Error is set when the -// stream ended because of a failure. +// StreamEnd is the payload of a stream.end notification, which closes the +// stream. Data carries the method's final result (ports.wait's +// {ready, timed_out}, for example) and is absent for a stream with nothing to +// say at the end; Error is set when the stream ended because of a failure. type StreamEnd struct { ID string `json:"id"` Data json.RawMessage `json:"data,omitempty"` Error *Error `json:"error,omitempty"` } -// StreamCancel is the params of a stream.cancel call. +// StreamCancel is the params of a stream.cancel call: the subscription to stop. type StreamCancel struct { ID string `json:"id"` } -// StreamStart is the minimum every streaming method returns immediately: -// the subscription id the chunks will carry. +// StreamStart is what every streaming method answers with immediately: the +// subscription id its chunks will carry. type StreamStart struct { SubscriptionID string `json:"subscription_id"` } diff --git a/engine/internal/daemon/runsreg/exits.go b/engine/internal/daemon/runsreg/exits.go index 07ce4a22..a3856931 100644 --- a/engine/internal/daemon/runsreg/exits.go +++ b/engine/internal/daemon/runsreg/exits.go @@ -33,16 +33,19 @@ const ( const ( // maxExits caps the exit history. maxExits = store.RunExitLimit - // lastLineCount and tailBytes bound the log lines kept with an exit. + + // lastLineCount and tailBytes bound the log lines an exit keeps. lastLineCount = 20 tailBytes = 16 << 10 - // stopWindow is how long after an exit a kill may still correct it to - // stopped: the kill can win the race against being marked as one. + + // stopWindow bounds how long a kill may still correct a just-recorded exit + // to stopped: the kill can win the race against being marked as one. stopWindow = 30 * time.Second ) -// Meta is what a caller knows about where a run comes from, beyond the spawn -// request itself. +// Meta is what a caller knows about a run beyond the spawn request: the +// manifest it was started from, the start that grouped it with its siblings, +// the client that asked, and the identity of what ran. type Meta struct { ConfigPath string StartID string @@ -55,22 +58,29 @@ type Meta struct { // through unchanged so a future caller kind does not need a daemon change. func Origin(req *daemon.Request) string { if req == nil || req.Conn == nil { + // A request with no connection has named no client, so the run it is + // about to register stays origin-less rather than misattributed. return "" } name, _, _ := req.Conn.Client() return name } -// Exit is a run that ended: the record it had, and how it ended. +// Exit is a run that ended: the record it carried while it was live, and how it +// ended. type Exit struct { Record - Code int - Reason string + // Code is the process's own exit status; Reason is the normalized outcome + // the wire publishes, which is not always what the code alone says. + Code int + Reason string + // ExitedAt and LastLines are the when and the tail: enough for a reader to + // place a run that is gone without opening its log file. ExitedAt time.Time LastLines []string } -// ServiceExit is the exit as a service row carries it. +// ServiceExit projects the exit onto the row a service carries. func (e Exit) ServiceExit() state.ServiceExit { return state.ServiceExit{ Code: e.Code, @@ -81,15 +91,16 @@ func (e Exit) ServiceExit() state.ServiceExit { } } +// exitReason names how a run ended from the two facts the exit itself carries: +// whether it was asked to stop, and the code it left. func exitReason(code int, stopped bool) string { - switch { - case stopped: + if stopped { return ReasonStopped - case code == 0: + } + if code == 0 { return ReasonExited - default: - return ReasonCrashed } + return ReasonCrashed } func occupiedExit(reason string, lines []string) string { @@ -227,26 +238,46 @@ func (r *Registry) StoppingWithReason(pids []int, reason string) { func (r *Registry) LastExit(group, service string) (state.ServiceExit, bool) { r.mu.Lock() defer r.mu.Unlock() + if r.hasRunLocked(group, service) { + return state.ServiceExit{}, false + } + e, ok := r.newestExitLocked(func(e Exit) bool { return e.Group == group && e.Name == service }) + if !ok { + return state.ServiceExit{}, false + } + return e.ServiceExit(), true +} + +// hasRunLocked reports whether a run of this group and service is registered at +// all, stopping or not. The caller holds mu. +func (r *Registry) hasRunLocked(group, service string) bool { for _, rec := range r.runs { if rec.Group == group && rec.Name == service { - return state.ServiceExit{}, false + return true } } + return false +} + +// newestExitLocked is the one backward scan the exit queries share: the exit +// history is oldest first, so the newest match is found from its end. The caller +// holds mu. +func (r *Registry) newestExitLocked(matches func(Exit) bool) (Exit, bool) { for i := len(r.exits) - 1; i >= 0; i-- { - if e := r.exits[i]; e.Group == group && e.Name == service { - return e.ServiceExit(), true + if matches(r.exits[i]) { + return r.exits[i], true } } - return state.ServiceExit{}, false + return Exit{}, false } -// Exits returns the exit history, newest first. +// Exits returns the recorded exits, newest first. func (r *Registry) Exits() []Exit { r.mu.Lock() defer r.mu.Unlock() - out := make([]Exit, 0, len(r.exits)) - for i := len(r.exits) - 1; i >= 0; i-- { - out = append(out, r.exits[i]) + out := make([]Exit, len(r.exits)) + for i, e := range r.exits { + out[len(out)-1-i] = e } return out } @@ -258,12 +289,7 @@ func (r *Registry) Exits() []Exit { func (r *Registry) LatestExit(group, service string) (Exit, bool) { r.mu.Lock() defer r.mu.Unlock() - for i := len(r.exits) - 1; i >= 0; i-- { - if r.exits[i].Group == group && r.exits[i].Name == service { - return r.exits[i], true - } - } - return Exit{}, false + return r.newestExitLocked(func(e Exit) bool { return e.Group == group && e.Name == service }) } // ExitForPID returns the recorded exit of one process. The startup window uses @@ -273,12 +299,7 @@ func (r *Registry) LatestExit(group, service string) (Exit, bool) { func (r *Registry) ExitForPID(pid int) (Exit, bool) { r.mu.Lock() defer r.mu.Unlock() - for i := len(r.exits) - 1; i >= 0; i-- { - if r.exits[i].PID == pid { - return r.exits[i], true - } - } - return Exit{}, false + return r.newestExitLocked(func(e Exit) bool { return e.PID == pid }) } func storeRow(e Exit) store.RunExitRow { @@ -305,48 +326,71 @@ func exitFromStore(row store.RunExitRow) Exit { } } -// tailLines returns the last n lines a run wrote to its log: from offset, where -// the run began, or from the top of a file that was rotated since. At most -// tailBytes are read; a line cut by that limit is dropped. +// tailLines returns the last n lines a run wrote to its log: counted from +// offset, where the run began, or from the top of a file that was rotated since. +// A line cut by the tailBytes window is dropped rather than half-reported. func tailLines(path string, offset int64, n int) []string { - f, err := os.Open(path) - if err != nil { + tail, ok := readTail(path, offset) + if !ok { return nil } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil - } - size := info.Size() - if offset > size { - offset = 0 - } - start := offset - if size-start > tailBytes { - start = size - tailBytes - } - buf := make([]byte, size-start) - if _, err := f.ReadAt(buf, start); err != nil && !errors.Is(err, io.EOF) { - return nil - } - text := strings.TrimRight(string(buf), "\r\n") + text := strings.TrimRight(string(tail.data), "\r\n") if text == "" { return nil } lines := strings.Split(text, "\n") - if start > offset && len(lines) > 1 { + if tail.start > tail.from && len(lines) > 1 { + // The read did not open on a line boundary, so its first entry is the + // tail of a line the reader never saw the start of. lines = lines[1:] } if len(lines) > n { lines = lines[len(lines)-n:] } - for i := range lines { - lines[i] = strings.TrimRight(lines[i], "\r") + for i, line := range lines { + lines[i] = strings.TrimRight(line, "\r") } return lines } +// logTail is the part of a log file one tail is read from. from is where the +// run's own output begins and start is where the read actually opened, which +// differs when the file outgrew the tailBytes window. +type logTail struct { + data []byte + start int64 + from int64 +} + +// readTail reads the bytes a tail may come from. A missing or unreadable file +// has no tail at all. +func readTail(path string, offset int64) (logTail, bool) { + f, err := os.Open(path) + if err != nil { + return logTail{}, false + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return logTail{}, false + } + size := info.Size() + if offset > size { + // The file is shorter than where the run began: it was rotated, so the + // whole of it belongs to this run. + offset = 0 + } + tail := logTail{from: offset, start: offset} + if size-tail.start > tailBytes { + tail.start = size - tailBytes + } + tail.data = make([]byte, size-tail.start) + if _, err := f.ReadAt(tail.data, tail.start); err != nil && !errors.Is(err, io.EOF) { + return logTail{}, false + } + return tail, true +} + // serviceURL is the address a run's port hint stands for. func serviceURL(port int) string { return "http://localhost:" + strconv.Itoa(port) } diff --git a/engine/internal/daemon/runsreg/handlers.go b/engine/internal/daemon/runsreg/handlers.go index aa6dc752..7b333dc5 100644 --- a/engine/internal/daemon/runsreg/handlers.go +++ b/engine/internal/daemon/runsreg/handlers.go @@ -34,11 +34,15 @@ var Default = New() const pruneInterval = scanner.BaseInterval func init() { + daemon.RegisterCapability("runs") + // One handler per method the namespace publishes. The table is closed, so a + // method is added here deliberately rather than resolved by a lookup that + // could miss. daemon.RegisterHandler("runs.register", handleRegister) daemon.RegisterHandler("runs.unregister", handleUnregister) daemon.RegisterHandler("runs.list", handleList) daemon.RegisterHandler("runs.spawn", handleSpawn) - daemon.RegisterCapability("runs") + // A rename has to move the runs recorded under the old group name too. daemon.OnGroupRename(func(renames map[string]string) { Default.RenameGroups(renames) }) daemon.OnStartChecked(func(rt *daemon.Runtime) error { @@ -200,14 +204,15 @@ func handleList(ctx context.Context, req *daemon.Request) (any, error) { return rpc.RunsListResult{Runs: rows, Exited: exited}, nil } +// handleSpawn is `runs.spawn`: one detached command, started through the same +// path a manifest service takes. func handleSpawn(ctx context.Context, req *daemon.Request) (any, error) { var p rpc.RunsSpawnParams if err := req.Bind(&p); err != nil { return nil, err } - if len(p.Argv) == 0 { - return nil, rpc.NewError(rpc.CodeInvalidParams, "argv is required", - `send {"argv": ["npm", "run", "dev"], "cwd": "/path/to/repo"}`) + if err := requireArgv(p.Argv); err != nil { + return nil, err } cwd, err := CheckCwd(p.Cwd, p.AllowOutsideHome) if err != nil { @@ -229,12 +234,12 @@ func handleSpawn(ctx context.Context, req *daemon.Request) (any, error) { } return rpc.RunsSpawnResult{ + RunID: h.ID, + PID: h.PID, + LogPath: h.LogPath, // A run has no ports at the moment it starts, so `affected` is empty and // the ports arrive with the next state.delta. MutationResult: rpc.MutationResult{OK: true, Affected: []string{}}, - RunID: h.ID, - PID: h.PID, - LogPath: h.LogPath, }, nil } @@ -272,24 +277,23 @@ func sessionOf(p rpc.RunsSpawnParams, cwd string) state.Session { // `groups.start` calls it once per service, so a service started from the // desktop is attributed exactly like one started from the CLI. // -// The caller has already resolved the group, the name and the working -// directory; CheckCwd is the home-directory guard. +// The group, the name and the working directory are already resolved when this +// runs, and CheckCwd has applied the home-directory guard. func Spawn(ctx context.Context, rt *daemon.Runtime, req spawn.Request, meta Meta) (*spawn.Handle, error) { req.Detach = true h, err := spawn.Spawn(ctx, req) if err != nil { - return nil, rpc.NewError(rpc.CodeInternal, err.Error(), - "check the command and its working directory") + return nil, spawnFailed(err) } record := Default.Register(runFromHandle(h, meta)) rememberSession(rt, h.Session) - rt.Logger.Info("spawned a run", - "id", h.ID, "pid", h.PID, "group", h.Group, "name", h.Name, "log", h.LogPath) + rt.Logger.Info("spawned a run", "id", h.ID, "pid", h.PID, "group", h.Group, + "name", h.Name, "log", h.LogPath) rt.Scanner.Wake() - // Reap it: an unwaited child stays a zombie, and a zombie pid still looks - // alive to every liveness test, so the run would never be pruned. + // Reap it. An unwaited child stays a zombie, and a zombie pid still passes + // every liveness test, so such a run would never be pruned. go func(rt *daemon.Runtime, h *spawn.Handle) { code, err := h.Wait() e, _ := Default.ExitedRun(record, code, false) @@ -300,34 +304,41 @@ func Spawn(ctx context.Context, rt *daemon.Runtime, req spawn.Request, meta Meta return h, nil } +// spawnFailed is the refusal a process that could not be started gets: the +// command and the working directory are the two things its caller can fix. +func spawnFailed(err error) error { + return rpc.NewError(rpc.CodeInternal, err.Error(), "check the command and its working directory") +} + // runFromHandle is the record a freshly started process becomes: the launch // facts the spawner observed, plus what the caller knew about why it started — // the manifest it came from, the start that grouped it, and the client that // asked. func runFromHandle(h *spawn.Handle, meta Meta) Record { - return Record{ - ID: h.ID, - PID: h.PID, - PPID: h.PPID, - Group: h.Group, - Name: h.Name, - Cmd: h.Cmd, - Cwd: h.Cwd, - PortHint: h.PortHint, - StartedAt: h.StartedAt, - Session: h.Session, - ConfigPath: meta.ConfigPath, - StartID: meta.StartID, - Origin: meta.Origin, - SpecHash: meta.SpecHash, - LogPath: h.LogPath, - LogOffset: h.LogOffset, - } + rec := Record{ + ID: h.ID, + PID: h.PID, + PPID: h.PPID, + // The launch itself, as the spawner observed it. + Group: h.Group, + Name: h.Name, + Cmd: h.Cmd, + Cwd: h.Cwd, + PortHint: h.PortHint, + StartedAt: h.StartedAt, + Session: h.Session, + LogPath: h.LogPath, + LogOffset: h.LogOffset, + } + // Where the run came from is the caller's knowledge, not the spawner's. + rec.ConfigPath, rec.StartID, rec.Origin = meta.ConfigPath, meta.StartID, meta.Origin + rec.SpecHash = meta.SpecHash + return rec } // rememberSession writes a session through to the store the moment a run -// carries one, so an agent's session survives a daemon restart and stays -// readable for the seven days after its last run exits. +// carries one. That is what keeps an agent's session readable across a daemon +// restart, and for the seven days after its last run exits. func rememberSession(rt *daemon.Runtime, s state.Session) { if s.ID == "" || rt == nil || rt.Store == nil { return @@ -413,19 +424,26 @@ func CheckCwd(cwd string, allowOutsideHome bool) (string, error) { // so the home check cannot pass one and fail the other. func absoluteCwd(cwd string) (string, error) { if strings.TrimSpace(cwd) == "" { + // No directory was named, so the daemon's own is the one meant. wd, err := os.Getwd() if err != nil { - return "", rpc.NewError(rpc.CodeInvalidParams, "cwd is required", "") + return "", invalidCwd("cwd is required") } cwd = wd } abs, err := filepath.Abs(cwd) if err != nil { - return "", rpc.NewError(rpc.CodeInvalidParams, "cwd is not a usable path: "+cwd, "") + return "", invalidCwd("cwd is not a usable path: " + cwd) } return groups.Canonical(abs), nil } +// invalidCwd is how a caller-supplied working directory is refused. The message +// carries what is wrong with it, and there is no next step to hint at. +func invalidCwd(message string) error { + return rpc.NewError(rpc.CodeInvalidParams, message, "") +} + // row renders one live run for `runs.list`: what the registry knows about it, // the ports it currently holds, and whether it is still coming up. // @@ -450,21 +468,23 @@ func row(rec Record, snap state.Snapshot, portsKnown bool) rpc.RunRecord { // A run that has ended carries the same block, which is why exitRow starts here // too: identity, provenance and where it logs are the run's facts either way. func liveRow(rec Record) rpc.RunRecord { - return rpc.RunRecord{ - ID: rec.ID, - PID: rec.PID, - Group: rec.Group, - Name: rec.Name, - Cmd: rec.Cmd, - Cwd: rec.Cwd, - StartedAt: rec.StartedAt.Format(time.RFC3339), - ConfigPath: rec.ConfigPath, - StartID: rec.StartID, - Origin: rec.Origin, - SpecHash: rec.SpecHash, - LogPath: rec.LogPath, - LogOffset: &rec.LogOffset, - } + out := rpc.RunRecord{ + ID: rec.ID, + PID: rec.PID, + // The manifest identity the run was started under, and what was run. + Group: rec.Group, + Name: rec.Name, + Cmd: rec.Cmd, + Cwd: rec.Cwd, + } + // The rest is renderings of the same record: the start time as the wire's + // RFC 3339 string, the provenance, and where the run logs. + out.StartedAt = rec.StartedAt.Format(time.RFC3339) + out.ConfigPath, out.StartID, out.Origin = rec.ConfigPath, rec.StartID, rec.Origin + out.SpecHash = rec.SpecHash + out.LogPath = rec.LogPath + out.LogOffset = &rec.LogOffset + return out } // runStatus is "starting" while a port-hinted run has not bound its port and @@ -516,6 +536,17 @@ func requirePID(pid int, example string) error { return nil } +// requireArgv is the one place that decides a spawn without a command is +// invalid. Like requirePID it answers with a request the caller can paste back, +// so the refusal shows their own shape rather than the registry's field names. +func requireArgv(argv []string) error { + if len(argv) > 0 { + return nil + } + return rpc.NewError(rpc.CodeInvalidParams, "argv is required", + `send {"argv": ["npm", "run", "dev"], "cwd": "/path/to/repo"}`) +} + // valueOrZero reads an optional wire value, returning the type's zero when the // caller named none. The optional-field helpers below differ only in their // element type, so the nil rule lives here once instead of once per type. diff --git a/engine/internal/daemon/runsreg/registry.go b/engine/internal/daemon/runsreg/registry.go index 1695bd06..a7958607 100644 --- a/engine/internal/daemon/runsreg/registry.go +++ b/engine/internal/daemon/runsreg/registry.go @@ -32,16 +32,24 @@ const parentsTTL = 2 * time.Second // The PPID-walk bounds and cycle guards live with the process table, in // ports.Ancestor; this registry only supplies the lookup. -// Record is one registered run. +// Record is one registered run: which process option-berth started, what it was +// started as, and where its output goes. type Record struct { - ID string - PID int - PPID int - Group string - Name string - Cmd string - Cwd string - PortHint int + // ID identifies the run even after its pid is recycled; PID and PPID are the + // process and its parent at the moment it was registered. + ID string + PID int + PPID int + // Group and Name are the manifest identity the run was started under; Cmd + // and Cwd are what it was started with. + Group string + Name string + Cmd string + Cwd string + // PortHint is the port the caller expected this run to bind, 0 for a run + // that declares none. + PortHint int + // StartedAt is when the process started, in the runner's own reading. StartedAt time.Time // ConfigPath, StartID and Origin say where a run came from: the // oberth.yaml it was started from, the groups.start that started it with @@ -50,8 +58,8 @@ type Record struct { StartID string Origin string SpecHash string - // LogPath and LogOffset are where a detached run's output goes and where - // this run's part of the file begins; its last lines are kept on exit. + // LogPath is the file a detached run writes to and LogOffset is where this + // run's part of it begins; the last lines are kept with the exit. LogPath string LogOffset int64 // Session is the agent session that asked for this run, or the zero value @@ -162,9 +170,7 @@ func (r *Registry) Register(rec Record) Record { rec.StartedAt = r.clock() } r.mu.Lock() - if existing, ok := r.runs[rec.PID]; ok && rec.ID == "" { - rec.ID = existing.ID - } + rec = r.keepIDLocked(rec) r.runs[rec.PID] = rec r.mu.Unlock() @@ -172,6 +178,17 @@ func (r *Registry) Register(rec Record) Record { return rec } +// keepIDLocked gives a re-registered pid the id it already had: registering the +// same pid twice replaces the entry, and the same process keeps its identity. +// The caller holds mu, so the id cannot be inherited from a run that is being +// dropped in between. +func (r *Registry) keepIDLocked(rec Record) Record { + if existing, ok := r.runs[rec.PID]; ok && rec.ID == "" { + rec.ID = existing.ID + } + return rec +} + // Unregister drops the run with this pid, reporting whether there was one. func (r *Registry) Unregister(pid int) bool { r.mirrorMu.Lock() @@ -205,10 +222,10 @@ func (r *Registry) PruneCodeSnapshots() { } } -// RenameGroups moves every run recorded under an old group name to its new one -// (`groups.rename`), so a service started before its project was renamed stays -// in the project's group instead of keeping a group of the old name to itself. -// It reports how many runs moved. +// RenameGroups follows a `groups.rename`: every run recorded under an old group +// name moves to the new one, so a service started before its project was +// renamed stays in the project's group instead of keeping a group of the old +// name to itself. It reports how many runs moved. func (r *Registry) RenameGroups(renames map[string]string) int { if len(renames) == 0 { return 0 @@ -216,58 +233,83 @@ func (r *Registry) RenameGroups(renames map[string]string) int { r.mirrorMu.Lock() defer r.mirrorMu.Unlock() r.mu.Lock() + moved := r.renameRunsLocked(renames) + r.renameExitsLocked(renames) + history := r.history + r.mu.Unlock() + for _, rec := range moved { + r.mirrorAddLocked(rec) + } + if history != nil { + r.persistenceResult("exit.rename", history.RenameRunExitGroups(renames)) + } + return len(moved) +} + +// renameRunsLocked moves the live runs and answers with the ones it moved, so +// the caller can mirror each of them after mu is released. The caller holds mu. +func (r *Registry) renameRunsLocked(renames map[string]string) []Record { var moved []Record for pid, rec := range r.runs { - next, ok := renames[rec.Group] - if !ok || next == "" { + next := renames[rec.Group] + if next == "" { continue } rec.Group = next r.runs[pid] = rec moved = append(moved, rec) } + return moved +} + +// renameExitsLocked carries the recorded exits along with the runs: a run's +// history belongs to the group it was started in, under whichever name. The +// caller holds mu. +func (r *Registry) renameExitsLocked(renames map[string]string) { for i := range r.exits { - if next, ok := renames[r.exits[i].Group]; ok && next != "" { + if next := renames[r.exits[i].Group]; next != "" { r.exits[i].Group = next } } - history := r.history - r.mu.Unlock() - for _, rec := range moved { - r.mirrorAddLocked(rec) - } - if history != nil { - r.persistenceResult("exit.rename", history.RenameRunExitGroups(renames)) - } - return len(moved) } -// List returns the live runs, oldest first, after pruning dead ones. +// List returns the live runs, oldest first, after pruning the dead ones. func (r *Registry) List() []Record { r.Prune() r.mu.Lock() defer r.mu.Unlock() + out := r.visibleLocked() + sort.Slice(out, func(i, j int) bool { return out[i].before(out[j]) }) + return out +} + +// visibleLocked is what a reader may see: every registered run except the ones +// already being stopped, whose exit is about to be recorded. The caller holds +// mu. +func (r *Registry) visibleLocked() []Record { out := make([]Record, 0, len(r.runs)) for _, rec := range r.runs { - if rec.stopping { - continue + if !rec.stopping { + out = append(out, rec) } - out = append(out, rec) } - sort.Slice(out, func(i, j int) bool { - if out[i].StartedAt.Equal(out[j].StartedAt) { - return out[i].PID < out[j].PID - } - return out[i].StartedAt.Before(out[j].StartedAt) - }) return out } +// before is the order List publishes: oldest start first, with the pid breaking +// a tie so two runs started in the same instant keep a stable order. +func (rec Record) before(other Record) bool { + if !rec.StartedAt.Equal(other.StartedAt) { + return rec.StartedAt.Before(other.StartedAt) + } + return rec.PID < other.PID +} + // Lookup returns the run registered for a pid. func (r *Registry) Lookup(pid int) (Record, bool) { r.mu.Lock() - defer r.mu.Unlock() rec, ok := r.runs[pid] + r.mu.Unlock() return rec, ok } @@ -339,7 +381,7 @@ func (r *Registry) pidReused(pid int, rec Record) bool { return started.After(rec.StartedAt.Add(ports.StartTolerance)) } -// Run implements groups.Registry: it attributes a listening port to the run +// Run implements groups.Registry: a listening port is attributed to the run // that owns it by walking the port's PPID ancestry, so `npm run dev` -> vite -> // esbuild all resolve to the run that started them. // @@ -405,8 +447,8 @@ func (r *Registry) RunStart(pid int) (time.Time, bool) { return rec.StartedAt, true } -// Session implements groups.SessionRegistry: it reports the agent session that -// started the run owning this port, using the same PPID walk the run +// Session implements groups.SessionRegistry. It answers with the agent session +// behind the run that owns this port, found through the same PPID walk the // attribution uses, so a port and its run can never disagree about who started // them. func (r *Registry) Session(p state.Port) (state.Session, bool) { @@ -423,36 +465,43 @@ func (r *Registry) Session(p state.Port) (state.Session, bool) { // Standalone callers without captured attribution still resolve once. rec, found = r.ancestor(p.PID, p.PPID) } - if !found || rec.Session.ID == "" { - return state.Session{}, false + if found && rec.Session.ID != "" { + return rec.Session, true } - return rec.Session, true + return state.Session{}, false } // SessionRuns lists every live run that carries a session. The daemon's -// sessions handlers read it through an interface assertion on the installed -// run registry, which is how package daemon reaches this data without -// importing the package that registers itself into it. +// sessions handlers read it through an interface assertion on the installed run +// registry, which is how package daemon reaches this data without importing the +// package that registers itself into it. func (r *Registry) SessionRuns() []sessions.Live { out := []sessions.Live{} for _, rec := range r.List() { - if rec.Session.ID == "" { - continue + if rec.Session.ID != "" { + out = append(out, live(rec)) } - out = append(out, sessions.Live{ - RunID: rec.ID, - PID: rec.PID, - Group: rec.Group, - Name: rec.Name, - Cmd: rec.Cmd, - Cwd: rec.Cwd, - StartedAt: rec.StartedAt, - Session: rec.Session, - }) } return out } +// live projects one run onto the session view: the identity of the run, then +// what it was started as, then the session that asked for it. +func live(rec Record) sessions.Live { + return sessions.Live{ + RunID: rec.ID, + PID: rec.PID, + Group: rec.Group, + Name: rec.Name, + Cmd: rec.Cmd, + Cwd: rec.Cwd, + // StartedAt is the run's, not the session's: a session outlives the + // runs that carry it. + StartedAt: rec.StartedAt, + Session: rec.Session, + } +} + // ancestor walks up from pid looking for a registered run. hintPPID is the // parent the scanner already resolved, used before the process table is read. // The walk itself is ports.Ancestor, the same one the direct-scan registry @@ -461,25 +510,33 @@ func (r *Registry) ancestor(pid, hintPPID int) (Record, bool) { if pid <= 0 { return Record{}, false } + // The port's own process first, then the parent the scanner resolved: both + // are registry lookups, cheaper than reading the process table. if rec, ok := r.Lookup(pid); ok { return rec, true } + // The parent the scanner already resolved is free evidence: it is checked + // before the process table is read at all. if hintPPID > 1 { if rec, ok := r.Lookup(hintPPID); ok { return rec, true } } - - r.mu.Lock() - empty := len(r.runs) == 0 - r.mu.Unlock() - if empty { + if r.isEmpty() { return Record{}, false } - return ports.Ancestor(pid, r.parentTable(), r.Lookup) } +// isEmpty reports whether nothing is registered at all. Walking the process +// table for a registry that knows no run can only come back empty, so the walk +// is skipped. +func (r *Registry) isEmpty() bool { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.runs) == 0 +} + func (r *Registry) clock() time.Time { if r.now == nil { return time.Now() From ca86f38ef6ec7dfd12eefeefb4a101a2ae676817 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 16:48:05 +0800 Subject: [PATCH 3/7] =?UTF-8?q?test(engine):=20daemon=20=E7=9A=84=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E3=80=81=E8=BF=9E=E6=8E=A5=E4=B8=8E=E9=94=81?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=8C=89=E8=A1=8C=E4=B8=BA=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server、integration、stream、lock、socket、listen、testguard 与 treekill 系列测试重写:harness 与局部命名按实际动作命名,重复断言收成辅助函数, 表驱动用例合并。测试函数数不变,断言覆盖不减少(stat 与枚举检查移入 辅助函数与期望表),未删用例或加 Skip。 Co-Authored-By: Claude Code --- engine/internal/daemon/integration_test.go | 404 +++++------ engine/internal/daemon/listen_unix_test.go | 97 ++- engine/internal/daemon/lock_test.go | 124 ++-- engine/internal/daemon/server_test.go | 663 +++++++++--------- engine/internal/daemon/socket_test.go | 114 +-- engine/internal/daemon/stream_test.go | 248 +++---- engine/internal/daemon/testguard_test.go | 57 +- engine/internal/daemon/treekill_test.go | 44 +- engine/internal/daemon/treekill_unix_test.go | 38 +- .../internal/daemon/treekill_windows_test.go | 9 +- 10 files changed, 917 insertions(+), 881 deletions(-) diff --git a/engine/internal/daemon/integration_test.go b/engine/internal/daemon/integration_test.go index 08f6cc85..3cd01c55 100644 --- a/engine/internal/daemon/integration_test.go +++ b/engine/internal/daemon/integration_test.go @@ -1,14 +1,15 @@ //go:build integration // Integration tests for the daemon, run with `go test -tags integration -// ./internal/daemon/...`. They build the real `option-berth` binary, start it as -// `oberth serve` in a temp HOME on a temp socket, and drive it with the Go -// client — the same path the CLI, the MCP server and the desktop app take. +// ./internal/daemon/...`. They compile the real `option-berth` binary, run it +// as `oberth serve` against a temporary HOME on a temporary socket, and drive +// it with the Go client — the path the CLI, the MCP server and the desktop app +// all take. // -// Covered here: spec integration test 1 (hello, version, capabilities), spec -// integration test 5 (second instance refused, stale socket cleaned after -// SIGKILL), and the step's acceptance demo (a subscriber sees a listener -// appear and disappear). +// What lives here: spec integration test 1 (hello, version, capabilities), +// spec integration test 5 (a second instance is refused, and a stale socket is +// cleaned up after SIGKILL), plus the step's acceptance demo (a subscriber sees +// a listener appear and then disappear). package daemon_test import ( @@ -35,34 +36,35 @@ import ( ) // buildBinary compiles the option-berth CLI once per test run. -// The binary goes under testenv.Root(): the leak gate only claims a `serve` -// whose executable lives inside this run's own temp root, so a daemon this -// harness starts has to be built there to be recognised — and one another run -// built has to be somewhere else to be left alone. +// +// The binary has to live under testenv.Root(): the leak gate only claims a +// `serve` whose executable sits inside this run's own temp root, so a daemon +// this harness starts must be built there to be recognised — and a binary that +// another run built has to be somewhere else, so it is left alone. var buildBinary = sync.OnceValues(func() (string, error) { - dir, err := os.MkdirTemp(testenv.Root(), "option-berth-itest-bin") + buildDir, err := os.MkdirTemp(testenv.Root(), "option-berth-itest-bin") if err != nil { return "", err } - bin := filepath.Join(dir, "oberth") + built := filepath.Join(buildDir, "oberth") if runtime.GOOS == "windows" { - bin += ".exe" + built += ".exe" } - cmd := exec.Command("go", "build", "-o", bin, "./cmd/oberth") + cmd := exec.Command("go", "build", "-o", built, "./cmd/oberth") cmd.Dir = repoRoot() if out, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("building oberth: %v: %s", err, out) } - return bin, nil + return built, nil }) -// repoRoot walks up from this file to the module root. +// repoRoot walks up from this file until it finds the module root. func repoRoot() string { dir, err := os.Getwd() if err != nil { return "." } - for i := 0; i < 8; i++ { + for depth := 0; depth < 8; depth++ { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { return dir } @@ -71,56 +73,58 @@ func repoRoot() string { return "." } -// env is one isolated daemon: its own HOME, its own socket, its own log. +// env is one isolated daemon: a HOME of its own, a socket of its own, a log of +// its own. type env struct { - t *testing.T - bin string - home string + t *testing.T + bin string + home string + // socket is this env's transport address: a unix socket, or a named pipe + // on Windows. socket string - // extra is appended to every command's environment, after everything - // else, so it wins: a test puts a fake tool first on PATH with it. + // extra is appended after everything else in every command's environment, + // so it wins: a test puts a fake tool first on PATH with it. extra []string } func newEnv(t *testing.T) *env { t.Helper() - bin, err := buildBinary() + built, err := buildBinary() if err != nil { t.Fatal(err) } home := t.TempDir() - // macOS caps a unix socket path at ~104 bytes and t.TempDir() is long, so - // the socket goes in a short directory of its own. - sockDir, err := os.MkdirTemp("", "snr") + // macOS caps a unix socket path at about 104 bytes and t.TempDir() is long, + // so the socket gets a short directory of its own. + runtimeDir, err := os.MkdirTemp("", "oberth") if err != nil { t.Fatal(err) } - t.Cleanup(func() { os.RemoveAll(sockDir) }) + t.Cleanup(func() { os.RemoveAll(runtimeDir) }) - socket := filepath.Join(sockDir, "d.sock") + addr := filepath.Join(runtimeDir, "d.sock") if runtime.GOOS == "windows" { - // Named pipes share one flat namespace, so the pipe is numbered as - // well as pid-stamped: two envs in one test binary must not land on - // the same address, or a daemon still shutting down from an earlier - // test owns the name this one is about to bind. - socket = fmt.Sprintf(`\\.\pipe\option-berth-itest-%d-%d`, os.Getpid(), pipeSeq.Add(1)) + // Named pipes share one flat namespace, so the pipe is numbered as well + // as pid-stamped: two envs in one test binary must not land on the same + // address, or a daemon still shutting down from an earlier test owns the + // name this one is about to bind. + addr = fmt.Sprintf(`\\.\pipe\option-berth-itest-%d-%d`, os.Getpid(), pipeSeq.Add(1)) } - return &env{t: t, bin: bin, home: home, socket: socket} + return &env{t: t, bin: built, home: home, socket: addr} } // waitDelay bounds how long Wait may block on a pipe after the process it was -// waiting for has exited. A stray grandchild holding the inherited stdout -// keeps that pipe open forever, so without this a test that has already failed -// hangs until `go test -timeout` shoots it. +// waiting for has exited. A stray grandchild holding the inherited stdout keeps +// that pipe open forever, so without this bound a test that has already failed +// hangs until `go test -timeout` kills the binary. const waitDelay = 5 * time.Second -// pipeSeq numbers this test binary's named pipes. +// pipeSeq numbers the named pipes this test binary creates. var pipeSeq atomic.Int64 -// lockPath is where this env's daemon takes its single-instance lock: beside -// the socket, or in the config dir when the transport is a named pipe -// . +// lockPath is where this env's daemon takes its single-instance lock — beside +// the socket, or in the config directory when the transport is a named pipe. func (e *env) lockPath() string { if runtime.GOOS == "windows" { return filepath.Join(e.home, ".option-berth", "daemon.lock") @@ -128,26 +132,27 @@ func (e *env) lockPath() string { return filepath.Join(filepath.Dir(e.socket), "daemon.lock") } -// stopDaemon stops a daemon this env started detached and waits until it is -// really gone, not merely unreachable. +// stopDaemon stops a daemon this env started detached, and waits until it is +// really gone rather than merely unreachable. // -// Windows refuses to delete a file another process still has open, so a test -// whose temp HOME holds the daemon's lock and log has to see that process exit -// before t.TempDir's own cleanup runs — otherwise the test fails in teardown -// with "the process cannot access the file because it is being used by another -// process". pid may be 0 when the caller does not know it; the socket and the -// lock are then the only evidence. +// Windows will not delete a file that another process still holds open, so a +// test whose temp HOME holds the daemon's lock and log must watch that process +// exit before t.TempDir's own cleanup runs; otherwise the test fails during +// teardown with "the process cannot access the file because it is being used +// by another process". The pid may be 0 when the caller does not know it, in +// which case the socket and the lock are the only evidence available. func (e *env) stopDaemon(pid int) { _ = e.command("daemon", "stop").Run() - deadline := time.Now().Add(20 * time.Second) - for time.Now().Before(deadline) { - if !daemon.SocketAlive(e.socket) && (pid <= 0 || !runs.PIDAlive(pid)) { + // Give it twenty seconds to go, then look at the lock regardless. + limit := time.Now().Add(20 * time.Second) + for time.Now().Before(limit) { + if gone := !daemon.SocketAlive(e.socket) && (pid <= 0 || !runs.PIDAlive(pid)); gone { break } time.Sleep(50 * time.Millisecond) } - // The lock is the last thing a daemon lets go of. + // A daemon lets go of its lock last of all. if err := daemon.WaitForLockRelease(e.lockPath(), 10*time.Second); err != nil { e.t.Logf("the daemon still holds %s: %v", e.lockPath(), err) } @@ -155,80 +160,80 @@ func (e *env) stopDaemon(pid int) { // command builds a `option-berth` invocation pinned to this env. func (e *env) command(args ...string) *exec.Cmd { - cmd := exec.Command(e.bin, args...) - cmd.WaitDelay = waitDelay - cmd.Env = append(os.Environ(), + run := exec.Command(e.bin, args...) + run.WaitDelay = waitDelay + run.Env = append(os.Environ(), "HOME="+e.home, "USERPROFILE="+e.home, "BERTH_SOCKET="+e.socket, - // XDG_RUNTIME_DIR would otherwise outrank HOME for the log path. + // XDG_RUNTIME_DIR outranks HOME for the log path when it is set. "XDG_RUNTIME_DIR=", // The test binary is isolated with one BERTH_DB for the whole run // (internal/testenv); clearing it puts this env's database under this // env's HOME, which is what a test that restarts a daemon and expects // its own rows back is asking for. "BERTH_DB=", - // These children are the real `option-berth`, and the tests here are the ones - // that drive its autostart on purpose. + // These children are the real `option-berth`, and the tests here are + // the ones that drive its autostart on purpose. testenv.ChildEnv(), ) - cmd.Env = append(cmd.Env, e.extra...) - return cmd + run.Env = append(run.Env, e.extra...) + return run } -// serve starts `oberth serve` in the background and waits for the socket. +// serve starts `oberth serve` in the background and waits for its socket. func (e *env) serve() *exec.Cmd { e.t.Helper() - cmd := e.command("serve") - var out strings.Builder - cmd.Stdout, cmd.Stderr = &out, &out - ownProcessGroup(cmd) - if err := cmd.Start(); err != nil { + run := e.command("serve") + var output strings.Builder + run.Stdout, run.Stderr = &output, &output + ownProcessGroup(run) + if err := run.Start(); err != nil { e.t.Fatalf("starting oberth serve: %v", err) } e.t.Cleanup(func() { - stopCommand(cmd) - if e.t.Failed() && out.Len() > 0 { - e.t.Logf("daemon output:\n%s", out.String()) + stopCommand(run) + if e.t.Failed() && output.Len() > 0 { + e.t.Logf("daemon output:\n%s", output.String()) } }) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + waitCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := client.WaitForSocket(ctx, e.socket, 10*time.Second); err != nil { - e.t.Fatalf("daemon never came up: %v\n%s", err, out.String()) + if err := client.WaitForSocket(waitCtx, e.socket, 10*time.Second); err != nil { + e.t.Fatalf("daemon never came up: %v\n%s", err, output.String()) } - return cmd + return run } // connect dials the daemon as a test client. func (e *env) connect(ctx context.Context) *client.Client { e.t.Helper() - c, err := client.Dial(ctx, client.ClientInfo{ + conn, err := client.Dial(ctx, client.ClientInfo{ Name: "cli", Version: "itest", Socket: e.socket, }) if err != nil { e.t.Fatalf("connecting to the daemon: %v", err) } - e.t.Cleanup(func() { c.Close() }) - return c + e.t.Cleanup(func() { conn.Close() }) + return conn } // TestHelloReportsVersionAndCapabilities is spec integration test 1. func TestHelloReportsVersionAndCapabilities(t *testing.T) { - e := newEnv(t) - e.serve() + sandbox := newEnv(t) + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) - hello := c.Hello() + hello := cl.Hello() if hello.ProtocolVersion != rpc.ProtocolVersion { t.Errorf("protocol_version = %q, want %q", hello.ProtocolVersion, rpc.ProtocolVersion) } if hello.DaemonVersion == "" { - t.Error("daemon_version is empty") + t.Error("hello reported an empty daemon_version") } if hello.PID <= 0 { t.Errorf("pid = %d, want a live process id", hello.PID) @@ -236,90 +241,90 @@ func TestHelloReportsVersionAndCapabilities(t *testing.T) { if hello.StartedAt == "" { t.Error("started_at is empty") } - if hello.Socket != e.socket { - t.Errorf("socket = %q, want %q", hello.Socket, e.socket) + if hello.Socket != sandbox.socket { + t.Errorf("socket = %q, want %q", hello.Socket, sandbox.socket) } if hello.BinaryPath == "" { t.Error("binary_path is empty; the contract requires it for autostart") } - want := map[string]bool{"state": true, rpc.CapabilityStateScope: true, "ports.read": true} - for _, cap := range hello.Capabilities { - delete(want, cap) + expected := map[string]bool{"state": true, rpc.CapabilityStateScope: true, "ports.read": true} + for _, capability := range hello.Capabilities { + delete(expected, capability) } - if len(want) > 0 { - t.Errorf("capabilities = %v, missing %v", hello.Capabilities, want) + if len(expected) > 0 { + t.Errorf("capabilities = %v, missing %v", hello.Capabilities, expected) } var status rpc.DaemonStatusResult - if err := c.Call(ctx, "daemon.status", rpc.Empty{}, &status); err != nil { + if err := cl.Call(ctx, "daemon.status", rpc.Empty{}, &status); err != nil { t.Fatalf("daemon.status: %v", err) } if status.PID != hello.PID { - t.Errorf("daemon.status pid = %d, hello pid = %d", status.PID, hello.PID) + t.Errorf("daemon.status reports pid %d, hello reports %d", status.PID, hello.PID) } } // TestSubscriberSeesAPortAppearAndDisappear is the step's acceptance demo, run -// in-process: a listener on a free port shows up in a state.delta, and its -// close shows up in a later one. +// in-process: a listener on a free port turns up in a state.delta, and its +// close turns up in a later one. func TestSubscriberSeesAPortAppearAndDisappear(t *testing.T) { - e := newEnv(t) - e.serve() + sandbox := newEnv(t) + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) - sub, err := c.Subscribe(ctx, client.SubscribeOptions{Events: true}) + sub, err := cl.Subscribe(ctx, client.SubscribeOptions{Events: true}) if err != nil { t.Fatalf("state.subscribe: %v", err) } t.Logf("subscribed at seq %d with %d ports", sub.Snapshot.Seq, len(sub.Snapshot.Ports)) - // A real listener on a real free port, so the daemon's own lsof/ss scan - // has to find it. - ln, err := net.Listen("tcp", "127.0.0.1:0") + // A real listener on a real free port, so the daemon's own lsof/ss scan is + // the thing that has to find it. + listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("opening a test listener: %v", err) } - port := ln.Addr().(*net.TCPAddr).Port + port := listener.Addr().(*net.TCPAddr).Port t.Logf("test listener is on port %d", port) if !waitForAdded(t, sub, port, 30*time.Second) { - ln.Close() - t.Fatalf("no state.delta added port %d within 30s", port) + listener.Close() + t.Fatalf("port %d never turned up in a state.delta within 30s", port) } - ln.Close() + listener.Close() if !waitForRemoved(t, sub, port, 30*time.Second) { - t.Fatalf("no state.delta removed port %d within 30s", port) + t.Fatalf("port %d never went away in a state.delta within 30s", port) } var status rpc.DaemonStatusResult - if err := c.Call(ctx, "daemon.status", rpc.Empty{}, &status); err != nil { + if err := cl.Call(ctx, "daemon.status", rpc.Empty{}, &status); err != nil { t.Fatalf("daemon.status: %v", err) } - if status.Subscribers != 1 { - t.Errorf("daemon.status subscribers = %d, want 1", status.Subscribers) + if got := status.Subscribers; got != 1 { + t.Errorf("daemon.status reports %d subscribers, want 1", got) } } func waitForAdded(t *testing.T, sub *client.Subscription, port int, timeout time.Duration) bool { t.Helper() - deadline := time.After(timeout) + expired := time.After(timeout) for { select { - case d, ok := <-sub.Deltas: - if !ok { + case delta, open := <-sub.Deltas: + if !open { return false } - for _, p := range d.Ports.Added { - if p.Port == port { - t.Logf("delta seq %d added %d (%s)", d.Seq, p.Port, p.DisplayName) + for _, row := range delta.Ports.Added { + if row.Port == port { + t.Logf("delta seq %d added %d (%s)", delta.Seq, row.Port, row.DisplayName) return true } } - case <-deadline: + case <-expired: return false } } @@ -328,20 +333,20 @@ func waitForAdded(t *testing.T, sub *client.Subscription, port int, timeout time func waitForRemoved(t *testing.T, sub *client.Subscription, port int, timeout time.Duration) bool { t.Helper() prefix := fmt.Sprintf("%d:", port) - deadline := time.After(timeout) + expired := time.After(timeout) for { select { - case d, ok := <-sub.Deltas: - if !ok { + case delta, open := <-sub.Deltas: + if !open { return false } - for _, key := range d.Ports.Removed { + for _, key := range delta.Ports.Removed { if strings.HasPrefix(key, prefix) { - t.Logf("delta seq %d removed %s", d.Seq, key) + t.Logf("delta seq %d removed %s", delta.Seq, key) return true } } - case <-deadline: + case <-expired: return false } } @@ -349,57 +354,57 @@ func waitForRemoved(t *testing.T, sub *client.Subscription, port int, timeout ti // TestSecondServeRefusesAndStaleSocketIsCleaned is spec integration test 5. func TestSecondServeRefusesAndStaleSocketIsCleaned(t *testing.T) { - e := newEnv(t) - first := e.serve() + sandbox := newEnv(t) + first := sandbox.serve() // A second `oberth serve` must refuse rather than steal the socket. - second := e.command("serve") + second := sandbox.command("serve") out, err := second.CombinedOutput() if err == nil { t.Fatalf("a second `oberth serve` succeeded; output:\n%s", out) } if !strings.Contains(string(out), "already running") { - t.Errorf("second serve said %q, want an \"already running\" message", out) + t.Errorf("the second serve said %q; it should have said it is already running", out) } if pid := first.Process.Pid; !strings.Contains(string(out), fmt.Sprint(pid)) { - t.Errorf("second serve did not name the holder pid %d: %q", pid, out) + t.Errorf("the second serve did not name the holder pid %d: %q", pid, out) } - // The first daemon is still serving. + // The first daemon carries on serving. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - e.connect(ctx) + sandbox.connect(ctx) - // SIGKILL leaves the socket file behind with no chance to clean up. + // SIGKILL leaves the socket file behind with no chance to tidy up. if err := first.Process.Kill(); err != nil { - t.Fatalf("killing the daemon: %v", err) + t.Fatalf("killing the first daemon: %v", err) } _ = first.Wait() if runtime.GOOS != "windows" { - if _, err := os.Stat(e.socket); err != nil { - t.Fatalf("expected a stale socket file at %s after SIGKILL: %v", e.socket, err) + if _, err := os.Stat(sandbox.socket); err != nil { + t.Fatalf("expected a stale socket file at %s after SIGKILL: %v", sandbox.socket, err) } - if _, err := daemon.Dial(e.socket); err == nil { + if _, err := daemon.Dial(sandbox.socket); err == nil { t.Fatal("the stale socket still accepts connections") } } // A fresh daemon must remove the stale socket and take the lock. - e.serve() - c2 := e.connect(ctx) - if c2.Hello().PID == first.Process.Pid { + sandbox.serve() + replacement := sandbox.connect(ctx) + if replacement.Hello().PID == first.Process.Pid { t.Error("the new daemon reports the dead daemon's pid") } } -// TestDaemonStatusAndStopThroughTheCLI exercises the `oberth daemon` surface the -// demo uses. +// TestDaemonStatusAndStopThroughTheCLI exercises the `oberth daemon` surface +// the demo uses. func TestDaemonStatusAndStopThroughTheCLI(t *testing.T) { - e := newEnv(t) - e.serve() + sandbox := newEnv(t) + sandbox.serve() - out, err := e.command("daemon", "status").CombinedOutput() + out, err := sandbox.command("daemon", "status").CombinedOutput() if err != nil { t.Fatalf("oberth daemon status: %v\n%s", err, out) } @@ -409,24 +414,24 @@ func TestDaemonStatusAndStopThroughTheCLI(t *testing.T) { } } - pathOut, err := e.command("daemon", "path").CombinedOutput() + pathOut, err := sandbox.command("daemon", "path").CombinedOutput() if err != nil { t.Fatalf("oberth daemon path: %v\n%s", err, pathOut) } - if strings.TrimSpace(string(pathOut)) != e.socket { - t.Errorf("`oberth daemon path` = %q, want %q", strings.TrimSpace(string(pathOut)), e.socket) + if got, want := strings.TrimSpace(string(pathOut)), sandbox.socket; got != want { + t.Errorf("`oberth daemon path` = %q, want %q", got, want) } - stopOut, err := e.command("daemon", "stop").CombinedOutput() + stopOut, err := sandbox.command("daemon", "stop").CombinedOutput() if err != nil { t.Fatalf("oberth daemon stop: %v\n%s", err, stopOut) } - if daemon.SocketAlive(e.socket) { + if daemon.SocketAlive(sandbox.socket) { t.Error("the daemon still accepts connections after `oberth daemon stop`") } - // With no daemon, `status` exits non-zero and says so. - downOut, err := e.command("daemon", "status").CombinedOutput() + // With no daemon left, `status` exits non-zero and says so. + downOut, err := sandbox.command("daemon", "status").CombinedOutput() if err == nil { t.Errorf("`oberth daemon status` with no daemon exited 0:\n%s", downOut) } @@ -435,48 +440,48 @@ func TestDaemonStatusAndStopThroughTheCLI(t *testing.T) { } } -// TestAutostart is the autostart path: no daemon, one Connect, a -// daemon within 3 s. +// TestAutostart is the autostart path: no daemon, one Connect, a daemon within +// three seconds. func TestAutostart(t *testing.T) { - e := newEnv(t) + sandbox := newEnv(t) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - if daemon.SocketAlive(e.socket) { + if daemon.SocketAlive(sandbox.socket) { t.Fatal("something is already listening on the test socket") } - // Connect uses the running executable by default; point it at the built - // binary and give the child the same isolated HOME. Autostart is off for - // the test binary as a whole, and this is the test that exists to drive it. + // Connect defaults to the running executable, so point it at the built + // binary and hand the child the same isolated HOME. Autostart is disabled + // for the whole test binary, and this is the test that drives it on purpose. testenv.AllowAutostart(t) - t.Setenv("HOME", e.home) - t.Setenv("USERPROFILE", e.home) - t.Setenv("BERTH_SOCKET", e.socket) + t.Setenv("HOME", sandbox.home) + t.Setenv("USERPROFILE", sandbox.home) + t.Setenv("BERTH_SOCKET", sandbox.socket) t.Setenv("XDG_RUNTIME_DIR", "") t.Setenv("BERTH_DB", "") - c, err := client.Connect(ctx, client.ClientInfo{ - Name: "cli", Version: "itest", Socket: e.socket, BinaryPath: e.bin, + cl, err := client.Connect(ctx, client.ClientInfo{ + Name: "cli", Version: "itest", Socket: sandbox.socket, BinaryPath: sandbox.bin, }) if err != nil { t.Fatalf("autostart: %v", err) } - defer c.Close() + defer cl.Close() - if c.Hello().PID <= 0 { - t.Errorf("autostarted daemon reported pid %d", c.Hello().PID) + if cl.Hello().PID <= 0 { + t.Errorf("autostarted daemon reported pid %d", cl.Hello().PID) } // The daemon autostart created is detached: nothing else in this test owns - // it. Cleanups run after the test's own defers, so this one cannot reuse c + // it. Cleanups run after the test's own defers, so this one cannot reuse cl // (already closed) — it stops the daemon through the CLI and waits for the - // process, so the temp HOME it holds the lock and log in can be removed. - daemonPID := c.Hello().PID - t.Cleanup(func() { e.stopDaemon(daemonPID) }) + // process, so the temp HOME holding the lock and log can be removed. + daemonPID := cl.Hello().PID + t.Cleanup(func() { sandbox.stopDaemon(daemonPID) }) var snap state.Snapshot - if err := c.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snap); err != nil { + if err := cl.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snap); err != nil { t.Fatalf("state.snapshot against the autostarted daemon: %v", err) } if snap.Ports == nil { @@ -484,73 +489,74 @@ func TestAutostart(t *testing.T) { } } -// TestProtocolMismatchIsReported checks the client-side version guard without -// needing a second daemon build. +// TestProtocolMismatchIsReported exercises the client-side version guard, with +// no second daemon build needed. func TestProtocolMismatchIsReported(t *testing.T) { - err := client.CheckProtocol("9.0.0") + failure := client.CheckProtocol("9.0.0") var mismatch *client.ProtocolMismatchError - if !errors.As(err, &mismatch) { - t.Fatalf("CheckProtocol(9.0.0) = %v, want a ProtocolMismatchError", err) + if !errors.As(failure, &mismatch) { + t.Fatalf("CheckProtocol(9.0.0) = %v, want a ProtocolMismatchError", failure) } } // TestDaemonRestartIsRepeatable is the smoke-test regression: `oberth daemon // restart` waited only for the socket to stop accepting, but a daemon releases // its single-instance lock after it closes the socket. The replacement raced -// that release, lost the lock and exited "already running" — two of three -// attempts left nothing running at all. Five restarts in a row must each leave +// that release, lost the lock and exited "already running" — two attempts out +// of three left nothing running at all. Five restarts in a row must each leave // a daemon up. func TestDaemonRestartIsRepeatable(t *testing.T) { - e := newEnv(t) - e.serve() + sandbox := newEnv(t) + sandbox.serve() // After the first restart the daemon is detached, so the env's own cleanup // (which kills the foreground child) no longer owns it. - t.Cleanup(func() { e.stopDaemon(0) }) + t.Cleanup(func() { sandbox.stopDaemon(0) }) - lastPID := "" - for i := 1; i <= 5; i++ { - out, err := e.command("daemon", "restart").CombinedOutput() + previousPID := "" + for attempt := 1; attempt <= 5; attempt++ { + out, err := sandbox.command("daemon", "restart").CombinedOutput() if err != nil { - t.Fatalf("restart %d of 5 failed: %v\n%s", i, err, out) + t.Fatalf("restart %d of 5 failed: %v\n%s", attempt, err, out) } - statusOut, err := e.command("daemon", "status").CombinedOutput() + statusOut, err := sandbox.command("daemon", "status").CombinedOutput() if err != nil { t.Fatalf("no daemon running after restart %d of 5: %v\nrestart said:\n%s\nstatus said:\n%s", - i, err, out, statusOut) + attempt, err, out, statusOut) } if !strings.Contains(string(statusOut), "running yes") { - t.Fatalf("after restart %d of 5 the daemon does not report itself running:\n%s", i, statusOut) + t.Fatalf("after restart %d of 5 the daemon does not report itself running:\n%s", attempt, statusOut) } pid := statusField(string(statusOut), "pid") if pid == "" { - t.Fatalf("`oberth daemon status` reported no pid after restart %d:\n%s", i, statusOut) + t.Fatalf("`oberth daemon status` reported no pid after restart %d:\n%s", attempt, statusOut) } - if pid == lastPID { - t.Errorf("restart %d reported the same pid %s as the previous one; the daemon was not replaced", i, pid) + if pid == previousPID { + t.Errorf("restart %d reported the same pid %s as the previous one; the daemon was not replaced", + attempt, pid) } - lastPID = pid + previousPID = pid - // The running daemon must also own the single-instance lock. When the - // replacement raced the old daemon's teardown the lock file was left + // The running daemon must own the single-instance lock too. When the + // replacement raced the old daemon's teardown, the lock file was left // holding a dead pid, or removed from under the live daemon. if runtime.GOOS != "windows" { - lockPath := e.lockPath() + lockPath := sandbox.lockPath() if holder := daemon.LockHolderPID(lockPath); fmt.Sprint(holder) != pid { t.Errorf("after restart %d the lock at %s records pid %d, but the daemon reports pid %s", - i, lockPath, holder, pid) + attempt, lockPath, holder, pid) } } } } // statusField pulls one value out of `oberth daemon status`'s aligned output. -func statusField(out, name string) string { +func statusField(out, field string) string { for _, line := range strings.Split(out, "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 && fields[0] == name { - return fields[1] + columns := strings.Fields(line) + if len(columns) >= 2 && columns[0] == field { + return columns[1] } } return "" diff --git a/engine/internal/daemon/listen_unix_test.go b/engine/internal/daemon/listen_unix_test.go index ee3dacf7..1fb0bdb9 100644 --- a/engine/internal/daemon/listen_unix_test.go +++ b/engine/internal/daemon/listen_unix_test.go @@ -9,31 +9,38 @@ import ( "testing" ) +// permOf reports the permission bits at path and fails the test if it cannot +// be stat'ed. +func permOf(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + return info.Mode().Perm() +} + // TestPrepareSocketDirLeavesAUserDirectoryAlone is the smoke-test regression: // `BERTH_SOCKET=/tmp/x.sock` used to fail with "chmod /tmp: operation not -// permitted", and on a directory it could chmod it silently tightened someone -// else's directory to 0700. +// permitted" — and, pointed at a directory it did not own, it would quietly +// tighten that directory to 0700. func TestPrepareSocketDirLeavesAUserDirectoryAlone(t *testing.T) { - dir := t.TempDir() - if err := os.Chmod(dir, 0o755); err != nil { + userDir := t.TempDir() + if err := os.Chmod(userDir, 0o755); err != nil { t.Fatalf("setting up a 0755 directory: %v", err) } - if err := prepareSocketDir(dir, false); err != nil { + if err := prepareSocketDir(userDir, false); err != nil { t.Fatalf("prepareSocketDir on a user-supplied directory: %v", err) } - info, err := os.Stat(dir) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != 0o755 { + if got := permOf(t, userDir); got != 0o755 { t.Errorf("a user-supplied directory was chmod'ed to %o, want it untouched at 0755", got) } } -// TestPrepareSocketDirCreatesAMissingUserDirectory: a directory that is not -// there yet is ours to make, so it is created 0700. +// TestPrepareSocketDirCreatesAMissingUserDirectory: a directory that does not +// exist yet is ours to make, so it is created 0700. func TestPrepareSocketDirCreatesAMissingUserDirectory(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "sock") @@ -41,49 +48,41 @@ func TestPrepareSocketDirCreatesAMissingUserDirectory(t *testing.T) { t.Fatalf("prepareSocketDir on a missing directory: %v", err) } - info, err := os.Stat(dir) - if err != nil { - t.Fatalf("the socket directory was not created: %v", err) - } - if got := info.Mode().Perm(); got != 0o700 { + if got := permOf(t, dir); got != 0o700 { t.Errorf("a directory we created has mode %o, want 0700", got) } } -// TestPrepareSocketDirTightensOurOwnDirectory: the default location is option-berth's, -// so it is tightened even when it already existed too loose. +// TestPrepareSocketDirTightensOurOwnDirectory: the default location belongs to +// option-berth, so it is tightened even when it was already there and too loose. func TestPrepareSocketDirTightensOurOwnDirectory(t *testing.T) { - dir := t.TempDir() - if err := os.Chmod(dir, 0o755); err != nil { + own := t.TempDir() + if err := os.Chmod(own, 0o755); err != nil { t.Fatal(err) } - if err := prepareSocketDir(dir, true); err != nil { + if err := prepareSocketDir(own, true); err != nil { t.Fatalf("prepareSocketDir on our own directory: %v", err) } - info, err := os.Stat(dir) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != 0o700 { + if got := permOf(t, own); got != 0o700 { t.Errorf("our own socket directory has mode %o, want 0700", got) } } // TestListenOnAUserSuppliedSocketPath binds a bare socket in a directory the // daemon does not own — the `BERTH_SOCKET=/tmp/x.sock` shape — and checks the -// socket file is still 0600. +// socket file still ends up 0600. func TestListenOnAUserSuppliedSocketPath(t *testing.T) { - dir, err := os.MkdirTemp("", "snr") + lnDir, err := os.MkdirTemp("", "oberth") if err != nil { t.Fatal(err) } - t.Cleanup(func() { os.RemoveAll(dir) }) - if err := os.Chmod(dir, 0o755); err != nil { + t.Cleanup(func() { os.RemoveAll(lnDir) }) + if err := os.Chmod(lnDir, 0o755); err != nil { t.Fatal(err) } - socket := filepath.Join(dir, "d.sock") + socket := filepath.Join(lnDir, "d.sock") ln, err := listen(socket) if err != nil { @@ -91,45 +90,39 @@ func TestListenOnAUserSuppliedSocketPath(t *testing.T) { } defer ln.Close() - info, err := os.Stat(socket) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); got != 0o600 { + if got := permOf(t, socket); got != 0o600 { t.Errorf("socket mode = %o, want 0600", got) } - if dirInfo, err := os.Stat(dir); err != nil { - t.Fatal(err) - } else if got := dirInfo.Mode().Perm(); got != 0o755 { + if got := permOf(t, lnDir); got != 0o755 { t.Errorf("listen tightened the user's directory to %o, want 0755", got) } - c, err := net.Dial("unix", socket) + conn, err := net.Dial("unix", socket) if err != nil { t.Fatalf("dialling the socket we just bound: %v", err) } - c.Close() + conn.Close() } -// TestOwnsSocketDir distinguishes the directory option-berth makes for itself from one -// the user pointed BERTH_SOCKET at. +// TestOwnsSocketDir separates the directory option-berth makes for itself from +// one the user pointed BERTH_SOCKET at. func TestOwnsSocketDir(t *testing.T) { - home := func() (string, error) { return "/home/dev", nil } - env := map[string]string{"BERTH_SOCKET": "/tmp/x.sock"} - getenv := func(k string) string { return env[k] } + homeDir := func() (string, error) { return "/home/dev", nil } + vars := map[string]string{"BERTH_SOCKET": "/tmp/x.sock"} + getenv := func(key string) string { return vars[key] } - if ownsSocketDir("/tmp/x.sock", getenv, home, "linux") { + if ownsSocketDir("/tmp/x.sock", getenv, homeDir, "linux") { t.Error("/tmp counted as a directory option-berth owns") } - if !ownsSocketDir("/home/dev/.option-berth/daemon.sock", getenv, home, "linux") { + if !ownsSocketDir("/home/dev/.option-berth/daemon.sock", getenv, homeDir, "linux") { t.Error("the default config directory did not count as ours") } - env["XDG_RUNTIME_DIR"] = "/run/user/1000" - if !ownsSocketDir("/run/user/1000/option-berth/daemon.sock", getenv, home, "linux") { + vars["XDG_RUNTIME_DIR"] = "/run/user/1000" + if !ownsSocketDir("/run/user/1000/option-berth/daemon.sock", getenv, homeDir, "linux") { t.Error("the XDG runtime subdirectory did not count as ours") } - if ownsSocketDir("/run/user/1000/daemon.sock", getenv, home, "linux") { + if ownsSocketDir("/run/user/1000/daemon.sock", getenv, homeDir, "linux") { t.Error("the XDG runtime directory itself counted as ours") } } diff --git a/engine/internal/daemon/lock_test.go b/engine/internal/daemon/lock_test.go index fcf9e1d1..6380de46 100644 --- a/engine/internal/daemon/lock_test.go +++ b/engine/internal/daemon/lock_test.go @@ -10,144 +10,152 @@ import ( "time" ) +// lockPathInTemp hands back a lock file path inside a directory of its own. +func lockPathInTemp(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "daemon.lock") +} + func TestAcquireLockRefusesASecondInstance(t *testing.T) { - path := filepath.Join(t.TempDir(), "daemon.lock") + path := lockPathInTemp(t) - first, err := AcquireLock(path) + holder, err := AcquireLock(path) if err != nil { t.Fatalf("first AcquireLock: %v", err) } - defer first.Release() + defer holder.Release() - // Both flock and LockFileEx associate the lock with the open file - // description, not the process, so a second AcquireLock is refused even - // from inside this test binary. - second, err := AcquireLock(path) + // flock and LockFileEx both tie a lock to the open file description rather + // than to the process, so a second AcquireLock is refused even when it + // comes from inside this very test binary. + rival, err := AcquireLock(path) if err == nil { - second.Release() + rival.Release() t.Fatal("a second AcquireLock succeeded; the single-instance lock does not hold") } if !IsAlreadyRunning(err) { t.Fatalf("second AcquireLock returned %v, want ErrAlreadyRunning", err) } - var already *ErrAlreadyRunning - if errors.As(err, &already) && already.PID != os.Getpid() { - t.Errorf("ErrAlreadyRunning.PID = %d, want the holder %d", already.PID, os.Getpid()) + var clash *ErrAlreadyRunning + if errors.As(err, &clash) && clash.PID != os.Getpid() { + t.Errorf("ErrAlreadyRunning.PID = %d, want the holder %d", clash.PID, os.Getpid()) } - // The lock file records the holder's pid so `oberth serve` can name the - // process it lost to. + // The file names the holder's pid, which is how `oberth serve` can tell the + // second instance who it lost to. if got := LockHolderPID(path); got != os.Getpid() { t.Errorf("LockHolderPID = %d, want %d", got, os.Getpid()) } - data, err := os.ReadFile(path) + raw, err := os.ReadFile(path) if err != nil { - t.Fatalf("reading lock file: %v", err) + t.Fatalf("reading the lock file: %v", err) } - if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) { - t.Errorf("lock file holds %q, want the pid %d", data, os.Getpid()) + if recorded := strings.TrimSpace(string(raw)); recorded != strconv.Itoa(os.Getpid()) { + t.Errorf("lock file holds %q, want the pid %d", raw, os.Getpid()) } } func TestReleasePreservesTheLockFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "daemon.lock") - lock, err := AcquireLock(path) + path := lockPathInTemp(t) + held, err := AcquireLock(path) if err != nil { - t.Fatalf("AcquireLock: %v", err) + t.Fatalf("acquiring the lock: %v", err) } - if err := lock.Release(); err != nil { - t.Fatalf("Release: %v", err) + if err := held.Release(); err != nil { + t.Fatalf("releasing the lock: %v", err) } if _, err := os.Stat(path); err != nil { - t.Errorf("stable lock file missing after Release: %v", err) + t.Errorf("the stable lock file went missing after Release: %v", err) } - // A released lock can be taken again. - again, err := AcquireLock(path) + // Releasing frees the lock for the next taker. + retaken, err := AcquireLock(path) if err != nil { - t.Fatalf("re-acquiring a released lock: %v", err) + t.Fatalf("re-acquiring the released lock: %v", err) } - again.Release() + retaken.Release() } func TestLockHolderPIDOnMissingFile(t *testing.T) { - if got := LockHolderPID(filepath.Join(t.TempDir(), "absent.lock")); got != 0 { + absent := filepath.Join(t.TempDir(), "absent.lock") + if got := LockHolderPID(absent); got != 0 { t.Errorf("LockHolderPID on a missing file = %d, want 0", got) } } func TestErrAlreadyRunningMessage(t *testing.T) { - err := &ErrAlreadyRunning{PID: 4321} - if got, want := err.Error(), "daemon already running (pid 4321)"; got != want { + clash := &ErrAlreadyRunning{PID: 6789} + if got, want := clash.Error(), "daemon already running (pid 6789)"; got != want { t.Errorf("Error() = %q, want %q", got, want) } - if !IsAlreadyRunning(err) { + if !IsAlreadyRunning(clash) { t.Error("IsAlreadyRunning did not recognise its own error type") } if IsAlreadyRunning(os.ErrNotExist) { - t.Error("IsAlreadyRunning matched an unrelated error") + t.Error("IsAlreadyRunning claimed an unrelated error") } } -// TestWaitForLockReleaseBlocksUntilTheHolderLetsGo is the mechanism behind the -// `daemon restart` fix: a daemon closes its socket before it releases its lock, -// so the replacement has to wait for the lock. +// TestWaitForLockReleaseBlocksUntilTheHolderLetsGo covers the mechanism behind +// the `daemon restart` fix: a daemon closes its socket before it releases its +// lock, so its replacement has to wait for the lock to go. func TestWaitForLockReleaseBlocksUntilTheHolderLetsGo(t *testing.T) { - path := filepath.Join(t.TempDir(), "daemon.lock") + path := lockPathInTemp(t) held, err := AcquireLock(path) if err != nil { - t.Fatalf("AcquireLock: %v", err) + t.Fatalf("acquiring the lock: %v", err) } - const hold = 200 * time.Millisecond + const holdFor = 200 * time.Millisecond go func() { - time.Sleep(hold) + time.Sleep(holdFor) _ = held.Release() }() - start := time.Now() + began := time.Now() if err := WaitForLockRelease(path, 10*time.Second); err != nil { - t.Fatalf("WaitForLockRelease: %v", err) + t.Fatalf("waiting for the lock to be released: %v", err) } - if waited := time.Since(start); waited < hold { - t.Errorf("WaitForLockRelease returned after %s, before the holder released at %s", waited, hold) + if waited := time.Since(began); waited < holdFor { + t.Errorf("WaitForLockRelease returned after %s, before the holder released at %s", waited, holdFor) } - // The lock really is free: it can be taken now. - again, err := AcquireLock(path) + // The lock is genuinely free, not merely reported free: take it. + retaken, err := AcquireLock(path) if err != nil { t.Fatalf("the lock WaitForLockRelease called free is still held: %v", err) } - again.Release() + retaken.Release() } // TestWaitForLockReleaseTimesOutWithAClearError: a lock nobody ever releases -// must produce a bounded, named failure rather than a hang. +// has to produce a bounded, named failure instead of hanging. func TestWaitForLockReleaseTimesOutWithAClearError(t *testing.T) { - path := filepath.Join(t.TempDir(), "daemon.lock") + path := lockPathInTemp(t) held, err := AcquireLock(path) if err != nil { - t.Fatalf("AcquireLock: %v", err) + t.Fatalf("acquiring the lock: %v", err) } defer held.Release() - err = WaitForLockRelease(path, 100*time.Millisecond) + const budget = 100 * time.Millisecond + err = WaitForLockRelease(path, budget) if err == nil { - t.Fatal("WaitForLockRelease returned nil while the lock was still held") + t.Fatal("WaitForLockRelease returned nil with the lock still held") } - for _, want := range []string{path, "still holds"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("error %q does not mention %q", err, want) + for _, needed := range []string{path, "still holds"} { + if !strings.Contains(err.Error(), needed) { + t.Errorf("error %q does not name %q", err, needed) } } } -// TestWaitForLockReleaseIgnoresAStaleLockFile: a lock file left by a process -// that is gone must not make a restart wait out the whole timeout. +// TestWaitForLockReleaseIgnoresAStaleLockFile: a lock file left behind by a +// process that is long gone must not make a restart wait out the full timeout. func TestWaitForLockReleaseIgnoresAStaleLockFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "daemon.lock") - // A pid nothing holds, and no lock on the file at all. + path := lockPathInTemp(t) + // A pid no process holds, and no lock held on the file at all. if err := os.WriteFile(path, []byte("2147483646\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/engine/internal/daemon/server_test.go b/engine/internal/daemon/server_test.go index ab8894c3..1c4f1c99 100644 --- a/engine/internal/daemon/server_test.go +++ b/engine/internal/daemon/server_test.go @@ -20,156 +20,159 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// pipeWrite and pipeRead bound how long a test will wait on the in-memory -// connection. They are hang-guards, not latency assertions: a handler that -// never answers has to fail the test rather than hang it until `go test -// -timeout` shoots the whole binary, but a handler that answers in 20 ms on a -// laptop and 4 s on a contended two-core CI runner is not a bug, and a deadline -// that calls it one produces exactly the flake this suite kept seeing — three -// different tests, on three different branches, all failing as "read pipe: i/o -// timeout" at the old 3 s. +// The two pipe deadlines below are hang-guards, not latency budgets. A handler +// that never answers has to fail its test rather than sit there until `go test +// -timeout` shoots the whole binary; but a handler that answers in 20 ms on a +// laptop and in four seconds on a loaded two-core CI runner is still a handler +// that answered, and calling that a bug is exactly what produced the run of +// "read pipe: i/o timeout" flakes this suite used to have at the old +// three-second bound. // -// net.Pipe is synchronous and unbuffered, so the server's write blocks until -// the test reads; the write deadline needs the same headroom as the read. +// net.Pipe is synchronous and has no buffer — the server's write returns only +// once the test reads — so the write side needs the same headroom as the read +// side. const ( pipeWrite = 30 * time.Second pipeRead = 30 * time.Second - // settle is how long a test may wait for work the daemon does on its own - // clock — a scan tick, a delta, a stream ending. The scan loop's base - // interval is 2 s, so anything under a few of those is a race with the - // runner rather than a bound on the daemon. + // settle bounds how long a test may wait for work the daemon schedules on + // its own clock: a scan tick, a delta, a stream ending. The scan loop's + // base interval is two seconds, so a bound of only a few of those would + // measure the runner rather than the daemon. settle = 30 * time.Second ) -// testHarness is a Server wired to an in-memory connection, so the dispatcher, -// the codec, the subscription registry and the fan-out are exercised together -// without touching the filesystem or the OS scanner. +// testHarness wires a Server to an in-memory connection: the dispatcher, the +// codec, the subscription registry and the fan-out all take part, and neither +// the filesystem nor the OS scanner is touched. // -// "Without touching the OS" is load-bearing and is enforced by the seams below: -// Scan, statistics and Probe are all faked, so no unit test spawns netstat, lsof, -// ss, ps, tasklist, powershell or docker, and none opens a real socket. A -// handler that reaches the OS behind those seams is a bug — it makes the test -// measure the runner instead of the handler. +// That last part is load-bearing, and the seams below are what enforce it: +// Scan, the process sampler and Probe are all faked, so no unit test here +// spawns netstat, lsof, ss, ps, tasklist, powershell or docker, and none opens +// a listening socket. A handler that reaches the OS behind one of those seams +// is a defect — the test would then be measuring the runner instead of the +// handler. type testHarness struct { t *testing.T srv *Server loop *scanner.Loop - stopLoop context.CancelFunc - loopDone chan struct{} + stopScan context.CancelFunc + scanDone chan struct{} mu sync.Mutex rows []ports.ListeningPort probe scanner.Probe - // statsMove turns the per-process sampler from a constant into climbing readings. + // statsMove turns the per-process sampler from a constant answer into + // readings that climb on every call; sampleN counts those calls. statsMove atomic.Bool sampleN atomic.Int64 } -// newHarness builds a server whose scan loop is already running, so a change to -// the fake scan result reaches subscribers the way it does in production. +// newHarness builds a server whose scan loop is already running, so a change +// to the fake scan result reaches subscribers the way it does in production. // -// A harness that opens a database must be built *after* the t.TempDir() that -// holds it: cleanups run last-registered-first, and the harness has to stop -// before the directory it writes into is removed. -// tune lets a test adjust the scanner options the harness builds, before the -// loop is constructed. The stats tick is what it is for: most tests only need -// it to exist, and paying the production 1 s cadence for each of them makes -// the suite slower without testing anything, so they turn it down. +// A harness that opens a database has to be built *after* the t.TempDir() that +// holds it: cleanups run newest-first, and the harness must stop before the +// directory it writes into is removed. +// +// The optional tweaks adjust the scanner options before the loop is built. The +// stats cadence is what they are for: most tests only need the tick to happen +// at all, and paying the production one-second cadence in each of them costs +// wall-clock without testing anything. type tune func(*scanner.Options) -// fastStats is that knob: a cadence short enough that a test can wait for a -// tick without waiting a second for it. +// fastStats is the usual tweak: a cadence short enough that a test can wait +// for a tick without waiting a second for it. func fastStats(o *scanner.Options) { o.StatsInterval = 50 * time.Millisecond } func newHarness(t *testing.T, ctx context.Context, tunes ...tune) *testHarness { t.Helper() - h := &testHarness{t: t, loopDone: make(chan struct{})} - h.probe = refusedProbe + hn := &testHarness{t: t, scanDone: make(chan struct{})} + hn.probe = refusedProbe - opts := h.scannerOptions() - for _, tune := range tunes { - tune(&opts) + opts := hn.scannerOptions() + for _, tweak := range tunes { + tweak(&opts) } - h.loop = scanner.New(opts) - h.srv = New(Options{Socket: "/test/option-berth.sock", Version: "test", Scanner: h.loop}) + hn.loop = scanner.New(opts) + hn.srv = New(Options{Socket: "/test/option-berth.sock", Version: "test", Scanner: hn.loop}) runCtx, stop := context.WithCancel(ctx) - h.stopLoop = stop + hn.stopScan = stop go func() { - defer close(h.loopDone) - h.loop.Run(runCtx) + defer close(hn.scanDone) + hn.loop.Run(runCtx) }() - t.Cleanup(h.shutdown) - return h + t.Cleanup(hn.shutdown) + return hn } // scannerOptions is the scanner configuration the harness runs on. Every seam // that would reach the real machine is faked, because a test that keeps a // subscription open would otherwise fork `ps` or open a real socket once a // second. -func (h *testHarness) scannerOptions() scanner.Options { +func (hn *testHarness) scannerOptions() scanner.Options { return scanner.Options{ DaemonVersion: "test", - // Scan answers from the rows the test set, copied so a caller cannot - // mutate the harness's slice. + // Scan answers from the rows the test installed, copied out so a caller + // cannot mutate the harness's own slice. Scan: func(scanner.Include) ([]ports.ListeningPort, error) { - h.mu.Lock() - defer h.mu.Unlock() - return append([]ports.ListeningPort{}, h.rows...), nil + hn.mu.Lock() + defer hn.mu.Unlock() + return append([]ports.ListeningPort{}, hn.rows...), nil }, - // Configured health goes through the injected probe, never a socket. + // Configured health goes through the injected probe and never a socket. Probe: func(host string, port int, path string, timeout time.Duration) ports.HealthResult { - h.mu.Lock() - probe := h.probe - h.mu.Unlock() + hn.mu.Lock() + probe := hn.probe + hn.mu.Unlock() return probe(host, port, path, timeout) }, - SampleStats: h.sampleStats, + SampleStats: hn.sampleStats, } } -// shutdown stops everything the harness started, in order, and returns only -// once it has stopped. +// shutdown stops everything the harness started, in order, and does not return +// until it has stopped. // -// Cancelling the test's context asks the loop to stop; it does not wait for it -// to have stopped, and a scan already in flight goes on publishing — which -// records history through the store, into the temp directory t.TempDir is -// removing at that very moment. That race failed as "TempDir RemoveAll -// cleanup: directory not empty", pointing at a SQLite -wal file recreated -// behind the delete. Connections first (their handlers write too), then the -// loop, then the database. -func (h *testHarness) shutdown() { - h.srv.closeAllConns("test over") - h.srv.wg.Wait() - h.stopLoop() - <-h.loopDone - h.srv.closeStore() -} - -// sampleStats is the harness's per-process sampler. It answers for nothing -// unless the test asked for movement, so an ordinary harness test's stats are -// whatever its fake rows carry. -func (h *testHarness) sampleStats(pids []int) map[int]ports.ProcSample { - if !h.statsMove.Load() { +// Cancelling the test's context only asks the loop to stop; it does not wait +// for it, and a scan already under way goes on publishing — which records +// history through the store, into the temp directory t.TempDir is removing at +// that very moment. That race showed up as "TempDir RemoveAll cleanup: +// directory not empty", pointing at a SQLite -wal file recreated behind the +// delete. So: connections first (their handlers write too), then the loop, +// then the database. +func (hn *testHarness) shutdown() { + hn.srv.closeAllConns("test over") + hn.srv.wg.Wait() + hn.stopScan() + <-hn.scanDone + hn.srv.closeStore() +} + +// sampleStats is the harness's per-process sampler. Unless the test asked for +// movement it answers for nothing, so an ordinary harness test's stats are +// whatever its fake rows already carry. +func (hn *testHarness) sampleStats(pids []int) map[int]ports.ProcSample { + if !hn.statsMove.Load() { return nil } - n := h.sampleN.Add(1) - out := make(map[int]ports.ProcSample, len(pids)) + tick := hn.sampleN.Add(1) + answer := make(map[int]ports.ProcSample, len(pids)) for _, pid := range pids { - out[pid] = ports.ProcSample{ - CPUPercent: float64(n), + answer[pid] = ports.ProcSample{ + CPUPercent: float64(tick), MemoryRSS: int64(pid) << 10, ThreadCount: 3, State: "running", Uptime: "1s", } } - return out + return answer } -// newClient starts a daemon a test only talks to, on a context this test +// newClient starts a daemon a test only talks to, on a context that test // cancels when it ends, and returns a connected client. Tests that reach into // the harness use newHarness instead. func newClient(t *testing.T) *testClient { @@ -180,153 +183,157 @@ func newClient(t *testing.T) *testClient { } // setRows replaces what the fake scanner returns. -func (h *testHarness) setRows(rows ...ports.ListeningPort) { - h.mu.Lock() - h.rows = rows - h.mu.Unlock() +func (hn *testHarness) setRows(rows ...ports.ListeningPort) { + hn.mu.Lock() + hn.rows = rows + hn.mu.Unlock() } // setProbe replaces the fake health probe. -func (h *testHarness) setProbe(p scanner.Probe) { - h.mu.Lock() - h.probe = p - h.mu.Unlock() +func (hn *testHarness) setProbe(p scanner.Probe) { + hn.mu.Lock() + hn.probe = p + hn.mu.Unlock() } -// refusedProbe is the harness default: nothing is really listening behind the -// fake rows, so every probe is refused, deterministically and instantly. +// refusedProbe is the harness default: nothing really listens behind the fake +// rows, so every probe is refused — instantly and deterministically. func refusedProbe(_ string, _ int, _ string, _ time.Duration) ports.HealthResult { return ports.HealthResult{Status: "refused"} } // dial attaches a client to the server over an in-memory pipe. -func (h *testHarness) dial(ctx context.Context) *testClient { - h.t.Helper() - clientSide, serverSide := net.Pipe() - h.srv.startConn(ctx, serverSide) - c := &testClient{t: h.t, conn: clientSide, r: bufio.NewReader(clientSide)} - h.t.Cleanup(func() { clientSide.Close() }) - return c +func (hn *testHarness) dial(ctx context.Context) *testClient { + hn.t.Helper() + clientEnd, serverEnd := net.Pipe() + hn.srv.startConn(ctx, serverEnd) + cl := &testClient{t: hn.t, conn: clientEnd, r: bufio.NewReader(clientEnd)} + hn.t.Cleanup(func() { clientEnd.Close() }) + return cl } type testClient struct { t *testing.T conn net.Conn r *bufio.Reader - id int + seq int } -// send writes one request and returns its id. -func (c *testClient) send(method string, params any) string { - c.t.Helper() - c.id++ - id := json.RawMessage(itoa(c.id)) +// send writes one request and returns the id it was given. +func (cl *testClient) send(method string, params any) string { + cl.t.Helper() + cl.seq++ + id := json.RawMessage(itoa(cl.seq)) raw, err := json.Marshal(params) if err != nil { - c.t.Fatalf("marshalling params: %v", err) + cl.t.Fatalf("marshalling params: %v", err) } - c.conn.SetWriteDeadline(time.Now().Add(pipeWrite)) - if err := json.NewEncoder(c.conn).Encode(rpc.Request{ - JSONRPC: rpc.Version, ID: id, Method: method, Params: raw, - }); err != nil { - c.t.Fatalf("sending %s: %v", method, err) + cl.conn.SetWriteDeadline(time.Now().Add(pipeWrite)) + request := rpc.Request{JSONRPC: rpc.Version, ID: id, Method: method, Params: raw} + if err := json.NewEncoder(cl.conn).Encode(request); err != nil { + cl.t.Fatalf("sending %s: %v", method, err) } return string(id) } // read returns the next message from the daemon. -func (c *testClient) read() rpc.Message { - c.t.Helper() - c.conn.SetReadDeadline(time.Now().Add(pipeRead)) - line, err := c.r.ReadBytes('\n') +func (cl *testClient) read() rpc.Message { + cl.t.Helper() + cl.conn.SetReadDeadline(time.Now().Add(pipeRead)) + line, err := cl.r.ReadBytes('\n') if err != nil { - c.t.Fatalf("reading from daemon: %v", err) + cl.t.Fatalf("reading from daemon: %v", err) } - var m rpc.Message - if err := json.Unmarshal(line, &m); err != nil { - c.t.Fatalf("decoding %q: %v", line, err) + var msg rpc.Message + if err := json.Unmarshal(line, &msg); err != nil { + cl.t.Fatalf("decoding %q: %v", line, err) } - return m + return msg } -// call sends a request and returns the matching response, skipping any -// notifications that arrive first. -func (c *testClient) call(method string, params, out any) *rpc.Error { - c.t.Helper() - id := c.send(method, params) +// call sends a request and returns the response that matches it, skipping any +// notification that arrives first. +func (cl *testClient) call(method string, params, out any) *rpc.Error { + cl.t.Helper() + id := cl.send(method, params) for { - m := c.read() - if !m.IsResponse() || string(m.ID) != id { + msg := cl.read() + if !msg.IsResponse() || string(msg.ID) != id { continue } - if m.Error != nil { - return m.Error + if msg.Error != nil { + return msg.Error } - if out != nil && len(m.Result) > 0 { - if err := json.Unmarshal(m.Result, out); err != nil { - c.t.Fatalf("decoding %s result: %v", method, err) + if out != nil && len(msg.Result) > 0 { + if err := json.Unmarshal(msg.Result, out); err != nil { + cl.t.Fatalf("decoding %s result: %v", method, err) } } return nil } } +// itoa formats a request counter as a decimal string, so the client can build +// ids without dragging strconv into this file. func itoa(i int) string { if i == 0 { return "0" } - var b []byte + var digits []byte for i > 0 { - b = append([]byte{byte('0' + i%10)}, b...) + digits = append([]byte{byte('0' + i%10)}, digits...) i /= 10 } - return string(b) + return string(digits) } -// TestHelloOverTheCodec runs a real request through the newline codec and the -// dispatcher and checks the handshake the whole protocol hangs off. +// TestHelloOverTheCodec pushes one real request through the newline codec and +// the dispatcher, and checks the handshake the whole protocol hangs off. func TestHelloOverTheCodec(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + hn := newHarness(t, ctx) + cl := hn.dial(ctx) var hello rpc.DaemonHelloResult - if e := c.call("daemon.hello", rpc.DaemonHelloParams{ + if failure := cl.call("daemon.hello", rpc.DaemonHelloParams{ Client: "cli", ClientVersion: "1.2.3", - }, &hello); e != nil { - t.Fatalf("daemon.hello: %v", e) + }, &hello); failure != nil { + t.Fatalf("daemon.hello: %v", failure) } if hello.ProtocolVersion != rpc.ProtocolVersion { t.Errorf("protocol_version = %q, want %q", hello.ProtocolVersion, rpc.ProtocolVersion) } if hello.DaemonVersion != "test" { - t.Errorf("daemon_version = %q, want test", hello.DaemonVersion) + t.Errorf("daemon_version = %q; the harness daemon is versioned \"test\"", hello.DaemonVersion) + } + if hello.PID == 0 { + t.Errorf("hello reported no pid: %+v", hello) } - if hello.PID == 0 || hello.StartedAt == "" { - t.Errorf("hello is missing pid or started_at: %+v", hello) + if hello.StartedAt == "" { + t.Errorf("hello reported no started_at: %+v", hello) } if hello.Socket != "/test/option-berth.sock" { t.Errorf("socket = %q, want /test/option-berth.sock", hello.Socket) } want := Capabilities() - if len(hello.Capabilities) != len(want) { - t.Fatalf("capabilities = %v, want %v", hello.Capabilities, want) + if got := hello.Capabilities; len(got) != len(want) { + t.Fatalf("capabilities = %v, want %v", got, want) } - for i := range want { - if hello.Capabilities[i] != want[i] { - t.Errorf("capabilities = %v, want %v", hello.Capabilities, want) + for i, capability := range hello.Capabilities { + if capability != want[i] { + t.Errorf("capability %d = %q, want %q", i, capability, want[i]) } } } func TestHelloRequiresAClientName(t *testing.T) { - c := newClient(t) + cl := newClient(t) - e := c.call("daemon.hello", rpc.DaemonHelloParams{ClientVersion: "1"}, nil) + e := cl.call("daemon.hello", rpc.DaemonHelloParams{ClientVersion: "1"}, nil) if e == nil { - t.Fatal("daemon.hello with no client succeeded") + t.Fatal("daemon.hello accepted a request with no client name") } if e.Code != rpc.CodeInvalidParams || e.Data.Code != "invalid_params" { t.Errorf("error = %+v, want invalid_params", e) @@ -334,11 +341,11 @@ func TestHelloRequiresAClientName(t *testing.T) { } func TestUnknownMethodIsNotFound(t *testing.T) { - c := newClient(t) + cl := newClient(t) - e := c.call("ports.teleport", rpc.Empty{}, nil) + e := cl.call("ports.teleport", rpc.Empty{}, nil) if e == nil { - t.Fatal("an unknown method succeeded") + t.Fatal("an unknown method was served") } if e.Code != rpc.CodeNotFound || e.Data.Code != "not_found" { t.Errorf("error = %+v, want not_found", e) @@ -349,13 +356,12 @@ func TestUnknownMethodIsNotFound(t *testing.T) { } func TestInvalidIncludeIsRejected(t *testing.T) { - c := newClient(t) + cl := newClient(t) - e := c.call("state.snapshot", map[string]any{"include": []string{"stats", "weather"}}, nil) - if e == nil { + params := map[string]any{"include": []string{"stats", "weather"}} + if e := cl.call("state.snapshot", params, nil); e == nil { t.Fatal("an unknown include was accepted") - } - if e.Code != rpc.CodeInvalidParams { + } else if e.Code != rpc.CodeInvalidParams { t.Errorf("error = %+v, want invalid_params", e) } } @@ -363,12 +369,12 @@ func TestInvalidIncludeIsRejected(t *testing.T) { func TestSnapshotAndStatus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) - h.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) - c := h.dial(ctx) + hn := newHarness(t, ctx) + hn.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) + cl := hn.dial(ctx) var snap state.Snapshot - if e := c.call("state.snapshot", rpc.StateSnapshotParams{}, &snap); e != nil { + if e := cl.call("state.snapshot", rpc.StateSnapshotParams{}, &snap); e != nil { t.Fatalf("state.snapshot: %v", e) } if len(snap.Ports) != 1 || snap.Ports[0].Port != 8123 { @@ -379,115 +385,115 @@ func TestSnapshotAndStatus(t *testing.T) { } var status rpc.DaemonStatusResult - if e := c.call("daemon.status", rpc.Empty{}, &status); e != nil { + if e := cl.call("daemon.status", rpc.Empty{}, &status); e != nil { t.Fatalf("daemon.status: %v", e) } - if status.Subscribers != 0 { - t.Errorf("subscribers = %d before subscribing, want 0", status.Subscribers) + if got := status.Subscribers; got != 0 { + t.Errorf("status reports %d subscribers before any subscribe, want 0", got) } if status.ScanIntervalMs == 0 || status.LastScanAt == "" { - t.Errorf("status is missing scanner counters: %+v", status) + t.Errorf("status is missing its scanner counters: %+v", status) } } -// TestSubscribeThenDelta is the core streaming contract: subscribe replies with -// the snapshot, and later changes arrive as state.delta notifications. +// TestSubscribeThenDelta is the core streaming contract: subscribe replies +// with the snapshot, and later changes arrive as state.delta notifications. func TestSubscribeThenDelta(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + hn := newHarness(t, ctx) + cl := hn.dial(ctx) var snap state.Snapshot - if e := c.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, &snap); e != nil { + if e := cl.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, &snap); e != nil { t.Fatalf("state.subscribe: %v", e) } - if h.srv.Subscribers() != 1 { - t.Fatalf("Subscribers() = %d after subscribe, want 1", h.srv.Subscribers()) + if got := hn.srv.Subscribers(); got != 1 { + t.Fatalf("Subscribers() = %d after subscribe, want 1", got) } - h.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) - h.loop.Wake() + hn.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) + hn.loop.Wake() var delta state.Delta var event state.Event deadline := time.Now().Add(settle) for (len(delta.Ports.Added) == 0 || event.Kind == "") && time.Now().Before(deadline) { - m := c.read() - switch m.Method { + msg := cl.read() + switch msg.Method { case rpc.MethodStateDelta: - json.Unmarshal(m.Params, &delta) + json.Unmarshal(msg.Params, &delta) case rpc.MethodStateEvent: - json.Unmarshal(m.Params, &event) + json.Unmarshal(msg.Params, &event) } } if len(delta.Ports.Added) != 1 || delta.Ports.Added[0].Port != 8123 { - t.Fatalf("delta = %+v, want 8123 added", delta.Ports) + t.Fatalf("delta = %+v, want one added row on 8123", delta.Ports) } if delta.Seq <= snap.Seq { - t.Errorf("delta seq %d did not advance past the snapshot seq %d", delta.Seq, snap.Seq) + t.Errorf("delta seq %d did not advance past snapshot seq %d", delta.Seq, snap.Seq) } if event.Kind != "port_up" { t.Errorf("event kind = %q, want port_up", event.Kind) } - if e := c.call("state.unsubscribe", rpc.Empty{}, nil); e != nil { + if e := cl.call("state.unsubscribe", rpc.Empty{}, nil); e != nil { t.Fatalf("state.unsubscribe: %v", e) } - if h.srv.Subscribers() != 0 { - t.Errorf("Subscribers() = %d after unsubscribe, want 0", h.srv.Subscribers()) + if got := hn.srv.Subscribers(); got != 0 { + t.Errorf("Subscribers() = %d after unsubscribe, want 0", got) } } // TestSubscribeResumesFromSequence reopens the wire after a published change // happened with no subscriber. The reconnect gets the exact cached snapshot at // its cursor and then the missing delta/event pair, rather than a fresh state -// with no way to tell whether the transition was observed. +// with no way to tell whether the transition was ever observed. func TestSubscribeResumesFromSequence(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) - h.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) - c1 := h.dial(ctx) + hn := newHarness(t, ctx) + hn.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) + first := hn.dial(ctx) - if e := c1.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, nil); e != nil { + if e := first.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, nil); e != nil { t.Fatalf("first state.subscribe: %v", e) } - first := c1.nextDelta() - if first.Seq == 0 || len(first.Ports.Added) != 1 || first.Ports.Added[0].Port != 8123 { - t.Fatalf("first delta = %+v, want a nonzero 8123 baseline sequence", first) + baseline := first.nextDelta() + if baseline.Seq == 0 || len(baseline.Ports.Added) != 1 || baseline.Ports.Added[0].Port != 8123 { + t.Fatalf("first delta = %+v, want a nonzero 8123 baseline sequence", baseline) } - if e := c1.call("state.unsubscribe", rpc.Empty{}, nil); e != nil { + if e := first.call("state.unsubscribe", rpc.Empty{}, nil); e != nil { t.Fatalf("state.unsubscribe: %v", e) } - h.setRows( + hn.setRows( ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}, ports.ListeningPort{Port: 8124, BindAddress: "127.0.0.1", PID: 43, Process: "worker"}, ) - if _, err := h.loop.Rescan(scanner.Include{}); err != nil { + if _, err := hn.loop.Rescan(scanner.Include{}); err != nil { t.Fatalf("second scan: %v", err) } - c2 := h.dial(ctx) + second := hn.dial(ctx) var resumed state.Snapshot - if e := c2.call("state.subscribe", rpc.StateSubscribeParams{ - Events: true, AfterSeq: first.Seq, + if e := second.call("state.subscribe", rpc.StateSubscribeParams{ + Events: true, AfterSeq: baseline.Seq, }, &resumed); e != nil { t.Fatalf("resuming state.subscribe: %v", e) } - if resumed.Seq != first.Seq { - t.Fatalf("resume snapshot seq = %d, want cursor %d", resumed.Seq, first.Seq) + if resumed.Seq != baseline.Seq { + t.Fatalf("resume snapshot seq = %d, want cursor %d", resumed.Seq, baseline.Seq) } - delta := c2.nextDelta() - if delta.Seq <= first.Seq || len(delta.Ports.Added) != 1 || delta.Ports.Added[0].Port != 8124 { - t.Fatalf("replayed delta = %+v, want 8124 after %d", delta, first.Seq) + replayed := second.nextDelta() + if replayed.Seq <= baseline.Seq || len(replayed.Ports.Added) != 1 || replayed.Ports.Added[0].Port != 8124 { + t.Fatalf("replayed delta = %+v, want 8124 after %d", replayed, baseline.Seq) } - event := c2.nextNotification(rpc.MethodStateEvent) + notification := second.nextNotification(rpc.MethodStateEvent) var got state.Event - if err := json.Unmarshal(event.Params, &got); err != nil { + if err := json.Unmarshal(notification.Params, &got); err != nil { t.Fatalf("decoding replayed event: %v", err) } if got.Kind != "port_up" { @@ -495,46 +501,46 @@ func TestSubscribeResumesFromSequence(t *testing.T) { } } -// TestIncludeFiltersEnrichments checks that a subscriber which did not ask for -// stats never sees them, even when another subscriber made the scanner collect -// them. +// TestIncludeFiltersEnrichments checks that a subscriber which never asked for +// stats does not see them, even once another subscriber has made the scanner +// collect them. func TestIncludeFiltersEnrichments(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := &testHarness{t: t} - h.loop = scanner.New(scanner.Options{ + hn := &testHarness{t: t} + hn.loop = scanner.New(scanner.Options{ DaemonVersion: "test", Scan: func(scanner.Include) ([]ports.ListeningPort, error) { return []ports.ListeningPort{{ - Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3", - CPUPercent: 1.5, MemoryRSS: 1024, + Port: 9099, BindAddress: "127.0.0.1", PID: 4242, Process: "ruby", + CPUPercent: 2.5, MemoryRSS: 2048, }}, nil }, }) - h.srv = New(Options{Socket: "/test/option-berth.sock", Version: "test", Scanner: h.loop}) + hn.srv = New(Options{Socket: "/test/option-berth.sock", Version: "test", Scanner: hn.loop}) - plainSnap := h.snapshotAs(ctx, t, nil) + plainSnap := hn.snapshotAs(ctx, t, nil) if plainSnap.Ports[0].Stats != nil { t.Errorf("stats leaked to a caller that did not ask: %+v", plainSnap.Ports[0].Stats) } - statsSnap := h.snapshotAs(ctx, t, rpc.Include{"stats"}) + statsSnap := hn.snapshotAs(ctx, t, rpc.Include{"stats"}) if statsSnap.Ports[0].Stats == nil { t.Fatal("stats missing for a caller that asked for them") } - if statsSnap.Ports[0].Stats.CPUPercent != 1.5 { - t.Errorf("cpu_percent = %v, want 1.5", statsSnap.Ports[0].Stats.CPUPercent) + if cpu := statsSnap.Ports[0].Stats.CPUPercent; cpu != 2.5 { + t.Errorf("cpu_percent = %v, want 2.5", cpu) } } // snapshotAs opens a fresh client, asks for one state.snapshot with the given -// include set, and returns it. Each caller gets its own connection so one test -// can compare what two differently-scoped subscribers are served. -func (h *testHarness) snapshotAs(ctx context.Context, t *testing.T, include rpc.Include) state.Snapshot { +// include set, and returns it. Each caller gets a connection of its own, so one +// test can compare what two differently-scoped subscribers are served. +func (hn *testHarness) snapshotAs(ctx context.Context, t *testing.T, include rpc.Include) state.Snapshot { t.Helper() var snapshot state.Snapshot - if failure := h.dial(ctx).call("state.snapshot", + if failure := hn.dial(ctx).call("state.snapshot", rpc.StateSnapshotParams{Include: include}, &snapshot); failure != nil { t.Fatalf("state.snapshot(include=%v): %v", include, failure) } @@ -546,17 +552,17 @@ func (h *testHarness) snapshotAs(ctx context.Context, t *testing.T, include rpc. func TestQueueOverflowDisconnects(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) + hn := newHarness(t, ctx) - conn := h.subscribeWithoutReading(ctx, t) + conn := hn.subscribeWithoutReading(ctx, t) // The writer goroutine takes one message and then blocks on the unread // pipe, so everything after that piles up in the queue. for queued := 0; queued < QueueSize+50; queued++ { - h.srv.broadcastEvent(state.Event{Kind: "port_up", At: "now"}) + hn.srv.broadcastEvent(state.Event{Kind: "port_up", At: "now"}) select { case <-conn.closed: - h.awaitDeregistered(t, queued) + hn.awaitDeregistered(t, queued) return default: } @@ -569,153 +575,151 @@ func TestQueueOverflowDisconnects(t *testing.T) { // subscribeWithoutReading attaches a client, marks its connection as a // subscriber behind the server's back, and then never reads from it — which is // exactly the misbehaviour the queue limit has to catch. -func (h *testHarness) subscribeWithoutReading(ctx context.Context, t *testing.T) *Conn { +func (hn *testHarness) subscribeWithoutReading(ctx context.Context, t *testing.T) *Conn { t.Helper() - clientSide, serverSide := net.Pipe() - t.Cleanup(func() { clientSide.Close() }) - h.srv.startConn(ctx, serverSide) + clientEnd, serverEnd := net.Pipe() + t.Cleanup(func() { clientEnd.Close() }) + hn.srv.startConn(ctx, serverEnd) - h.srv.subsMu.Lock() - defer h.srv.subsMu.Unlock() - var subscribed *Conn - for _, candidate := range h.srv.conns { - subscribed = candidate + hn.srv.subsMu.Lock() + defer hn.srv.subsMu.Unlock() + var subscriber *Conn + for _, candidate := range hn.srv.conns { + subscriber = candidate } - if subscribed == nil { + if subscriber == nil { t.Fatal("the connection was not registered") } - subscribed.subscribed, subscribed.events = true, true - return subscribed + subscriber.subscribed, subscriber.events = true, true + return subscriber } // awaitDeregistered waits for the overflowing connection to be taken off the // client list. closed fires just before that step, so this gives it a moment // rather than racing it. -func (h *testHarness) awaitDeregistered(t *testing.T, queued int) { +func (hn *testHarness) awaitDeregistered(t *testing.T, queued int) { t.Helper() - for waited := 0; waited < 100 && h.srv.Clients() != 0; waited++ { + for waited := 0; waited < 100 && hn.srv.Clients() != 0; waited++ { time.Sleep(time.Millisecond) } - if h.srv.Clients() != 0 { + if hn.srv.Clients() != 0 { t.Fatalf("overflowing client still registered after %d messages", queued) } } -// TestShutdownAnswersBeforeClosing checks that daemon.shutdown replies and -// emits daemon_stopping before the socket goes away. +// TestShutdownAnswersBeforeClosing checks that daemon.shutdown gets its reply +// out, and daemon_stopping with it, before the socket goes away. func TestShutdownAnswersBeforeClosing(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + hn := newHarness(t, ctx) + cl := hn.dial(ctx) var snap state.Snapshot - if e := c.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, &snap); e != nil { + if e := cl.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, &snap); e != nil { t.Fatalf("state.subscribe: %v", e) } - id := c.send("daemon.shutdown", rpc.Empty{}) + id := cl.send("daemon.shutdown", rpc.Empty{}) sawOK, sawStopping := false, false for !sawOK || !sawStopping { - m := c.read() + msg := cl.read() switch { - case m.IsResponse() && string(m.ID) == id: + case msg.IsResponse() && string(msg.ID) == id: var ok rpc.OKResult - json.Unmarshal(m.Result, &ok) + json.Unmarshal(msg.Result, &ok) if !ok.OK { t.Fatalf("daemon.shutdown returned %+v", ok) } sawOK = true - case m.Method == rpc.MethodStateEvent: + case msg.Method == rpc.MethodStateEvent: var ev state.Event - json.Unmarshal(m.Params, &ev) - if ev.Kind == "daemon_stopping" { - sawStopping = true - } + json.Unmarshal(msg.Params, &ev) + sawStopping = sawStopping || ev.Kind == "daemon_stopping" } } } -// TestOversizeMessageIsRejectedNotFatal checks the 4 MiB framing limit: the -// daemon answers with invalid_params and keeps the connection. +// TestOversizeMessageIsRejectedNotFatal covers the 4 MiB framing limit: the +// daemon answers invalid_params and the connection stays up. func TestOversizeMessageIsRejectedNotFatal(t *testing.T) { - c := newClient(t) + cl := newClient(t) go func() { - c.conn.SetWriteDeadline(time.Now().Add(pipeWrite)) - io.WriteString(c.conn, `{"jsonrpc":"2.0","id":1,"method":"daemon.status","params":{"pad":"`) + cl.conn.SetWriteDeadline(time.Now().Add(pipeWrite)) + io.WriteString(cl.conn, `{"jsonrpc":"2.0","id":1,"method":"daemon.status","params":{"pad":"`) chunk := strings.Repeat("x", 64<<10) for written := 0; written < MaxMessageBytes+(1<<20); written += len(chunk) { - if _, err := io.WriteString(c.conn, chunk); err != nil { + if _, err := io.WriteString(cl.conn, chunk); err != nil { return } } - io.WriteString(c.conn, "\"}}\n") + io.WriteString(cl.conn, "\"}}\n") }() - m := c.read() - if m.Error == nil || m.Error.Code != rpc.CodeInvalidParams { - t.Fatalf("oversize message produced %+v, want invalid_params", m.Error) + msg := cl.read() + if msg.Error == nil || msg.Error.Code != rpc.CodeInvalidParams { + t.Fatalf("oversize message produced %+v, want invalid_params", msg.Error) } // The connection survives: the decoder resynced on the next line. var status rpc.DaemonStatusResult - if e := c.call("daemon.status", rpc.Empty{}, &status); e != nil { + if e := cl.call("daemon.status", rpc.Empty{}, &status); e != nil { t.Fatalf("daemon.status after an oversize message: %v", e) } } -// TestStreamCancelOnAnUnknownID keeps the cancel path honest before -// any streaming method exists to use it. +// TestStreamCancelOnAnUnknownID keeps the cancel path honest before any +// streaming method exists to use it. func TestStreamCancelOnAnUnknownID(t *testing.T) { - c := newClient(t) + cl := newClient(t) - e := c.call("stream.cancel", rpc.StreamCancel{ID: "nope"}, nil) + e := cl.call("stream.cancel", rpc.StreamCancel{ID: "nope"}, nil) if e == nil || e.Code != rpc.CodeNotFound { t.Fatalf("stream.cancel on an unknown id = %+v, want not_found", e) } } -// TestIdleTimeoutStopsTheDaemon and TestKeepaliveDisablesIdleTimeout cover the +// TestIdleTimeoutStopsTheDaemon and TestKeepaliveDisablesIdleTimeout are the // two halves of the idle rule. func TestIdleTimeoutStopsTheDaemon(t *testing.T) { - srv := New(Options{Socket: "/test/option-berth.sock", IdleTimeout: 40 * time.Millisecond, + idle := New(Options{Socket: "/test/option-berth.sock", IdleTimeout: 40 * time.Millisecond, Scanner: scanner.New(scanner.Options{})}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go srv.watchIdle(ctx) + go idle.watchIdle(ctx) select { - case <-srv.stopping: + case <-idle.stopping: case <-time.After(3 * time.Second): t.Fatal("the daemon did not stop after its idle timeout") } } func TestKeepaliveDisablesIdleTimeout(t *testing.T) { - srv := New(Options{Socket: "/test/option-berth.sock", IdleTimeout: 40 * time.Millisecond, + idle := New(Options{Socket: "/test/option-berth.sock", IdleTimeout: 40 * time.Millisecond, Scanner: scanner.New(scanner.Options{})}) - srv.keepalives.Store(1) + idle.keepalives.Store(1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go srv.watchIdle(ctx) + go idle.watchIdle(ctx) select { - case <-srv.stopping: + case <-idle.stopping: t.Fatal("the daemon idled out with a keepalive client connected") case <-time.After(300 * time.Millisecond): } } // nextDelta reads until a state.delta notification arrives and decodes it. -func (c *testClient) nextDelta() state.Delta { - c.t.Helper() +func (cl *testClient) nextDelta() state.Delta { + cl.t.Helper() var d state.Delta - m := c.nextNotification(rpc.MethodStateDelta) - if err := json.Unmarshal(m.Params, &d); err != nil { - c.t.Fatalf("decoding state.delta: %v", err) + msg := cl.nextNotification(rpc.MethodStateDelta) + if err := json.Unmarshal(msg.Params, &d); err != nil { + cl.t.Fatalf("decoding state.delta: %v", err) } return d } @@ -723,39 +727,39 @@ func (c *testClient) nextDelta() state.Delta { // subscribeAndSettle subscribes and waits for the delta the subscription's own // wake produces, so a test that needs the ports on screen has them. The // subscribe reply itself is the cached snapshot and may still be empty. -func (c *testClient) subscribeAndSettle(p rpc.StateSubscribeParams) state.Delta { - c.t.Helper() - if e := c.call("state.subscribe", p, nil); e != nil { - c.t.Fatalf("state.subscribe: %v", e) +func (cl *testClient) subscribeAndSettle(p rpc.StateSubscribeParams) state.Delta { + cl.t.Helper() + if e := cl.call("state.subscribe", p, nil); e != nil { + cl.t.Fatalf("state.subscribe: %v", e) } - return c.nextDelta() + return cl.nextDelta() } -// TestStatusReportsConfiguredIntervals: both `daemon.scan_interval` and -// `daemon.stats_interval` are read once when the daemon starts, so -// `daemon.status` is what tells a client — and the person who just edited the -// config file and restarted — which values a running daemon is actually on. +// TestStatusReportsConfiguredIntervals: `daemon.scan_interval` and +// `daemon.stats_interval` are both read once at startup, so `daemon.status` is +// the only way for a client — or for the person who just edited the config file +// and restarted — to learn which values the running daemon is actually on. func TestStatusReportsConfiguredIntervals(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx, func(o *scanner.Options) { + hn := newHarness(t, ctx, func(o *scanner.Options) { o.ScanInterval = 4 * time.Second o.StatsInterval = 300 * time.Millisecond }) - c := h.dial(ctx) + cl := hn.dial(ctx) var status rpc.DaemonStatusResult - if e := c.call("daemon.status", rpc.Empty{}, &status); e != nil { - t.Fatalf("daemon.status: %v", e) + if e := cl.call("daemon.status", rpc.Empty{}, &status); e != nil { + t.Fatalf("asking a running daemon for its status: %v", e) } if status.ScanBaseIntervalMs != 4000 { - t.Errorf("scan_base_interval_ms = %d, want 4000", status.ScanBaseIntervalMs) + t.Errorf("scan_base_interval_ms = %d, want the configured 4000", status.ScanBaseIntervalMs) } if status.StatsIntervalMs != 300 { - t.Errorf("stats_interval_ms = %d, want 300", status.StatsIntervalMs) + t.Errorf("stats_interval_ms = %d, want the configured 300", status.StatsIntervalMs) } - // The adaptive interval is a separate number and still starts at the base + // The adaptive interval is a number of its own, and still starts at the base // rather than at the 2 s constant. if status.ScanIntervalMs < status.ScanBaseIntervalMs { t.Errorf("scan_interval_ms = %d, want at least the %dms base", @@ -764,25 +768,24 @@ func TestStatusReportsConfiguredIntervals(t *testing.T) { } // TestDroppedConnectionReclaimsItsSubscription: a subscriber that vanishes -// without unsubscribing must not keep the loop scanning, and must not block -// the fan-out to a live subscriber. The connection is closed under the server -// — no state.unsubscribe — which is what a killed CLI or a dead app leaves -// behind. +// without unsubscribing must not keep the loop scanning, and must not block the +// fan-out to a live subscriber. The connection is closed under the server — no +// state.unsubscribe — which is what a killed CLI or a dead app leaves behind. func TestDroppedConnectionReclaimsItsSubscription(t *testing.T) { for _, warm := range []bool{false, true} { t.Run(fmt.Sprintf("cached=%v", warm), func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - started, release := make(chan struct{}), make(chan struct{}) - var first sync.Once - h := newHarness(t, ctx, func(o *scanner.Options) { + scanStarted, unblock := make(chan struct{}), make(chan struct{}) + var once sync.Once + hn := newHarness(t, ctx, func(o *scanner.Options) { scan := o.Scan o.Scan = func(include scanner.Include) ([]ports.ListeningPort, error) { rows, err := scan(include) - first.Do(func() { - close(started) + once.Do(func() { + close(scanStarted) select { - case <-release: + case <-unblock: case <-ctx.Done(): } }) @@ -791,35 +794,35 @@ func TestDroppedConnectionReclaimsItsSubscription(t *testing.T) { }) // Cancel before the harness waits for the blocked collector on failure. t.Cleanup(cancel) - h.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) + hn.setRows(ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) if warm { - close(release) - if _, err := h.loop.Rescan(scanner.Include{}); err != nil { + close(unblock) + if _, err := hn.loop.Rescan(scanner.Include{}); err != nil { t.Fatal(err) } } - gone := h.dial(ctx) + gone := hn.dial(ctx) if e := gone.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, nil); e != nil { t.Fatalf("state.subscribe: %v", e) } - if got := h.srv.Subscribers(); got != 1 { + if got := hn.srv.Subscribers(); got != 1 { t.Fatalf("Subscribers() = %d after subscribe, want 1", got) } select { - case <-started: + case <-scanStarted: case <-time.After(settle): t.Fatal("subscription never woke the collector") } gone.conn.Close() deadline := time.Now().Add(settle) - for h.srv.Subscribers() != 0 && time.Now().Before(deadline) { + for hn.srv.Subscribers() != 0 && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } - if got := h.srv.Subscribers(); got != 0 { + if got := hn.srv.Subscribers(); got != 0 { t.Fatalf("Subscribers() = %d after drop, want 0", got) } - live := h.dial(ctx) + live := hn.dial(ctx) var snap state.Snapshot if e := live.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, &snap); e != nil { t.Fatalf("live state.subscribe: %v", e) @@ -830,20 +833,20 @@ func TestDroppedConnectionReclaimsItsSubscription(t *testing.T) { } // Force the initial 8123 delta to be pending on the live stream. // It is valid and must be applied, not mistaken for the next change. - close(release) + close(unblock) deadline = time.Now().Add(settle) - for len(h.loop.Cached().Ports) == 0 && time.Now().Before(deadline) { + for len(hn.loop.Cached().Ports) == 0 && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } - if len(h.loop.Cached().Ports) != 1 { + if len(hn.loop.Cached().Ports) != 1 { t.Fatal("initial collector never published") } } - h.setRows( + hn.setRows( ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}, ports.ListeningPort{Port: 8124, BindAddress: "127.0.0.1", PID: 43, Process: "worker"}, ) - want, err := h.loop.Rescan(scanner.Include{}) + want, err := hn.loop.Rescan(scanner.Include{}) if err != nil { t.Fatalf("rescan: %v", err) } diff --git a/engine/internal/daemon/socket_test.go b/engine/internal/daemon/socket_test.go index 0feb7fba..3d947283 100644 --- a/engine/internal/daemon/socket_test.go +++ b/engine/internal/daemon/socket_test.go @@ -6,78 +6,79 @@ import ( "testing" ) -// TestSocketPathResolution walks the resolution order across the -// whole environment matrix, on one host, for both platform branches. +// TestSocketPathResolution walks the resolution order over the whole +// environment matrix, on one host, for both platform branches. func TestSocketPathResolution(t *testing.T) { - home := func() (string, error) { return "/home/dev", nil } + homeDir := func() (string, error) { return "/home/dev", nil } - tests := []struct { - name string - goos string - env map[string]string - want string + cases := []struct { + label string + platform string + vars map[string]string + want string }{ { - name: "BERTH_SOCKET wins over everything", - goos: "darwin", - env: map[string]string{"BERTH_SOCKET": "/tmp/x.sock", "XDG_RUNTIME_DIR": "/run/user/1000"}, - want: "/tmp/x.sock", + label: "BERTH_SOCKET outranks everything else", + platform: "darwin", + vars: map[string]string{"BERTH_SOCKET": "/tmp/x.sock", "XDG_RUNTIME_DIR": "/run/user/1000"}, + want: "/tmp/x.sock", }, { - name: "BERTH_SOCKET wins on Windows too", - goos: "windows", - env: map[string]string{"BERTH_SOCKET": `\\.\pipe\option-berth-test`}, - want: `\\.\pipe\option-berth-test`, + label: "BERTH_SOCKET outranks everything on Windows too", + platform: "windows", + vars: map[string]string{"BERTH_SOCKET": `\\.\pipe\option-berth-test`}, + want: `\\.\pipe\option-berth-test`, }, { - name: "empty BERTH_SOCKET is not set", - goos: "linux", - env: map[string]string{"BERTH_SOCKET": "", "XDG_RUNTIME_DIR": "/run/user/1000"}, - want: filepath.Join("/run/user/1000", "option-berth", "daemon.sock"), + label: "an empty BERTH_SOCKET counts as unset", + platform: "linux", + vars: map[string]string{"BERTH_SOCKET": "", "XDG_RUNTIME_DIR": "/run/user/1000"}, + want: filepath.Join("/run/user/1000", "option-berth", "daemon.sock"), }, { - name: "Windows uses the named pipe", - goos: "windows", - env: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, - want: WindowsPipe, + label: "Windows falls back to the named pipe", + platform: "windows", + vars: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, + want: WindowsPipe, }, { - name: "XDG_RUNTIME_DIR when set", - goos: "linux", - env: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, - want: filepath.Join("/run/user/1000", "option-berth", "daemon.sock"), + label: "XDG_RUNTIME_DIR is used when it is set", + platform: "linux", + vars: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1000"}, + want: filepath.Join("/run/user/1000", "option-berth", "daemon.sock"), }, { - name: "home config directory otherwise", - goos: "darwin", - env: map[string]string{}, - want: filepath.Join("/home/dev", ".option-berth", "daemon.sock"), + label: "otherwise the home config directory", + platform: "darwin", + vars: map[string]string{}, + want: filepath.Join("/home/dev", ".option-berth", "daemon.sock"), }, { - name: "empty XDG_RUNTIME_DIR falls through to home", - goos: "linux", - env: map[string]string{"XDG_RUNTIME_DIR": ""}, - want: filepath.Join("/home/dev", ".option-berth", "daemon.sock"), + label: "an empty XDG_RUNTIME_DIR falls through to home", + platform: "linux", + vars: map[string]string{"XDG_RUNTIME_DIR": ""}, + want: filepath.Join("/home/dev", ".option-berth", "daemon.sock"), }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - getenv := func(k string) string { return tt.env[k] } - if got := socketPathFrom(getenv, home, tt.goos); got != tt.want { - t.Errorf("socketPathFrom() = %q, want %q", got, tt.want) + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + getenv := func(key string) string { return tc.vars[key] } + got := socketPathFrom(getenv, homeDir, tc.platform) + if got != tc.want { + t.Errorf("socketPathFrom() = %q, want %q", got, tc.want) } }) } } -// TestSocketPathHomeFailure covers the unhappy path where HOME is unset: the -// daemon must not fall back to the filesystem root. +// TestSocketPathHomeFailure covers the unhappy path, where HOME cannot be +// resolved: the daemon must not fall back to the filesystem root. func TestSocketPathHomeFailure(t *testing.T) { getenv := func(string) string { return "" } - home := func() (string, error) { return "", errNoHome{} } + noHome := func() (string, error) { return "", errNoHome{} } want := filepath.Join(".", ".option-berth", "daemon.sock") - if got := socketPathFrom(getenv, home, "linux"); got != want { + if got := socketPathFrom(getenv, noHome, "linux"); got != want { t.Errorf("socketPathFrom() = %q, want %q", got, want) } } @@ -86,23 +87,22 @@ type errNoHome struct{} func (errNoHome) Error() string { return "no home" } -// TestLockPathFollowsSocket checks that the lock lives beside the socket, so a -// socket on a per-boot tmpfs takes its lock with it, and that a named pipe -// (which has no directory) falls back to the config directory. +// TestLockPathFollowsSocket checks that the lock sits beside the socket — so a +// socket on a per-boot tmpfs takes its lock along with it — and that a named +// pipe, which has no directory to sit in, falls back to the config directory. func TestLockPathFollowsSocket(t *testing.T) { - // The "beside the socket" branch is a unix-socket branch: on Windows the - // address is always a named pipe (the default, or a BERTH_SOCKET pointing - // at another pipe), which has no directory and takes the config-dir branch - // below. Asserting a unix path there would only assert what filepath does - // to slashes. + // The "beside the socket" branch only exists for unix sockets: on Windows + // the address is always a named pipe (the default, or a BERTH_SOCKET + // pointing at another pipe), which takes the config-dir branch below. + // Asserting a unix path there would assert nothing but filepath's slash + // handling. if runtime.GOOS != "windows" { - if got, want := lockPathFor("/run/user/1000/option-berth/daemon.sock"), - "/run/user/1000/option-berth/daemon.lock"; got != want { + want := "/run/user/1000/option-berth/daemon.lock" + if got := lockPathFor("/run/user/1000/option-berth/daemon.sock"); got != want { t.Errorf("lockPathFor(unix socket) = %q, want %q", got, want) } } - got := lockPathFor(WindowsPipe) - if want := filepath.Join(ConfigDir(), "daemon.lock"); got != want { + if got, want := lockPathFor(WindowsPipe), filepath.Join(ConfigDir(), "daemon.lock"); got != want { t.Errorf("lockPathFor(named pipe) = %q, want %q", got, want) } } diff --git a/engine/internal/daemon/stream_test.go b/engine/internal/daemon/stream_test.go index df21b674..3543a984 100644 --- a/engine/internal/daemon/stream_test.go +++ b/engine/internal/daemon/stream_test.go @@ -12,27 +12,33 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// unregisterHandler drops a method again. Only tests need it: RegisterHandler -// panics on a duplicate, so a test that installs a probe has to clean up. +// unregisterHandler takes a method back out of the dispatch table. Only tests +// need it: RegisterHandler panics on a duplicate, so a test that installs a +// probe of its own has to undo that when the probe is done. func unregisterHandler(method string) { handlersMu.Lock() delete(handlers, method) handlersMu.Unlock() } -// streamProbe is a streaming method that emits whatever the test pushes onto -// its channel, so the lifecycle can be driven step by step. +// streamProbe is a streaming method that sends whatever the test pushes into +// its channel, so a test can walk the stream lifecycle one step at a time. type streamProbe struct { - lines chan string - started chan *Stream - done chan error + writes chan string + began chan *Stream + finished chan error +} + +// probeLine is the payload of one chunk the probe sends. +type probeLine struct { + Line string `json:"line"` } func newStreamProbe() *streamProbe { return &streamProbe{ - lines: make(chan string, 4), - started: make(chan *Stream, 1), - done: make(chan error, 1), + writes: make(chan string, 4), + began: make(chan *Stream, 1), + finished: make(chan error, 1), } } @@ -40,19 +46,19 @@ func (p *streamProbe) handler(end any) Handler { return func(ctx context.Context, req *Request) (any, error) { return StartStream(ctx, req, map[string]any{"source": "probe"}, func(ctx context.Context, s *Stream) (any, error) { - p.started <- s + p.began <- s for { select { case <-ctx.Done(): - p.done <- ctx.Err() + p.finished <- ctx.Err() return end, ctx.Err() - case line, ok := <-p.lines: - if !ok { - p.done <- nil + case line, open := <-p.writes: + if !open { + p.finished <- nil return end, nil } if err := s.Send(map[string]string{"line": line}); err != nil { - p.done <- err + p.finished <- err return end, err } } @@ -61,13 +67,14 @@ func (p *streamProbe) handler(end any) Handler { } } -// nextNotification reads until a notification with the wanted method arrives. +// nextNotification reads until a notification carrying the wanted method +// arrives, and hands it back. func (c *testClient) nextNotification(method string) rpc.Message { c.t.Helper() for { - m := c.read() - if m.IsNotification() && m.Method == method { - return m + msg := c.read() + if msg.IsNotification() && msg.Method == method { + return msg } } } @@ -76,65 +83,65 @@ func TestStreamChunkEndAndInitialReply(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - p := newStreamProbe() - RegisterHandler("test.streamChunks", p.handler(map[string]any{"lines": 2})) + probe := newStreamProbe() + RegisterHandler("test.streamChunks", probe.handler(map[string]any{"lines": 2})) t.Cleanup(func() { unregisterHandler("test.streamChunks") }) - h := newHarness(t, ctx) - c := h.dial(ctx) + hn := newHarness(t, ctx) + cl := hn.dial(ctx) var start struct { Source string `json:"source"` SubscriptionID string `json:"subscription_id"` } - if e := c.call("test.streamChunks", rpc.Empty{}, &start); e != nil { - t.Fatalf("starting stream: %v", e) + if failure := cl.call("test.streamChunks", rpc.Empty{}, &start); failure != nil { + t.Fatalf("starting the stream: %v", failure) } if start.Source != "probe" { - t.Fatalf("initial payload lost: %+v", start) + t.Fatalf("the initial payload was lost: %+v", start) } if start.SubscriptionID == "" { - t.Fatal("no subscription_id in the streaming reply") + t.Fatal("the streaming reply carried no subscription_id") } - p.lines <- "one" - p.lines <- "two" + sent := []string{"one", "two"} + for _, line := range sent { + probe.writes <- line + } - for _, want := range []string{"one", "two"} { - msg := c.nextNotification(rpc.MethodStreamChunk) + for _, want := range sent { + msg := cl.nextNotification(rpc.MethodStreamChunk) var chunk rpc.StreamChunk if err := json.Unmarshal(msg.Params, &chunk); err != nil { - t.Fatalf("decoding chunk: %v", err) + t.Fatalf("decoding the chunk: %v", err) } if chunk.ID != start.SubscriptionID { t.Fatalf("chunk id = %q, want %q", chunk.ID, start.SubscriptionID) } - var body struct { - Line string `json:"line"` - } - if err := json.Unmarshal(chunk.Data, &body); err != nil { - t.Fatalf("decoding chunk data: %v", err) + var payload probeLine + if err := json.Unmarshal(chunk.Data, &payload); err != nil { + t.Fatalf("decoding the chunk payload: %v", err) } - if body.Line != want { - t.Fatalf("chunk line = %q, want %q", body.Line, want) + if payload.Line != want { + t.Fatalf("chunk line = %q, want %q", payload.Line, want) } } - close(p.lines) + close(probe.writes) - msg := c.nextNotification(rpc.MethodStreamEnd) - var end rpc.StreamEnd - if err := json.Unmarshal(msg.Params, &end); err != nil { + msg := cl.nextNotification(rpc.MethodStreamEnd) + var ending rpc.StreamEnd + if err := json.Unmarshal(msg.Params, &ending); err != nil { t.Fatalf("decoding stream.end: %v", err) } - if end.ID != start.SubscriptionID { - t.Fatalf("stream.end id = %q, want %q", end.ID, start.SubscriptionID) + if ending.ID != start.SubscriptionID { + t.Fatalf("stream.end id = %q, want %q", ending.ID, start.SubscriptionID) } - if end.Error != nil { - t.Fatalf("stream.end carried an error: %v", end.Error) + if ending.Error != nil { + t.Fatalf("stream.end carried an error: %v", ending.Error) } - if string(end.Data) != `{"lines":2}` { - t.Fatalf("stream.end data = %s, want the producer's final payload", end.Data) + if string(ending.Data) != `{"lines":2}` { + t.Fatalf("stream.end data = %s, want the producer's final payload", ending.Data) } } @@ -142,36 +149,36 @@ func TestStreamCancelStopsTheProducer(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - p := newStreamProbe() - RegisterHandler("test.streamCancel", p.handler(nil)) + probe := newStreamProbe() + RegisterHandler("test.streamCancel", probe.handler(nil)) t.Cleanup(func() { unregisterHandler("test.streamCancel") }) - h := newHarness(t, ctx) - c := h.dial(ctx) + hn := newHarness(t, ctx) + cl := hn.dial(ctx) var start rpc.StreamStart - if e := c.call("test.streamCancel", rpc.Empty{}, &start); e != nil { - t.Fatalf("starting stream: %v", e) + if failure := cl.call("test.streamCancel", rpc.Empty{}, &start); failure != nil { + t.Fatalf("starting the stream: %v", failure) } - <-p.started + <-probe.began - end := c.cancelStreamAndAwaitEnd(start.SubscriptionID) + ending := cl.cancelStreamAndAwaitEnd(start.SubscriptionID) select { - case err := <-p.done: + case err := <-probe.finished: if err == nil { - t.Fatal("producer stopped without a cancellation error") + t.Fatal("the producer stopped without a cancellation error") } case <-time.After(2 * time.Second): - t.Fatal("cancelling the stream did not stop the producer") + t.Fatal("cancelling the stream left the producer running") } - if end.Error != nil { - t.Fatalf("a cancelled stream must end cleanly, got %v", end.Error) + if ending.Error != nil { + t.Fatalf("a cancelled stream must end cleanly, got %v", ending.Error) } - // The id is gone: cancelling twice is a not_found, not a second cancel. - if e := c.call("stream.cancel", rpc.StreamCancel{ID: start.SubscriptionID}, nil); e == nil { + // The id is gone: a second cancel is a not_found, not another cancel. + if e := cl.call("stream.cancel", rpc.StreamCancel{ID: start.SubscriptionID}, nil); e == nil { t.Fatal("cancelling an ended stream should be not_found") } } @@ -180,30 +187,30 @@ func TestStreamDisconnectCancelsTheProducer(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - p := newStreamProbe() - RegisterHandler("test.streamDisconnect", p.handler(nil)) + probe := newStreamProbe() + RegisterHandler("test.streamDisconnect", probe.handler(nil)) t.Cleanup(func() { unregisterHandler("test.streamDisconnect") }) - h := newHarness(t, ctx) - c := h.dial(ctx) + hn := newHarness(t, ctx) + cl := hn.dial(ctx) var start rpc.StreamStart - if e := c.call("test.streamDisconnect", rpc.Empty{}, &start); e != nil { - t.Fatalf("starting stream: %v", e) + if failure := cl.call("test.streamDisconnect", rpc.Empty{}, &start); failure != nil { + t.Fatalf("starting the stream: %v", failure) } - s := <-p.started + live := <-probe.began - c.conn.Close() + cl.conn.Close() select { - case <-p.done: + case <-probe.finished: case <-time.After(2 * time.Second): - t.Fatal("disconnecting did not stop the producer") + t.Fatal("disconnecting left the producer running") } - if s.Context().Err() == nil { + if live.Context().Err() == nil { t.Fatal("the stream context is still live after a disconnect") } - if s.conn.CancelStream(s.ID()) { + if live.conn.CancelStream(live.ID()) { t.Fatal("the stream is still registered after a disconnect") } } @@ -212,77 +219,77 @@ func TestStreamSendFailsAfterCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) - h.srv.subsMu.RLock() + hn := newHarness(t, ctx) + cl := hn.dial(ctx) + hn.srv.subsMu.RLock() var conn *Conn - for _, cc := range h.srv.conns { - conn = cc + for _, candidate := range hn.srv.conns { + conn = candidate } - h.srv.subsMu.RUnlock() - _ = c + hn.srv.subsMu.RUnlock() + _ = cl - sctx, scancel := context.WithCancel(ctx) - s := &Stream{id: "sub-test", conn: conn, ctx: sctx, cancel: scancel} - scancel() + streamCtx, stop := context.WithCancel(ctx) + s := &Stream{id: "sub-test", conn: conn, ctx: streamCtx, cancel: stop} + stop() if err := s.Send(map[string]string{"line": "x"}); err == nil { t.Fatal("Send on a cancelled stream should fail") } } -// TestSubscribeRepliesWithoutWaitingForAScan is an open follow-up: -// `state.subscribe {include: ["stats"]}` used to rescan before replying, so on -// a machine where collecting stats is slow the first snapshot arrived seconds -// late. The reply now comes from the cache — stats may be null in it — and the -// tick the subscription wakes delivers them in the first delta. +// TestSubscribeRepliesWithoutWaitingForAScan covers an open follow-up: +// `state.subscribe {include: ["stats"]}` used to rescan before it replied, so +// on a machine where collecting stats is slow the first snapshot turned up +// seconds late. The reply now comes from the cache — stats may be null in it — +// and the tick the subscription wakes carries them in the first delta. func TestSubscribeRepliesWithoutWaitingForAScan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() const scanDelay = 700 * time.Millisecond - h := &testHarness{t: t} - h.rows = []ports.ListeningPort{{ + hn := &testHarness{t: t} + hn.rows = []ports.ListeningPort{{ Port: 8123, PID: 42, Process: "python3", Command: "python3 -m http.server", }} - h.loop = scanner.New(scanner.Options{ + hn.loop = scanner.New(scanner.Options{ DaemonVersion: "test", Scan: func(inc scanner.Include) ([]ports.ListeningPort, error) { time.Sleep(scanDelay) - h.mu.Lock() - defer h.mu.Unlock() - out := append([]ports.ListeningPort{}, h.rows...) + hn.mu.Lock() + defer hn.mu.Unlock() + rows := append([]ports.ListeningPort{}, hn.rows...) if inc.Stats { - for i := range out { - out[i].CPUPercent, out[i].MemoryRSS = 12.5, 1<<20 + for i := range rows { + rows[i].CPUPercent, rows[i].MemoryRSS = 12.5, 1<<20 } } - return out, nil + return rows, nil }, }) - h.srv = New(Options{Socket: "/test/option-berth.sock", Version: "test", Scanner: h.loop}) - go h.loop.Run(ctx) - c := h.dial(ctx) + hn.srv = New(Options{Socket: "/test/option-berth.sock", Version: "test", Scanner: hn.loop}) + go hn.loop.Run(ctx) + cl := hn.dial(ctx) - start := time.Now() + started := time.Now() var snap state.Snapshot - if e := c.call("state.subscribe", rpc.StateSubscribeParams{ + if e := cl.call("state.subscribe", rpc.StateSubscribeParams{ Include: rpc.Include{"stats"}, }, &snap); e != nil { t.Fatalf("state.subscribe: %v", e) } - if took := time.Since(start); took > 100*time.Millisecond { + if took := time.Since(started); took > 100*time.Millisecond { t.Fatalf("state.subscribe replied after %v against a %v scan, want under 100ms", took, scanDelay) } // The woken tick carries the fields the subscriber asked for. - delta := c.nextDelta() + delta := cl.nextDelta() if len(delta.Ports.Added) != 1 { t.Fatalf("first delta = %+v, want the port", delta.Ports) } - if delta.Ports.Added[0].Stats == nil || delta.Ports.Added[0].Stats.CPUPercent != 12.5 { - t.Fatalf("first delta carries stats %+v, want the collected ones", delta.Ports.Added[0].Stats) + if stats := delta.Ports.Added[0].Stats; stats == nil || stats.CPUPercent != 12.5 { + t.Fatalf("first delta carries stats %+v, want the collected ones", stats) } - // Seq semantics are unchanged: the delta continues from the snapshot + // Seq semantics are unchanged: the delta continues from the snapshot that // the subscribe reply carried. if delta.Seq != snap.Seq+1 { t.Errorf("delta seq = %d, want the snapshot's %d plus one", delta.Seq, snap.Seq) @@ -290,28 +297,29 @@ func TestSubscribeRepliesWithoutWaitingForAScan(t *testing.T) { } // A producer may finish before the cancel handler's acknowledgment is queued. -// c.call deliberately skips notifications, so this test must collect BOTH -// frames rather than assuming a response-before-notification ordering. +// c.call deliberately skips notifications, so this helper has to collect BOTH +// frames rather than assume a response-before-notification ordering. func (c *testClient) cancelStreamAndAwaitEnd(subscription string) rpc.StreamEnd { c.t.Helper() request := c.send("stream.cancel", rpc.StreamCancel{ID: subscription}) replied, ended := false, false - var end rpc.StreamEnd + var ending rpc.StreamEnd for !replied || !ended { msg := c.read() - if msg.IsResponse() && string(msg.ID) == request { + switch { + case msg.IsResponse() && string(msg.ID) == request: if msg.Error != nil { c.t.Fatalf("stream.cancel: %v", msg.Error) } - var ok rpc.OKResult - if err := json.Unmarshal(msg.Result, &ok); err != nil || !ok.OK { + var ack rpc.OKResult + if err := json.Unmarshal(msg.Result, &ack); err != nil || !ack.OK { c.t.Fatalf("invalid cancellation acknowledgment: %v", err) } if replied { c.t.Fatal("duplicate cancellation acknowledgment") } replied = true - } else if msg.IsNotification() && msg.Method == rpc.MethodStreamEnd { + case msg.IsNotification() && msg.Method == rpc.MethodStreamEnd: var next rpc.StreamEnd if err := json.Unmarshal(msg.Params, &next); err != nil { c.t.Fatal(err) @@ -322,8 +330,8 @@ func (c *testClient) cancelStreamAndAwaitEnd(subscription string) rpc.StreamEnd if ended { c.t.Fatal("duplicate stream end") } - end, ended = next, true + ending, ended = next, true } } - return end + return ending } diff --git a/engine/internal/daemon/testguard_test.go b/engine/internal/daemon/testguard_test.go index 11f5229f..9783d29a 100644 --- a/engine/internal/daemon/testguard_test.go +++ b/engine/internal/daemon/testguard_test.go @@ -3,44 +3,55 @@ package daemon import "testing" func TestIsTestBinary(t *testing.T) { - tests := map[string]bool{ - "/tmp/go-build123/b001/cmd.test": true, - "/tmp/go-build123/b001/daemon.test": true, - `C:\Temp\go-build\b001\cmd.test.exe`: true, - "cmd.test": true, - "/usr/local/bin/oberth": false, - `C:\Program Files\option-berth\oberth.exe`: false, - "/tmp/option-berth-itest-bin123/option-berth": false, - "/home/dev/testing/option-berth": false, - "/home/dev/option-berth.test.backup/option-berth": false, + cases := []struct { + path string + test bool + }{ + {path: "/tmp/go-build123/b001/cmd.test", test: true}, + {path: "/tmp/go-build123/b001/daemon.test", test: true}, + {path: `C:\Temp\go-build\b001\cmd.test.exe`, test: true}, + {path: "cmd.test", test: true}, + {path: "/usr/local/bin/oberth", test: false}, + {path: `C:\Program Files\option-berth\oberth.exe`, test: false}, + {path: "/tmp/option-berth-itest-bin123/option-berth", test: false}, + {path: "/home/dev/testing/option-berth", test: false}, + {path: "/home/dev/option-berth.test.backup/option-berth", test: false}, } - for path, want := range tests { - if got := IsTestBinary(path); got != want { - t.Errorf("IsTestBinary(%q) = %v, want %v", path, got, want) + for _, tc := range cases { + if got := IsTestBinary(tc.path); got != tc.test { + t.Errorf("IsTestBinary(%q) = %v, want %v", tc.path, got, tc.test) } } } func TestEnvEnabled(t *testing.T) { - off := []string{"", "0", "false", "no", " FALSE "} - for _, v := range off { - if EnvEnabled(v) { - t.Errorf("EnvEnabled(%q) = true, want false", v) - } + cases := []struct { + value string + on bool + }{ + {"", false}, + {"0", false}, + {"false", false}, + {"no", false}, + {" FALSE ", false}, + {"1", true}, + {"true", true}, + {"yes", true}, + {"anything", true}, } - for _, v := range []string{"1", "true", "yes", "anything"} { - if !EnvEnabled(v) { - t.Errorf("EnvEnabled(%q) = false, want true", v) + for _, tc := range cases { + if got := EnvEnabled(tc.value); got != tc.on { + t.Errorf("EnvEnabled(%q) = %v, want %v", tc.value, got, tc.on) } } } func TestTestDaemonAllowedFollowsTheEnvironment(t *testing.T) { if TestDaemonAllowed() { - t.Fatalf("%s is set in a test run; the guard is disarmed", AllowTestDaemonEnv) + t.Fatalf("%s is set during a test run; the guard is disarmed", AllowTestDaemonEnv) } t.Setenv(AllowTestDaemonEnv, "1") if !TestDaemonAllowed() { - t.Error("the explicit opt-in was not honoured") + t.Error("setting the opt-in variable explicitly did not arm the guard") } } diff --git a/engine/internal/daemon/treekill_test.go b/engine/internal/daemon/treekill_test.go index e0fcd73c..7fa66e1a 100644 --- a/engine/internal/daemon/treekill_test.go +++ b/engine/internal/daemon/treekill_test.go @@ -10,41 +10,45 @@ import ( // stopCommand takes a `option-berth` invocation down for good and reaps it. // -// Killing only the process the test started is not enough: `oberth start` puts +// Killing only the process the test started is not enough. `oberth start` puts // the command it supervises in a process group of its own (daemon spec), so -// the child survives its parent and keeps holding the stdout pipe it -// inherited. Wait then blocks forever copying from a pipe nobody will close, -// which is how a failed assertion turned into a ten-minute CI timeout. The -// whole tree goes, and env.command's WaitDelay is the backstop for anything -// that still escapes. +// that child survives its parent while still holding the inherited stdout pipe. +// Wait then blocks copying from a pipe nobody will close — which is how a +// failed assertion became a ten-minute CI timeout. So the whole tree goes, and +// the WaitDelay that env.command sets is the backstop for anything that gets +// away anyway. func stopCommand(cmd *exec.Cmd) { if cmd == nil || cmd.Process == nil { return } killTree(cmd) + // Reap it now: an unreaped child keeps its pid, and a later kill by pid + // could land on whatever the system handed that number to next. _ = cmd.Wait() } -// descendants lists every process below pid in the process table, deepest -// first, so children are signalled before the parent that could respawn them. -func descendants(pid int) []int { - children := map[int][]int{} +// descendants lists every process below root in the process table, deepest +// first, so a child is signalled before the parent that could respawn it. +func descendants(root int) []int { + byParent := map[int][]int{} for child, parent := range ports.ParentTable() { - if child != parent && child > 1 { - children[parent] = append(children[parent], child) + if child == parent || child <= 1 { + continue } + byParent[parent] = append(byParent[parent], child) } - var out []int - var walk func(int, int) - walk = func(p, depth int) { + + var found []int + var visit func(pid, depth int) + visit = func(pid, depth int) { if depth > 32 { return } - for _, c := range children[p] { - walk(c, depth+1) - out = append(out, c) + for _, child := range byParent[pid] { + visit(child, depth+1) + found = append(found, child) } } - walk(pid, 0) - return out + visit(root, 0) + return found } diff --git a/engine/internal/daemon/treekill_unix_test.go b/engine/internal/daemon/treekill_unix_test.go index 8a30d4b8..3a52af66 100644 --- a/engine/internal/daemon/treekill_unix_test.go +++ b/engine/internal/daemon/treekill_unix_test.go @@ -7,34 +7,36 @@ import ( "syscall" ) -// ownProcessGroup puts a command in a process group of its own so the test can -// signal it separately from the `go test` process it was started from. +// ownProcessGroup gives a command a process group of its own, so the test can +// signal it without signalling the `go test` process that started it. func ownProcessGroup(cmd *exec.Cmd) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{} + attr := cmd.SysProcAttr + if attr == nil { + attr = &syscall.SysProcAttr{} + cmd.SysProcAttr = attr } - cmd.SysProcAttr.Setpgid = true + attr.Setpgid = true } -// killTree SIGKILLs a command, everything below it in the process table and -// its process group. All three: a `oberth start` child gets a process group of -// its own, so the group kill alone would miss it, and the process table can be -// a scan behind. +// killTree SIGKILLs the command, every process the table reports below it, and +// its process group — all three, because a `oberth start` child is put in a +// process group of its own (the group kill alone would miss it) and the +// process table can trail the truth by one scan. // -// The signals are numeric-pid operations, so they are only safe while the -// command is unreaped: a zombie keeps its pid allocated, and a pid that has -// already been waited on can name an unrelated process. Once Wait has run there -// is nothing of this command left to stop. +// Each of those is a numeric-pid operation, so they are only safe while the +// command is still unreaped: a zombie keeps its pid allocated, and a pid that +// has already been waited on can name an unrelated process. Once Wait has run, +// nothing of this command is left to signal. func killTree(cmd *exec.Cmd) { if cmd == nil || cmd.Process == nil || cmd.ProcessState != nil { return } - pid := cmd.Process.Pid - for _, p := range descendants(pid) { - _ = syscall.Kill(p, syscall.SIGKILL) + root := cmd.Process.Pid + for _, pid := range descendants(root) { + _ = syscall.Kill(pid, syscall.SIGKILL) } - if pgid, err := syscall.Getpgid(pid); err == nil && pgid > 1 { - _ = syscall.Kill(-pgid, syscall.SIGKILL) + if group, err := syscall.Getpgid(root); err == nil && group > 1 { + _ = syscall.Kill(-group, syscall.SIGKILL) } _ = cmd.Process.Kill() } diff --git a/engine/internal/daemon/treekill_windows_test.go b/engine/internal/daemon/treekill_windows_test.go index 6a571c76..3300b93f 100644 --- a/engine/internal/daemon/treekill_windows_test.go +++ b/engine/internal/daemon/treekill_windows_test.go @@ -4,10 +4,11 @@ package daemon_test import "os/exec" -// ownProcessGroup is a no-op on Windows: spawn already puts a started command -// in a Job Object, which is what makes the tree die with it. +// ownProcessGroup has nothing to do on Windows. Spawning a process already +// puts it inside a Job Object, and the Job Object is what carries the kill +// down the tree. func ownProcessGroup(*exec.Cmd) {} -// killTree kills the command, and the Job Object takes the rest of the tree -// with it. +// killTree kills the command itself; the Job Object disposes of whatever it +// started below it. func killTree(cmd *exec.Cmd) { _ = cmd.Process.Kill() } From a60d49068352eaaec59e7f98ce2022eacdebbaf4 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 17:02:57 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test(engine):=20daemon=20=E7=9A=84=20groups?= =?UTF-8?q?=20=E4=B8=8E=E9=85=8D=E7=BD=AE=E5=AD=98=E5=82=A8=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8C=89=E8=A1=8C=E4=B8=BA=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groups.init/config/rename/read、worktree_ports、store 与 handlers_store 的 测试重构为 rig 结构体与辅助方法,重复的读取与错误检查收进方法内(调用点 仍然 fail 测试)。夹具端口、服务名与目录换成等价取值并同步断言;测试 函数数不变,未删用例或加 Skip。 Co-Authored-By: Claude Code --- engine/internal/daemon/groups_config_test.go | 507 ++++++++------- engine/internal/daemon/groups_init_test.go | 590 ++++++++---------- engine/internal/daemon/groups_read_test.go | 38 +- engine/internal/daemon/groups_rename_test.go | 242 +++---- engine/internal/daemon/handlers_store_test.go | 75 +-- engine/internal/daemon/store_test.go | 95 ++- engine/internal/daemon/worktree_ports_test.go | 136 ++-- 7 files changed, 822 insertions(+), 861 deletions(-) diff --git a/engine/internal/daemon/groups_config_test.go b/engine/internal/daemon/groups_config_test.go index fbeaab73..7086b9cd 100644 --- a/engine/internal/daemon/groups_config_test.go +++ b/engine/internal/daemon/groups_config_test.go @@ -1,7 +1,6 @@ package daemon import ( - "context" "encoding/json" "os" "path/filepath" @@ -15,290 +14,286 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -const configFixture = `# demo project +// demoManifest is the project the config handlers are exercised against: one +// service that is running (db) and one that is only declared (api, which +// depends on db). +const demoManifest = `# the demo project name: demo services: - # the database goes first + # declared first: everything else depends on it - name: db - cmd: postgres port: 5432 + cmd: postgres - name: api + health: /health cmd: uv run api port: 8000 - health: /health depends_on: [db] ` -// configHarness gives a test a daemon whose index already holds one config, -// seen the way the scanner sees one: through a listening process's cwd. -func configHarness(t *testing.T, ctx context.Context) (*testHarness, string, string) { +// resolvedDir returns dir with its symlinks resolved, which is the form the +// scanner reports: t.TempDir can sit behind /var → /private/var on macOS, and +// comparing against the raw form would fail for reasons that have nothing to +// do with the handler under test. +func resolvedDir(t *testing.T, dir string) string { + t.Helper() + real, err := filepath.EvalSymlinks(dir) + if err != nil { + return dir + } + return real +} + +// configRig is one daemon, a client bound to it and the manifest file it was +// pointed at. Cancelling the test's context shuts the daemon down and the +// constructors arrange that themselves, so a test body starts at the call it +// means to make. +type configRig struct { + t *testing.T + h *testHarness + cl *testClient + dir string + file string +} + +// newConfigRig builds a daemon whose index already holds demoManifest, seen the +// way the scanner sees one: through a listening process's cwd. +func newConfigRig(t *testing.T) *configRig { t.Helper() + // The directory is claimed before the harness, so the harness stops before + // the directory it indexed is removed. dir := resolvedDir(t, t.TempDir()) - path := filepath.Join(dir, groups.ConfigName) - if err := os.WriteFile(path, []byte(configFixture), 0o644); err != nil { + file := filepath.Join(dir, groups.ConfigName) + if err := os.WriteFile(file, []byte(demoManifest), 0o644); err != nil { t.Fatal(err) } - h := newHarness(t, ctx) + h := newHarness(t, t.Context()) h.setRows(ports.ListeningPort{ Port: 5432, BindAddress: "127.0.0.1", PID: 501, Process: "postgres", Cwd: dir, }) if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming the scanner: %v", err) } - return h, dir, path -} - -func resolvedDir(t *testing.T, dir string) string { - t.Helper() - if real, err := filepath.EvalSymlinks(dir); err == nil { - return real - } - return dir -} - -// configCase is one daemon whose index already holds the fixture manifest, a -// client bound to it, and the directory and file the fixture was written to. -// Cancelling the test's context shuts the daemon down; the helper arranges that -// itself, so a test body starts at the call it means to make. -type configCase struct { - harness *testHarness - client *testClient - dir string - path string -} - -// newConfigCase builds the fixture daemon the config handlers are exercised -// against. -func newConfigCase(t *testing.T) *configCase { - t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - harness, dir, path := configHarness(t, ctx) - return &configCase{ - harness: harness, - client: harness.dial(ctx), - dir: dir, - path: path, - } + return &configRig{t: t, h: h, cl: h.dial(t.Context()), dir: dir, file: file} } -// newBareConfigCase is newConfigCase without the fixture manifest: the test -// writes its own file, so the daemon has never scanned the directory. -func newBareConfigCase(t *testing.T) *configCase { +// newBareRig is newConfigRig without the fixture manifest: the test writes its +// own file, so the daemon has never scanned the directory. +func newBareRig(t *testing.T) *configRig { t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - // The directory is claimed before the harness so the harness stops before - // the directory it indexed is removed. dir := resolvedDir(t, t.TempDir()) - harness := newHarness(t, ctx) - return &configCase{harness: harness, client: harness.dial(ctx), dir: dir} + h := newHarness(t, t.Context()) + return &configRig{ + t: t, + h: h, + cl: h.dial(t.Context()), + dir: dir, + file: filepath.Join(dir, groups.ConfigName), + } } -// assertFileHas fails unless the manifest on disk contains want. The hint says -// what losing it would mean, so a failure names the consequence rather than -// just the missing substring. -func assertFileHas(t *testing.T, path, want, hint string) { - t.Helper() - if contents := readFixture(t, path); !strings.Contains(contents, want) { - t.Fatalf("%s (%q missing):\n%s", hint, want, contents) +// manifest returns the file's bytes as they are on disk right now, ending the +// test when it cannot be read: several cases compare the file before and after +// a handler, and a read failure there is always a broken fixture rather than a +// behaviour under test. +func (r *configRig) manifest() string { + r.t.Helper() + body, err := os.ReadFile(r.file) + if err != nil { + r.t.Fatalf("reading %s: %v", r.file, err) } + return string(body) } -// readFixture returns the manifest's bytes as they are on disk right now, -// ending the test when it cannot be read. Several cases compare the file before -// and after a handler, and a read failure there is always a broken fixture -// rather than a behaviour under test. -func readFixture(t *testing.T, path string) string { - t.Helper() - contents, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading %s: %v", path, err) +// requireManifest fails unless the manifest on disk contains want. The hint +// says what losing it would mean, so a failure names the consequence rather +// than just the missing substring. +func (r *configRig) requireManifest(want, hint string) { + r.t.Helper() + if body := r.manifest(); !strings.Contains(body, want) { + r.t.Fatalf("%s (%q missing):\n%s", hint, want, body) } - return string(contents) } // TestGroupsConfigGetByName returns the file as the resolver publishes it, // joined against what is actually running. func TestGroupsConfigGetByName(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path + rig := newConfigRig(t) name := "demo" var res rpc.GroupsConfigGetResult - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, &res); e != nil { + if e := rig.cl.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, &res); e != nil { t.Fatalf("groups.config.get: %v", e) } - if res.Path != path { - t.Errorf("path = %q, want %q", res.Path, path) + if res.Path != rig.file { + t.Errorf("path = %q, want %q", res.Path, rig.file) } if res.Config.Name != "demo" || len(res.Config.Services) != 2 { - t.Fatalf("config = %+v", res.Config) + t.Fatalf("config = %+v, want demo with its two services", res.Config) } db, api := res.Config.Services[0], res.Config.Services[1] - if db.Name != "db" || !db.Running || db.PortActual == nil || *db.PortActual != 5432 { - t.Errorf("db row = %+v, want it running on 5432", db) + if db.Name != "db" || !db.Running { + t.Errorf("db row = %+v, want it running", db) + } + if db.PortActual == nil || *db.PortActual != 5432 { + t.Errorf("db port_actual = %v, want the 5432 that is listening", db.PortActual) } - if api.Health == nil || *api.Health != "/health" || api.Running { - t.Errorf("api row = %+v, want /health and not running", api) + if api.Health == nil || *api.Health != "/health" { + t.Errorf("api health = %v, want the declared /health", api.Health) + } + if api.Running { + t.Errorf("api row = %+v, want it not running", api) } if api.Description != nil || api.Icon != nil || api.Color != nil { t.Errorf("api metadata should be null: %+v", api) } } -// TestGroupsConfigGetByPath also indexes a file the daemon has never scanned. +// TestGroupsConfigGetByPath also indexes a manifest the daemon has never +// scanned. func TestGroupsConfigGetByPath(t *testing.T) { - fixture := newBareConfigCase(t) - c, dir := fixture.client, fixture.dir - - path := filepath.Join(dir, groups.ConfigName) - if err := os.WriteFile(path, []byte(configFixture), 0o644); err != nil { + rig := newBareRig(t) + if err := os.WriteFile(rig.file, []byte(demoManifest), 0o644); err != nil { t.Fatal(err) } + path := rig.file var res rpc.GroupsConfigGetResult - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Path: &path}, &res); e != nil { - t.Fatalf("groups.config.get: %v", e) + if e := rig.cl.call("groups.config.get", rpc.GroupsConfigGetParams{Path: &path}, &res); e != nil { + t.Fatalf("groups.config.get by path: %v", e) } if res.Config.Name != "demo" { - t.Fatalf("config = %+v", res.Config) + t.Fatalf("config = %+v, want the file the daemon never scanned", res.Config) } } func TestGroupsConfigGetNotFound(t *testing.T) { - fixture := newConfigCase(t) - c, dir := fixture.client, fixture.dir + rig := newConfigRig(t) name := "nope" - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, nil); e == nil { - t.Fatal("an unknown group name should not resolve") + if e := rig.cl.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, nil); e == nil { + t.Fatal("an unknown group name resolved") } else if e.Data.Code != "not_found" { t.Errorf("error = %+v, want not_found", e) } - missing := filepath.Join(dir, "sub", groups.ConfigName) - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Path: &missing}, nil); e == nil { - t.Fatal("a missing path should not resolve") + missing := filepath.Join(rig.dir, "sub", groups.ConfigName) + if e := rig.cl.call("groups.config.get", rpc.GroupsConfigGetParams{Path: &missing}, nil); e == nil { + t.Fatal("a missing path resolved") } else if e.Data.Code != "not_found" { t.Errorf("error = %+v, want not_found", e) } - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{}, nil); e == nil { - t.Fatal("neither name nor path should be invalid params") + // Neither name nor path is a caller mistake, not a lookup that failed. + if e := rig.cl.call("groups.config.get", rpc.GroupsConfigGetParams{}, nil); e == nil { + t.Fatal("a call with neither name nor path resolved") } else if e.Data.Code != "invalid_params" { - t.Errorf("error = %+v, want invalid_params", e) + t.Errorf("a call with neither name nor path: error = %+v, want invalid_params", e) } } -// TestGroupsConfigSetWritesAndPublishes is the round trip the desktop editor -// makes: patch a service, keep the comments, and see the change in a delta -// without asking for it. +// TestGroupsConfigSetWritesAndPublishes is the round trip a desktop editor +// makes: patch one service, keep the comments, and see the change in a delta +// without having to ask for it. func TestGroupsConfigSetWritesAndPublishes(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path + rig := newConfigRig(t) // Subscribe first, so the delta the write publishes has somewhere to go. var snap state.Snapshot - if e := c.call("state.subscribe", rpc.StateSubscribeParams{}, &snap); e != nil { + if e := rig.cl.call("state.subscribe", rpc.StateSubscribeParams{}, &snap); e != nil { t.Fatalf("state.subscribe: %v", e) } // The handler publishes before it replies, so the delta is already on the - // wire when the response arrives: read both together. + // wire when the response arrives: one loop collects both. + blueHealth := groups.ServicePatch{}. + SetString(groups.FieldHealth, "/"). + SetString(groups.FieldColor, "blue") var res rpc.GroupsConfigSetResult - edited := callCollectingGroups(t, c, "groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, - Services: []groups.ServiceEdit{{ - Name: "db", - Patch: groups.ServicePatch{}. - SetString(groups.FieldHealth, "/"). - SetString(groups.FieldColor, "blue"), - }}, + deltas := callGatheringGroups(t, rig.cl, "groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, + Services: []groups.ServiceEdit{{Name: "db", Patch: blueHealth}}, }, &res) if !res.OK || len(res.Affected) != 1 || res.Affected[0] != "db" { - t.Errorf("result = %+v", res.MutationResult) + t.Errorf("result = %+v, want the db patch acknowledged", res.MutationResult) } db := res.Config.Services[0] if db.Health == nil || *db.Health != "/" || db.Color == nil || *db.Color != "blue" { t.Fatalf("returned row = %+v", db) } - assertFileHas(t, path, "# the database goes first", "the write dropped a comment the editor must preserve") - assertFileHas(t, path, "color: blue", "the patch never reached the file") + rig.requireManifest("# declared first: everything else depends on it", + "the write dropped a comment the editor must preserve") + rig.requireManifest("color: blue", "the patch never reached the file") // The delta carries the edited group. - g, ok := edited["demo"] - if !ok { - g2 := waitForGroup(t, c, "demo") - if g2 == nil { + g, seen := deltas["demo"] + if !seen { + published := awaitGroup(t, rig.cl, "demo") + if published == nil { t.Fatal("no groups delta after the write") } - g = *g2 + g = *published } if g.Services[0].Color == nil || *g.Services[0].Color != "blue" { t.Errorf("the published group is stale: %+v", g.Services[0]) } } -// callCollectingGroups makes one call and returns every group that arrived in a +// callGatheringGroups makes one call and returns every group that arrived in a // delta while it was in flight, keyed by name. -func callCollectingGroups(t *testing.T, c *testClient, method string, params, out any) map[string]state.Group { +func callGatheringGroups(t *testing.T, cl *testClient, method string, params, out any) map[string]state.Group { t.Helper() - id := c.send(method, params) - seen := map[string]state.Group{} + id := cl.send(method, params) + gathered := map[string]state.Group{} for { - m := c.read() - if m.Method == rpc.MethodStateDelta { + m := cl.read() + switch { + case m.Method == rpc.MethodStateDelta: var d state.Delta if err := json.Unmarshal(m.Params, &d); err != nil { t.Fatalf("decoding delta: %v", err) } for _, g := range append(append([]state.Group{}, d.Groups.Added...), d.Groups.Updated...) { - seen[g.Name] = g + gathered[g.Name] = g + } + case m.IsResponse() && string(m.ID) == id: + if m.Error != nil { + t.Fatalf("%s failed: %v", method, m.Error) + } + if out == nil || len(m.Result) == 0 { + return gathered } - continue - } - if !m.IsResponse() || string(m.ID) != id { - continue - } - if m.Error != nil { - t.Fatalf("%s: %v", method, m.Error) - } - if out != nil && len(m.Result) > 0 { if err := json.Unmarshal(m.Result, out); err != nil { - t.Fatalf("decoding %s result: %v", method, err) + t.Fatalf("decoding the %s result: %v", method, err) } + return gathered } - return seen } } func TestGroupsConfigSetErrors(t *testing.T) { - fixture := newConfigCase(t) - c, dir, path := fixture.client, fixture.dir, fixture.path - before := readFixture(t, path) + rig := newConfigRig(t) + before := rig.manifest() // A service the file does not declare. - if e := c.call("groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, + if e := rig.cl.call("groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, Services: []groups.ServiceEdit{{Name: "worker", Patch: groups.ServicePatch{}.SetString(groups.FieldIcon, "gear")}}, }, nil); e == nil { - t.Fatal("patching an unknown service should fail") + t.Fatal("patching an unknown service was accepted") } else if e.Data.Code != "not_found" { t.Errorf("error = %+v, want not_found", e) } // A patch that would make the file invalid. - e := c.call("groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, + e := rig.cl.call("groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, Services: []groups.ServiceEdit{{Name: "api", Patch: groups.ServicePatch{}.SetPort(70000)}}, }, nil) if e == nil { - t.Fatal("an invalid port should fail") + t.Fatal("an invalid port was accepted") } if e.Code != rpc.CodeInvalidConfig || e.Data.Code != "invalid_config" { t.Errorf("error = %+v, want 1006 invalid_config", e) @@ -307,16 +302,15 @@ func TestGroupsConfigSetErrors(t *testing.T) { t.Errorf("detail should name the bad value: %q", e.Data.Detail) } - // Anything that is not a .oberth.yaml is refused outright. - if e := c.call("groups.config.set", rpc.GroupsConfigSetParams{ - Path: filepath.Join(dir, "docker-compose.yml"), + // Anything that is not a oberth.yaml is refused outright. + if e := rig.cl.call("groups.config.set", rpc.GroupsConfigSetParams{ + Path: filepath.Join(rig.dir, "docker-compose.yml"), Services: []groups.ServiceEdit{{Name: "api", Patch: groups.ServicePatch{}.SetString(groups.FieldIcon, "x")}}, }, nil); e == nil || e.Data.Code != "invalid_params" { t.Errorf("error = %+v, want invalid_params", e) } - after := readFixture(t, path) - if after != before { + if after := rig.manifest(); after != before { t.Errorf("a failed write changed the file:\n%s", after) } } @@ -324,132 +318,128 @@ func TestGroupsConfigSetErrors(t *testing.T) { // TestGroupsReloadPicksUpANewFile: the index is long-lived, so this is the // method that makes a project created after the daemon started a group. func TestGroupsReloadPicksUpANewFile(t *testing.T) { - fixture := newConfigCase(t) - c, dir := fixture.client, fixture.dir + rig := newConfigRig(t) - sub := filepath.Join(dir, "nested") - if err := os.MkdirAll(sub, 0o755); err != nil { + sub := filepath.Join(rig.dir, "nested") + nested := filepath.Join(sub, groups.ConfigName) + if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(sub, groups.ConfigName), + if err := os.WriteFile(nested, []byte("name: nested\nservices:\n - name: worker\n"), 0o644); err != nil { t.Fatal(err) } - // The daemon only knows the parent directory, so nothing has seen this - // file yet; the reload has to walk the roots it remembers. - fixture.harness.loop.ObserveConfig(sub) + // The daemon only knows the parent directory, so nothing has seen this file + // yet; the reload has to walk the roots it remembers. + rig.h.loop.ObserveConfig(sub) var res rpc.GroupsReloadResult - if e := c.call("groups.reload", rpc.Empty{}, &res); e != nil { + if e := rig.cl.call("groups.reload", rpc.Empty{}, &res); e != nil { t.Fatalf("groups.reload: %v", e) } if res.Loaded != 2 || len(res.Errors) != 0 { - t.Fatalf("reload = %+v", res) + t.Fatalf("reload = %+v, want both files loaded and no errors", res) } name := "nested" var got rpc.GroupsConfigGetResult - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, &got); e != nil { + if e := rig.cl.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, &got); e != nil { t.Fatalf("the new config is not in the index: %v", e) } if got.Config.Services[0].Name != "worker" { - t.Errorf("config = %+v", got.Config) + t.Errorf("config = %+v, want the nested project's worker", got.Config) } } // TestGroupsReloadReportsBrokenFiles: a file that does not validate is listed, // never fatal. func TestGroupsReloadReportsBrokenFiles(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path + rig := newConfigRig(t) - if err := os.WriteFile(path, []byte("name: two words\nservices:\n - name: api\n"), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte("name: two words\nservices:\n - name: api\n"), 0o644); err != nil { t.Fatal(err) } var res rpc.GroupsReloadResult - if e := c.call("groups.reload", rpc.Empty{}, &res); e != nil { + if e := rig.cl.call("groups.reload", rpc.Empty{}, &res); e != nil { t.Fatalf("groups.reload: %v", e) } if res.Loaded != 0 || len(res.Errors) != 1 { - t.Fatalf("reload = %+v", res) + t.Fatalf("reload = %+v, want the one broken file reported", res) } - if res.Errors[0].Path != path || !strings.Contains(res.Errors[0].Error, "whitespace") { + if res.Errors[0].Path != rig.file || !strings.Contains(res.Errors[0].Error, "whitespace") { t.Errorf("error row = %+v", res.Errors[0]) } } -// waitForGroup reads notifications until a delta carries the named group. -func waitForGroup(t *testing.T, c *testClient, name string) *state.Group { +// awaitGroup reads notifications until a delta carries the named group. +func awaitGroup(t *testing.T, cl *testClient, name string) *state.Group { t.Helper() - for i := 0; i < 20; i++ { - m := c.read() - if m.Method != rpc.MethodStateDelta { + for range 20 { + msg := cl.read() + if msg.Method != rpc.MethodStateDelta { continue } - var d state.Delta - if err := json.Unmarshal(m.Params, &d); err != nil { - t.Fatalf("decoding delta: %v", err) + var delta state.Delta + if err := json.Unmarshal(msg.Params, &delta); err != nil { + t.Fatalf("decoding the delta: %v", err) } - for _, g := range append(append([]state.Group{}, d.Groups.Added...), d.Groups.Updated...) { + for _, g := range append(append([]state.Group{}, delta.Groups.Added...), delta.Groups.Updated...) { if g.Name == name { - out := g - return &out + return &g } } } return nil } -// TestGroupsConfigSetAddsRenamesAndRemoves is why the call exists: the -// daemon owns every edit to a `.oberth.yaml`, so a client never has to write the -// file itself to add a service or rename one. +// TestGroupsConfigSetAddsRenamesAndRemoves is why the call exists: the daemon +// owns every edit to an oberth.yaml, so a client never has to write the file +// itself to add a service or rename one. func TestGroupsConfigSetAddsRenamesAndRemoves(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path + rig := newConfigRig(t) var snap state.Snapshot - if e := c.call("state.subscribe", rpc.StateSubscribeParams{}, &snap); e != nil { + if e := rig.cl.call("state.subscribe", rpc.StateSubscribeParams{}, &snap); e != nil { t.Fatalf("state.subscribe: %v", e) } - // Add. The delta collected here is the one published before the reply was - // queued: the caller cannot see the acknowledgement - // before the change. + // Add. The delta collected here was published before the reply was queued: + // the caller cannot see the acknowledgement before the change. + worker := groups.ServiceAdd{ + Name: "worker", Port: 9000, Cmd: "uv run worker", Color: "green", DependsOn: []string{"db"}, + } var res rpc.GroupsConfigSetResult - seen := callCollectingGroups(t, c, "groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, - Add: []groups.ServiceAdd{{ - Name: "worker", Port: 9000, Cmd: "uv run worker", Color: "green", DependsOn: []string{"db"}, - }}, + seen := callGatheringGroups(t, rig.cl, "groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, + Add: []groups.ServiceAdd{worker}, }, &res) if !res.OK || len(res.Affected) != 1 || res.Affected[0] != "worker" { - t.Errorf("result = %+v", res.MutationResult) + t.Errorf("result = %+v, want the add acknowledged", res.MutationResult) } if len(res.Config.Services) != 3 || res.Config.Services[2].Name != "worker" { t.Fatalf("returned config = %+v", res.Config.Services) } - g, ok := seen["demo"] - if !ok { + g, seenDelta := seen["demo"] + if !seenDelta { t.Fatal("the add was acknowledged before the delta that carries it") } if len(g.Services) != 3 || g.Services[2].Name != "worker" { t.Errorf("the published group is stale: %+v", g.Services) } - raw := readFixture(t, path) - if !strings.Contains(raw, "# the database goes first") { + if raw := rig.manifest(); !strings.Contains(raw, "# declared first: everything else depends on it") { t.Errorf("the comment was lost:\n%s", raw) } // Rename, and every depends_on reference with it. - seen = callCollectingGroups(t, c, "groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, + seen = callGatheringGroups(t, rig.cl, "groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, Rename: []groups.ServiceRename{{From: "db", To: "database"}}, }, &res) if len(res.Affected) != 1 || res.Affected[0] != "database" { t.Errorf("affected = %v, want the new name", res.Affected) } - raw = readFixture(t, path) + raw := rig.manifest() if strings.Contains(raw, "depends_on: [db]") || !strings.Contains(raw, "depends_on: [database]") { t.Errorf("the rename did not follow depends_on:\n%s", raw) } @@ -458,47 +448,46 @@ func TestGroupsConfigSetAddsRenamesAndRemoves(t *testing.T) { } // Remove, and the dependency on it. - if e := c.call("groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, + if e := rig.cl.call("groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, Remove: []string{"database"}, }, &res); e != nil { - t.Fatalf("groups.config.set remove: %v", e) + t.Fatalf("removing database: %v", e) } if len(res.Affected) != 1 || res.Affected[0] != "database" { - t.Errorf("affected = %v", res.Affected) + t.Errorf("affected = %v, want the removed name", res.Affected) } - raw = readFixture(t, path) + raw = rig.manifest() if strings.Contains(raw, "database") { t.Errorf("the removed service is still referenced:\n%s", raw) } if len(res.Config.Services) != 2 { - t.Errorf("config = %+v, want api and worker left", res.Config.Services) + t.Errorf("config = %+v, want api and worker left behind", res.Config.Services) } } -// TestGroupsConfigSetIsAtomic: the four lists are one change. A rename that +// TestGroupsConfigSetIsAtomic: the four lists are one change, so a rename that // clashes after an add has already been applied must leave the file byte for // byte as it was. func TestGroupsConfigSetIsAtomic(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path - before := readFixture(t, path) + rig := newConfigRig(t) + before := rig.manifest() - e := c.call("groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, + clash := rpc.GroupsConfigSetParams{ + Path: rig.file, Add: []groups.ServiceAdd{{Name: "worker", Port: 9000}}, Rename: []groups.ServiceRename{{From: "db", To: "api"}}, Services: []groups.ServiceEdit{{Name: "api", Patch: groups.ServicePatch{}.SetString(groups.FieldIcon, "gear")}}, - }, nil) + } + e := rig.cl.call("groups.config.set", clash, nil) if e == nil { - t.Fatal("a rename onto an existing name should fail the whole call") + t.Fatal("a rename onto an existing name was accepted") } if e.Code != rpc.CodeConflict || e.Data.Code != "conflict" { t.Errorf("error = %+v, want 1011 conflict", e) } - after := readFixture(t, path) - if after != before { + if after := rig.manifest(); after != before { t.Errorf("a failed edit changed the file:\n%s", after) } } @@ -508,55 +497,54 @@ func TestGroupsConfigSetIsAtomic(t *testing.T) { // leave the file untouched — a rejected edit that still writes is worse than an // accepted one that fails later. func TestGroupsConfigSetEditErrors(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path - before := readFixture(t, path) - - cases := []struct { - label string - edit rpc.GroupsConfigSetParams - want string + rig := newConfigRig(t) + before := rig.manifest() + + refusals := []struct { + what string + edit rpc.GroupsConfigSetParams + code string }{ {"a name the file already has", rpc.GroupsConfigSetParams{ - Path: path, Add: []groups.ServiceAdd{{Name: "api", Port: 9000}}, + Path: rig.file, Add: []groups.ServiceAdd{{Name: "api", Port: 9000}}, }, "conflict"}, {"a port the file already has", rpc.GroupsConfigSetParams{ - Path: path, Add: []groups.ServiceAdd{{Name: "worker", Port: 8000}}, + Path: rig.file, Add: []groups.ServiceAdd{{Name: "worker", Port: 8000}}, }, "conflict"}, {"renaming a service that is not there", rpc.GroupsConfigSetParams{ - Path: path, Rename: []groups.ServiceRename{{From: "worker", To: "jobs"}}, + Path: rig.file, Rename: []groups.ServiceRename{{From: "worker", To: "jobs"}}, }, "not_found"}, {"removing a service that is not there", rpc.GroupsConfigSetParams{ - Path: path, Remove: []string{"worker"}, + Path: rig.file, Remove: []string{"worker"}, }, "not_found"}, - {"an edit that asks for nothing", rpc.GroupsConfigSetParams{Path: path}, "invalid_params"}, + {"an edit that asks for nothing", rpc.GroupsConfigSetParams{Path: rig.file}, "invalid_params"}, {"a rename with no target name", rpc.GroupsConfigSetParams{ - Path: path, Rename: []groups.ServiceRename{{From: "db", To: " "}}, + Path: rig.file, Rename: []groups.ServiceRename{{From: "db", To: " "}}, }, "invalid_params"}, {"an add with no name", rpc.GroupsConfigSetParams{ - Path: path, Add: []groups.ServiceAdd{{Port: 9000}}, + Path: rig.file, Add: []groups.ServiceAdd{{Port: 9000}}, }, "invalid_params"}, {"an add whose depends_on names nothing", rpc.GroupsConfigSetParams{ - Path: path, Add: []groups.ServiceAdd{{Name: "worker", Port: 9000, DependsOn: []string{"nope"}}}, + Path: rig.file, Add: []groups.ServiceAdd{{Name: "worker", Port: 9000, DependsOn: []string{"nope"}}}, }, "invalid_config"}, } - for _, tc := range cases { - t.Run(tc.label, func(t *testing.T) { - refusal := c.call("groups.config.set", tc.edit, nil) - if refusal == nil { - t.Fatalf("the edit was accepted, want %s", tc.want) + for _, refusal := range refusals { + t.Run(refusal.what, func(t *testing.T) { + e := rig.cl.call("groups.config.set", refusal.edit, nil) + if e == nil { + t.Fatalf("the edit was accepted, want %s", refusal.code) } - if refusal.Data.Code != tc.want { - t.Fatalf("code = %q, want %q (%+v)", refusal.Data.Code, tc.want, refusal) + if e.Data.Code != refusal.code { + t.Fatalf("code = %q, want %q (%+v)", e.Data.Code, refusal.code, e) } - if refusal.Data.Hint == "" { - t.Errorf("the refusal carries no hint: %+v", refusal) + if e.Data.Hint == "" { + t.Errorf("the refusal carries no hint: %+v", e) } }) } - if after := readFixture(t, path); after != before { + if after := rig.manifest(); after != before { t.Errorf("a refused edit changed the file:\n%s", after) } } @@ -564,21 +552,20 @@ func TestGroupsConfigSetEditErrors(t *testing.T) { // TestGroupsConfigSetRemoveThenAddReusesThePort: the lists are applied remove // first, so swapping one service for another on the same port is one call. func TestGroupsConfigSetRemoveThenAddReusesThePort(t *testing.T) { - fixture := newConfigCase(t) - c, path := fixture.client, fixture.path + rig := newConfigRig(t) var res rpc.GroupsConfigSetResult - if e := c.call("groups.config.set", rpc.GroupsConfigSetParams{ - Path: path, + if e := rig.cl.call("groups.config.set", rpc.GroupsConfigSetParams{ + Path: rig.file, Remove: []string{"api"}, - Add: []groups.ServiceAdd{{Name: "gateway", Port: 8000}}, + Add: []groups.ServiceAdd{{Name: "frontend", Port: 8000}}, }, &res); e != nil { - t.Fatalf("groups.config.set: %v", e) + t.Fatalf("swapping api for frontend on the same port: %v", e) } - if len(res.Affected) != 2 || res.Affected[0] != "api" || res.Affected[1] != "gateway" { + if len(res.Affected) != 2 || res.Affected[0] != "api" || res.Affected[1] != "frontend" { t.Errorf("affected = %v, want the removal then the add", res.Affected) } - if len(res.Config.Services) != 2 || res.Config.Services[1].Name != "gateway" { - t.Errorf("config = %+v", res.Config.Services) + if len(res.Config.Services) != 2 || res.Config.Services[1].Name != "frontend" { + t.Errorf("config = %+v, want frontend in api's place", res.Config.Services) } } diff --git a/engine/internal/daemon/groups_init_test.go b/engine/internal/daemon/groups_init_test.go index 71ef46b9..edaab895 100644 --- a/engine/internal/daemon/groups_init_test.go +++ b/engine/internal/daemon/groups_init_test.go @@ -2,10 +2,10 @@ package daemon import ( "bytes" - "context" "log/slog" "os" "path/filepath" + "slices" "strings" "testing" @@ -16,96 +16,160 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// initHarness gives a test a daemon that can see two services listening inside -// a git checkout that has no `.oberth.yaml` yet — the state `groups.init` exists -// to get out of. -func initHarness(t *testing.T, ctx context.Context) (*testHarness, string, string) { +// initRig is a daemon that can see two services listening inside a git +// checkout that has no manifest yet — the state groups.init exists to get out +// of. +type initRig struct { + t *testing.T + h *testHarness + cl *testClient + root string // the checkout the proposal is for + file string // the manifest groups.init would write +} + +func newInitRig(t *testing.T) *initRig { t.Helper() root := resolvedDir(t, t.TempDir()) if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatal(err) } - h := newHarness(t, ctx) - h.setRows( - ports.ListeningPort{ - Port: 8000, BindAddress: "127.0.0.1", PID: 4242, - Process: "python", Display: "api", Command: "uv run api", Cwd: root, - }, - ports.ListeningPort{ - Port: 5173, BindAddress: "127.0.0.1", PID: 4243, - Process: "node", Display: "web", Command: "npm run dev", - Cwd: filepath.Join(root, "web"), - }, - // Somebody else's process, in somebody else's directory: it must not - // end up in this project's file. - ports.ListeningPort{ - Port: 9999, BindAddress: "127.0.0.1", PID: 4244, - Process: "other", Display: "other", Command: "other", Cwd: resolvedDir(t, t.TempDir()), - }, - ) + h := newHarness(t, t.Context()) + api := ports.ListeningPort{ + Port: 8000, BindAddress: "127.0.0.1", PID: 4242, + Process: "python", Display: "api", Command: "uv run api", Cwd: root, + } + web := ports.ListeningPort{ + Port: 5173, BindAddress: "127.0.0.1", PID: 4243, + Process: "node", Display: "web", Command: "npm run dev", Cwd: filepath.Join(root, "web"), + } + // Somebody else's process in somebody else's directory: it must not reach + // this project's file. + stranger := ports.ListeningPort{ + Port: 9999, BindAddress: "127.0.0.1", PID: 4244, + Process: "other", Display: "other", Command: "other", Cwd: resolvedDir(t, t.TempDir()), + } + h.setRows(api, web, stranger) if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming the scanner: %v", err) } - return h, root, filepath.Join(root, groups.ConfigName) + return &initRig{ + t: t, + h: h, + cl: h.dial(t.Context()), + root: root, + file: filepath.Join(root, groups.ConfigName), + } } -// callInit is the method under test, with the params a caller sends. -func callInit(t *testing.T, c *testClient, p rpc.GroupsInitParams) (rpc.GroupsInitResult, *rpc.Error) { - t.Helper() +// init sends groups.init the way a caller does and hands back the answer. +func (r *initRig) init(p rpc.GroupsInitParams) (rpc.GroupsInitResult, *rpc.Error) { + r.t.Helper() var res rpc.GroupsInitResult - e := c.call("groups.init", p, &res) + e := r.cl.call("groups.init", p, &res) return res, e } +// proposal is the default call: a dry run over a checkout with no manifest, +// which must never fail. +func (r *initRig) proposal() rpc.GroupsInitResult { + r.t.Helper() + res, e := r.init(rpc.GroupsInitParams{RootDir: r.root}) + if e != nil { + r.t.Fatalf("groups.init: %v", e) + } + return res +} + +// onDisk is the manifest as it is on disk right now. +func (r *initRig) onDisk() string { + r.t.Helper() + body, err := os.ReadFile(r.file) + if err != nil { + r.t.Fatalf("reading %s: %v", r.file, err) + } + return string(body) +} + +// load writes a proposal to a scratch file and reads it back through the real +// loader, which is the check that matters: the daemon must never propose +// something groups.Load would reject. +func (r *initRig) load(text string) *groups.Config { + r.t.Helper() + path := filepath.Join(r.t.TempDir(), groups.ConfigName) + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + r.t.Fatal(err) + } + cfg, err := groups.Load(path) + if err != nil { + r.t.Fatalf("the proposal does not load: %v\n%s", err, text) + } + return cfg +} + +// declaredPorts is the ports a proposed file declares, in order. +func (r *initRig) declaredPorts(text string) []int { + r.t.Helper() + cfg := r.load(text) + out := make([]int, 0, len(cfg.Services)) + for _, s := range cfg.Services { + out = append(out, s.Port) + } + return out +} + +// groupNamed picks one group out of a list by name. +func groupNamed(gg []state.Group, name string) (state.Group, bool) { + if i := slices.IndexFunc(gg, func(g state.Group) bool { return g.Name == name }); i >= 0 { + return gg[i], true + } + return state.Group{}, false +} + // TestGroupsInitDryRunReturnsAParsableProposal is the default call: no write, // and what comes back is the exact file that would have been written. func TestGroupsInitDryRunReturnsAParsableProposal(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root}) - if e != nil { - t.Fatalf("groups.init: %v", e) - } - if !res.OK || res.Path != target { - t.Fatalf("result = %+v, want ok with path %q", res, target) + res := rig.proposal() + if !res.OK || res.Path != rig.file { + t.Fatalf("result = %+v, want ok with path %q", res, rig.file) } - if _, err := os.Lstat(target); !os.IsNotExist(err) { - t.Fatalf("a dry run must not write %s (err = %v)", target, err) + if _, err := os.Lstat(rig.file); !os.IsNotExist(err) { + t.Fatalf("a dry run wrote %s (err = %v)", rig.file, err) } if !strings.Contains(res.YAML, "oberth init") { - t.Errorf("yaml is missing the generated header:\n%s", res.YAML) + t.Errorf("the yaml carries no generated header:\n%s", res.YAML) } // The proposal has to survive the loader, or `write: true` would put an // unusable file on disk. - cfg := loadYAML(t, res.YAML) + cfg := rig.load(res.YAML) if cfg.Name != res.Proposal.Name { - t.Errorf("parsed name %q, proposal says %q", cfg.Name, res.Proposal.Name) + t.Errorf("the loader reads name %q, the proposal says %q", cfg.Name, res.Proposal.Name) } // Nothing is declared: two ports are listening in the checkout, and neither - // becomes a service. option-berth has no way to state how to start either one - // — the command line a process was launched with is a snapshot of that run. + // becomes a service. option-berth has no way to state how to start either + // one — the command line a process was launched with is a snapshot of that + // run. if len(cfg.Services) != 0 { t.Fatalf("parsed services = %+v, want none: a proposal declares nothing", cfg.Services) } - if got := servicePorts(t, res.YAML); len(got) != 0 { + if got := rig.declaredPorts(res.YAML); len(got) != 0 { t.Errorf("declared ports = %v, want none", got) } if len(res.Affected) != 0 { t.Errorf("affected = %v, want none — no service was written", res.Affected) } - // What it saw is still reported, as the context whoever writes the list needs. + // What it saw is still reported, as the context whoever writes the list + // needs. for _, want := range []string{"8000", "5173", "api", "web"} { if !strings.Contains(res.YAML, want) { t.Errorf("the file does not mention %q as context:\n%s", want, res.YAML) } } - if res.Proposal.ConfigPath == nil || *res.Proposal.ConfigPath != target { - t.Errorf("proposal config_path = %v, want %q", res.Proposal.ConfigPath, target) + if res.Proposal.ConfigPath == nil || *res.Proposal.ConfigPath != rig.file { + t.Errorf("proposal config_path = %v, want %q", res.Proposal.ConfigPath, rig.file) } if len(res.Proposal.Services) != 0 || len(res.Proposal.Members) != 0 || res.Proposal.Status != "stopped" { t.Errorf("proposal group = %+v, want a file with nothing declared", res.Proposal) @@ -117,26 +181,19 @@ func TestGroupsInitDryRunReturnsAParsableProposal(t *testing.T) { // as a comment with its source, and the list stays empty — the same rule the // CLI's `oberth init` follows, so the two paths write the same file. func TestGroupsInitCarriesDeclarationsAsComments(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, _ := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - if err := os.WriteFile(filepath.Join(root, "compose.yaml"), + if err := os.WriteFile(filepath.Join(rig.root, "compose.yaml"), []byte("services:\n db:\n image: postgres\n ports: [\"5432:5432\"]\n"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, "package.json"), + if err := os.WriteFile(filepath.Join(rig.root, "package.json"), []byte(`{"scripts":{"dev":"vite"}}`), 0o644); err != nil { t.Fatal(err) } - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root}) - if e != nil { - t.Fatalf("groups.init: %v", e) - } - cfg := loadYAML(t, res.YAML) - if len(cfg.Services) != 0 { + res := rig.proposal() + if cfg := rig.load(res.YAML); len(cfg.Services) != 0 { t.Fatalf("parsed services = %+v, want none: a declaration is not this project's service", cfg.Services) } for _, want := range []string{ @@ -152,190 +209,129 @@ func TestGroupsInitCarriesDeclarationsAsComments(t *testing.T) { // TestGroupsInitWriteFlipsTheGroupToFile is the whole point of the method: the // file lands on disk and the next read already calls the group a file group. func TestGroupsInitWriteFlipsTheGroupToFile(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true}) + res, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true}) if e != nil { - t.Fatalf("groups.init: %v", e) + t.Fatalf("groups.init --write: %v", e) } - onDisk, err := os.ReadFile(target) - if err != nil { - t.Fatalf("reading what the daemon wrote: %v", err) - } - if string(onDisk) != res.YAML { - t.Errorf("the file and the returned yaml differ:\n%s\n---\n%s", onDisk, res.YAML) + if written := rig.onDisk(); written != res.YAML { + t.Errorf("the file and the returned yaml differ:\n%s\n---\n%s", written, res.YAML) } - var list rpc.GroupsListResult - if e := c.call("groups.list", nil, &list); e != nil { + var listed rpc.GroupsListResult + if e := rig.cl.call("groups.list", nil, &listed); e != nil { t.Fatalf("groups.list: %v", e) } - g, ok := groupNamed(list.Groups, res.Proposal.Name) - if !ok { - t.Fatalf("group %q is gone after init: %+v", res.Proposal.Name, list.Groups) + g, found := groupNamed(listed.Groups, res.Proposal.Name) + if !found { + t.Fatalf("group %q is gone after init: %+v", res.Proposal.Name, listed.Groups) } if g.Source != "file" { t.Errorf("group source = %q, want file", g.Source) } - if g.ConfigPath == nil || *g.ConfigPath != target { - t.Errorf("group config_path = %v, want %q", g.ConfigPath, target) + if g.ConfigPath == nil || *g.ConfigPath != rig.file { + t.Errorf("group config_path = %v, want %q", g.ConfigPath, rig.file) } // The group is on file now, and what it declares is what the file says: - // nothing, until the user writes the list. The ports are still listed as - // members, because those are what is running, not what is declared. + // nothing, until the user writes the list. The ports stay listed as members, + // because those are what is running, not what is declared. if len(g.Services) != 0 { t.Fatalf("group services = %+v, want none: the file declares none", g.Services) } } -// TestGroupsInitRefusesToOverwrite keeps §16's rule on the wire: an existing -// file is never clobbered by accident, and the error names the way past it. +// TestGroupsInitRefusesToOverwrite keeps the never-clobber rule on the wire: an +// existing file is never overwritten by accident, and the error names the way +// past it. func TestGroupsInitRefusesToOverwrite(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) const hand = "name: hand-written\n" - if err := os.WriteFile(target, []byte(hand), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte(hand), 0o644); err != nil { t.Fatal(err) } // A dry run still answers: reading is never blocked by an existing file. - if _, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root}); e != nil { + if _, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root}); e != nil { t.Fatalf("dry run over an existing file: %v", e) } - _, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true}) + _, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true}) if e == nil { - t.Fatal("writing over an existing file should have been refused") + t.Fatal("writing over an existing file was not refused") } if e.Data.Code != "invalid_params" || !strings.Contains(e.Data.Hint, "force") { t.Errorf("error = %+v, want invalid_params hinting at force", e.Data) } - if b, _ := os.ReadFile(target); string(b) != hand { - t.Errorf("the file was touched anyway:\n%s", b) + if body := rig.onDisk(); body != hand { + t.Errorf("the file was touched anyway:\n%s", body) } } // TestGroupsInitForceOverwrites is the other half of the same rule. func TestGroupsInitForceOverwrites(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - if err := os.WriteFile(target, []byte("name: hand-written\n"), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte("name: hand-written\n"), 0o644); err != nil { t.Fatal(err) } - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true, Force: true}) + res, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true, Force: true}) if e != nil { t.Fatalf("groups.init --force: %v", e) } - b, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(b) != res.YAML || strings.Contains(string(b), "hand-written") { - t.Errorf("force did not replace the file:\n%s", b) + if body := rig.onDisk(); body != res.YAML || strings.Contains(body, "hand-written") { + t.Errorf("force did not replace the file:\n%s", body) } } // TestGroupsInitRejectsABadRoot: the daemon has no working directory of the // caller's, so a root it cannot use is a parameter error, not a guess. func TestGroupsInitRejectsABadRoot(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, _ := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - file := filepath.Join(root, "notadir") + file := filepath.Join(rig.root, "notadir") if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { t.Fatal(err) } - for _, tc := range []struct { - name string - root string - code string + for _, bad := range []struct { + what string + dir string + want string }{ {"missing", "", "invalid_params"}, {"relative", filepath.Join("relative", "path"), "invalid_params"}, - {"nonexistent", filepath.Join(root, "nope", "nope"), "not_found"}, + {"nonexistent", filepath.Join(rig.root, "nope", "nope"), "not_found"}, {"not a directory", file, "invalid_params"}, } { - t.Run(tc.name, func(t *testing.T) { - _, e := callInit(t, c, rpc.GroupsInitParams{RootDir: tc.root, Write: true}) + t.Run(bad.what, func(t *testing.T) { + _, e := rig.init(rpc.GroupsInitParams{RootDir: bad.dir, Write: true}) if e == nil { - t.Fatalf("root_dir %q should have been refused", tc.root) + t.Fatalf("root_dir %q was accepted", bad.dir) } - if e.Data.Code != tc.code { - t.Errorf("error code = %q, want %q (%s)", e.Data.Code, tc.code, e.Data.Detail) + if e.Data.Code != bad.want { + t.Errorf("error code = %q, want %q (%s)", e.Data.Code, bad.want, e.Data.Detail) } }) } } -// loadYAML writes a proposal to a scratch file and reads it back through the -// real loader, which is the check that matters: the daemon must never propose -// something `groups.Load` would reject. -func loadYAML(t *testing.T, text string) *groups.Config { - t.Helper() - path := filepath.Join(t.TempDir(), groups.ConfigName) - if err := os.WriteFile(path, []byte(text), 0o644); err != nil { - t.Fatal(err) - } - cfg, err := groups.Load(path) - if err != nil { - t.Fatalf("the proposal does not load: %v\n%s", err, text) - } - return cfg -} - -// servicePorts is the proposed ports, sorted, read back out of the YAML. -func servicePorts(t *testing.T, text string) []int { - t.Helper() - cfg := loadYAML(t, text) - out := make([]int, 0, len(cfg.Services)) - for _, s := range cfg.Services { - out = append(out, s.Port) - } - return out -} - -func groupNamed(gg []state.Group, name string) (state.Group, bool) { - for _, g := range gg { - if g.Name == name { - return g, true - } - } - return state.Group{}, false -} - // TestGroupsInitCuratesTheProposal: a caller that has let a user name the -// services writes the whole curated file in one call. The list is -// the caller's word for word: a port the caller kept does not get a command or -// a directory copied onto it from whatever happens to be listening there. That -// copy used to look helpful and was the bug — it produced entries whose command -// described one past run, so `oberth up` could not start them. +// services writes the whole curated file in one call. The list is the caller's +// word for word: a port the caller kept does not get a command or a directory +// copied onto it from whatever happens to be listening there. That copy used to +// look helpful and was the bug — it produced entries whose command described +// one past run, so `oberth up` could not start them. func TestGroupsInitCuratesTheProposal(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) - - res, e := callInit(t, c, rpc.GroupsInitParams{ - RootDir: root, - Write: true, - Services: []groups.ServiceAdd{ - {Name: "gateway", Port: 8000, Health: "/healthz", Color: "#4f8cc9", Description: "the api"}, - {Name: "storefront", Port: 5173}, - {Name: "worker", Port: 9000, Cmd: "uv run worker"}, - }, - }) + rig := newInitRig(t) + + curated := []groups.ServiceAdd{ + {Name: "gateway", Port: 8000, Health: "/healthz", Color: "#4f8cc9", Description: "the api"}, + {Name: "storefront", Port: 5173}, + {Name: "worker", Port: 9000, Cmd: "uv run worker"}, + } + res, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true, Services: curated}) if e != nil { t.Fatalf("groups.init with services: %v", e) } @@ -343,44 +339,41 @@ func TestGroupsInitCuratesTheProposal(t *testing.T) { t.Errorf("affected = %v, want the curated names", res.Affected) } - cfg := loadYAML(t, res.YAML) + cfg := rig.load(res.YAML) if len(cfg.Services) != 3 { t.Fatalf("the file has %d services, want the three that were asked for:\n%s", len(cfg.Services), res.YAML) } - if cfg.Services[0].Name != "gateway" || cfg.Services[0].Health != "/healthz" || - cfg.Services[0].Color != "#4f8cc9" || cfg.Services[0].Description != "the api" { - t.Errorf("gateway = %+v, want the caller's own metadata", cfg.Services[0]) + gateway := cfg.Services[0] + if gateway.Name != "gateway" || gateway.Health != "/healthz" || + gateway.Color != "#4f8cc9" || gateway.Description != "the api" { + t.Errorf("gateway = %+v, want the caller's own metadata", gateway) } - if cfg.Services[0].Cmd != "" { - t.Errorf("gateway cmd = %q, want empty: the caller stated none, and nothing listening on 8000 gets to state one", cfg.Services[0].Cmd) + if gateway.Cmd != "" { + t.Errorf("gateway cmd = %q, want empty: the caller stated none, and nothing listening on 8000 gets to state one", gateway.Cmd) } - if cfg.Services[1].Cwd != "" { - t.Errorf("storefront cwd = %q, want empty: the caller stated none", cfg.Services[1].Cwd) + if storefront := cfg.Services[1]; storefront.Cwd != "" { + t.Errorf("storefront cwd = %q, want empty: the caller stated none", storefront.Cwd) } - if cfg.Services[2].Cmd != "uv run worker" { - t.Errorf("worker cmd = %q, want the caller's own", cfg.Services[2].Cmd) + if worker := cfg.Services[2]; worker.Cmd != "uv run worker" { + t.Errorf("worker cmd = %q, want the caller's own", worker.Cmd) } // A service nothing is listening on is not reported as running. - byName := map[string]state.Service{} + running := map[string]bool{} for _, s := range res.Proposal.Services { - byName[s.Name] = s + running[s.Name] = s.Running } - if !byName["gateway"].Running { + if !running["gateway"] { t.Errorf("gateway should be running: port 8000 is listening") } - if byName["worker"].Running { + if running["worker"] { t.Errorf("worker should not be running: nothing is on port 9000") } if res.Proposal.Status != "partial" { t.Errorf("proposal status = %q, want partial", res.Proposal.Status) } - written, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(written) != res.YAML { + if written := rig.onDisk(); written != res.YAML { t.Errorf("the file on disk is not the yaml that was returned:\n%s", written) } } @@ -388,90 +381,82 @@ func TestGroupsInitCuratesTheProposal(t *testing.T) { // TestGroupsInitCuratedListIsValidated: the service list is the caller's, so it // is checked before anything is written rather than found by the next scan. func TestGroupsInitCuratedListIsValidated(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - _, e := callInit(t, c, rpc.GroupsInitParams{ - RootDir: root, + _, e := rig.init(rpc.GroupsInitParams{ + RootDir: rig.root, Write: true, Services: []groups.ServiceAdd{{Name: "api", Port: 8000, DependsOn: []string{"nope"}}}, }) if e == nil || e.Data.Code != "invalid_config" { t.Fatalf("error = %+v, want invalid_config", e) } - if _, err := os.Lstat(target); err == nil { + if _, err := os.Lstat(rig.file); err == nil { t.Error("the invalid file was written anyway") } - _, e = callInit(t, c, rpc.GroupsInitParams{ - RootDir: root, + blank := rpc.GroupsInitParams{ + RootDir: rig.root, Services: []groups.ServiceAdd{{Name: " ", Port: 8000}}, - }) + } + _, e = rig.init(blank) if e == nil || e.Data.Code != "invalid_params" { - t.Fatalf("error = %+v, want invalid_params for an empty name", e) + t.Fatalf("a service with a blank name: error = %+v, want invalid_params", e) } } // TestGroupsInitMergeAppends: `merge` is the way past an existing file that -// keeps what is in it, and the file's comments and order come through the -// append because it goes down the same node-level path as groups.config.set. +// keeps what is in it. The file's comments and its order survive the append +// because it goes down the same node-level path as groups.config.set. // -// What it appends is the caller's list. A plain proposal declares nothing, so +// What it appends is the caller's list; a plain proposal declares nothing, so // there is nothing for it to append on its own — see // TestGroupsInitMergeWithNothingToAdd. func TestGroupsInitMergeAppends(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) const hand = `# hand written, and it stays that way name: demo services: - # the queue was here first - - name: queue + # the cache was here first + - name: redis port: 6379 ` - if err := os.WriteFile(target, []byte(hand), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte(hand), 0o644); err != nil { t.Fatal(err) } adds := []groups.ServiceAdd{{Name: "api", Port: 8000}, {Name: "web", Port: 5173}} // A dry-run merge previews the merged file without touching it. - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Merge: true, Services: adds}) + res, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Merge: true, Services: adds}) if e != nil { t.Fatalf("groups.init --merge dry run: %v", e) } - if !strings.Contains(res.YAML, "# the queue was here first") || !strings.Contains(res.YAML, "port: 8000") { + if !strings.Contains(res.YAML, "# the cache was here first") || !strings.Contains(res.YAML, "port: 8000") { t.Errorf("the previewed merge is wrong:\n%s", res.YAML) } - if b, _ := os.ReadFile(target); string(b) != hand { - t.Errorf("a dry-run merge wrote to the file:\n%s", b) + if body := rig.onDisk(); body != hand { + t.Errorf("a dry-run merge wrote to the file:\n%s", body) } - res, e = callInit(t, c, rpc.GroupsInitParams{RootDir: root, Merge: true, Write: true, Services: adds}) + res, e = rig.init(rpc.GroupsInitParams{RootDir: rig.root, Merge: true, Write: true, Services: adds}) if e != nil { t.Fatalf("groups.init --merge: %v", e) } - written, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(written) != res.YAML { + written := rig.onDisk() + if written != res.YAML { t.Errorf("the file on disk is not the yaml that was returned:\n%s", written) } - cfg := loadYAML(t, string(written)) + cfg := rig.load(written) if cfg.Name != "demo" { t.Errorf("name = %q, want the existing file's own", cfg.Name) } - if len(cfg.Services) != 3 || cfg.Services[0].Name != "queue" { - t.Fatalf("services = %+v, want the queue plus the two appended", cfg.Services) + if len(cfg.Services) != 3 || cfg.Services[0].Name != "redis" { + t.Fatalf("services = %+v, want the cache plus the two appended", cfg.Services) } - if !strings.Contains(string(written), "# hand written, and it stays that way") || - !strings.Contains(string(written), "# the queue was here first") { + if !strings.Contains(written, "# hand written, and it stays that way") || + !strings.Contains(written, "# the cache was here first") { t.Errorf("the comments were lost:\n%s", written) } if len(res.Affected) != 2 { @@ -479,7 +464,7 @@ services: } // Merging the same list again is a conflict, not a duplicate. - _, e = callInit(t, c, rpc.GroupsInitParams{RootDir: root, Merge: true, Write: true, Services: adds}) + _, e = rig.init(rpc.GroupsInitParams{RootDir: rig.root, Merge: true, Write: true, Services: adds}) if e == nil || e.Data.Code != "conflict" { t.Fatalf("error = %+v, want conflict", e) } @@ -489,74 +474,58 @@ services: // with no service list has nothing to append. It has to say so rather than // reporting a successful edit that changed nothing. func TestGroupsInitMergeWithNothingToAdd(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) const hand = "name: demo\nservices:\n - name: queue\n port: 6379\n" - if err := os.WriteFile(target, []byte(hand), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte(hand), 0o644); err != nil { t.Fatal(err) } - _, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Merge: true, Write: true}) + _, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Merge: true, Write: true}) if e == nil { - t.Fatal("merging nothing into a file should have been refused") + t.Fatal("merging nothing into a file was accepted") } - if b, _ := os.ReadFile(target); string(b) != hand { - t.Errorf("a refused merge changed the file:\n%s", b) + if body := rig.onDisk(); body != hand { + t.Errorf("a refused merge changed the file:\n%s", body) } } // TestGroupsInitMergeOnANewFileJustWrites: merge is about not refusing, so with // nothing there it behaves exactly like a plain init. func TestGroupsInitMergeOnANewFileJustWrites(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Merge: true, Write: true}) + res, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Merge: true, Write: true}) if e != nil { t.Fatalf("groups.init --merge on a new file: %v", e) } - b, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(b) != res.YAML || !strings.Contains(string(b), "written by") { - t.Errorf("merge on a new file should write the proposal with its header:\n%s", b) + if body := rig.onDisk(); body != res.YAML || !strings.Contains(body, "written by") { + t.Errorf("merge on a new file should write the proposal with its header:\n%s", body) } } // TestGroupsInitMergeAndForceAreExclusive: one replaces the file, the other // keeps it; asking for both is a caller bug worth naming. func TestGroupsInitMergeAndForceAreExclusive(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, _ := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - _, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true, Force: true, Merge: true}) + _, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true, Force: true, Merge: true}) if e == nil || e.Data.Code != "invalid_params" { - t.Fatalf("error = %+v, want invalid_params", e) + t.Fatalf("force together with merge: error = %+v, want invalid_params", e) } } -// TestGroupsInitRefusalNamesMerge: the hint past an existing file now has two -// ways out, and both have to be in it. +// TestGroupsInitRefusalNamesMerge: the hint past an existing file has two ways +// out now, and the refusal has to name both of them. func TestGroupsInitRefusalNamesMerge(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - if err := os.WriteFile(target, []byte("name: hand-written\n"), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte("name: hand-written\n"), 0o644); err != nil { t.Fatal(err) } - _, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true}) + _, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true}) if e == nil { - t.Fatal("writing over an existing file should have been refused") + t.Fatal("writing over an existing file was not refused") } if !strings.Contains(e.Data.Hint, "force") || !strings.Contains(e.Data.Hint, "merge") { t.Errorf("hint = %q, want both ways out named", e.Data.Hint) @@ -568,10 +537,7 @@ func TestGroupsInitRefusalNamesMerge(t *testing.T) { // name and the services it edited. Re-rendering an edit would silently undo it, // which is worse than not offering the edit at all. func TestGroupsInitWritesAnEditedProposal(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) const edited = `# mine now name: hand-edited @@ -580,16 +546,12 @@ services: cmd: uv run api port: 8000 ` - res, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true, YAML: edited}) + res, e := rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true, YAML: edited}) if e != nil { t.Fatalf("groups.init with yaml: %v", e) } - onDisk, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(onDisk) != edited { - t.Errorf("on disk:\n%s\n--- want:\n%s", onDisk, edited) + if body := rig.onDisk(); body != edited { + t.Errorf("on disk:\n%s\n--- want:\n%s", body, edited) } if res.YAML != edited { t.Errorf("returned yaml is not the edited one:\n%s", res.YAML) @@ -610,25 +572,22 @@ services: // TestGroupsInitRefusesAnEditThatWillNotLoad: an edit is parsed before anything // is written, so a broken one costs a message rather than a broken project. func TestGroupsInitRefusesAnEditThatWillNotLoad(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - _, e := callInit(t, c, rpc.GroupsInitParams{ - RootDir: root, Write: true, + _, e := rig.init(rpc.GroupsInitParams{ + RootDir: rig.root, Write: true, YAML: "name: demo\nservices:\n - name: api\n port: not-a-number\n", }) if e == nil || e.Data.Code != "invalid_config" { t.Fatalf("error = %+v, want invalid_config", e) } - if _, err := os.Lstat(target); err == nil { + if _, err := os.Lstat(rig.file); err == nil { t.Error("a broken edit was written anyway") } // A name a group cannot have is the same kind of refusal. (A *missing* name // is not one: a file without it takes the directory's name.) - _, e = callInit(t, c, rpc.GroupsInitParams{RootDir: root, Write: true, YAML: "name: not a name\n"}) + _, e = rig.init(rpc.GroupsInitParams{RootDir: rig.root, Write: true, YAML: "name: not a name\n"}) if e == nil || e.Data.Code != "invalid_config" { t.Fatalf("error = %+v, want invalid_config for a name with a space", e) } @@ -638,17 +597,13 @@ func TestGroupsInitRefusesAnEditThatWillNotLoad(t *testing.T) { // renders are two different answers to "who writes this". Taking both would // mean silently dropping one of them. func TestGroupsInitRefusesYAMLNextToAList(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, _ := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) for _, p := range []rpc.GroupsInitParams{ - {RootDir: root, YAML: "name: demo\n", Merge: true}, - {RootDir: root, YAML: "name: demo\n", Services: []groups.ServiceAdd{{Name: "api", Port: 8000}}}, + {RootDir: rig.root, YAML: "name: demo\n", Merge: true}, + {RootDir: rig.root, YAML: "name: demo\n", Services: []groups.ServiceAdd{{Name: "api", Port: 8000}}}, } { - _, e := callInit(t, c, p) - if e == nil || e.Data.Code != "invalid_params" { + if _, e := rig.init(p); e == nil || e.Data.Code != "invalid_params" { t.Errorf("params %+v: error = %+v, want invalid_params", p, e) } } @@ -660,7 +615,7 @@ func TestGroupsInitRefusesYAMLNextToAList(t *testing.T) { // Otherwise the call reads state the ordinary way, and an absent cache is never // mistaken for a warm one. func TestInitSnapshotTrustsAWarmCacheOnlyWhileSubscribed(t *testing.T) { - newRuntime := func(t *testing.T, scans *int) (*Runtime, *scanner.Loop) { + countingRuntime := func(t *testing.T, scans *int) (*Runtime, *scanner.Loop) { t.Helper() loop := scanner.New(scanner.Options{ DaemonVersion: "test", @@ -674,7 +629,7 @@ func TestInitSnapshotTrustsAWarmCacheOnlyWhileSubscribed(t *testing.T) { t.Run("no subscriber forces a read", func(t *testing.T) { scans := 0 - rt, loop := newRuntime(t, &scans) + rt, loop := countingRuntime(t, &scans) if _, err := loop.Snapshot(scanner.Include{}); err != nil { t.Fatal(err) } @@ -693,7 +648,7 @@ func TestInitSnapshotTrustsAWarmCacheOnlyWhileSubscribed(t *testing.T) { t.Run("a subscriber keeps it warm", func(t *testing.T) { scans := 0 - rt, loop := newRuntime(t, &scans) + rt, loop := countingRuntime(t, &scans) if _, err := loop.Snapshot(scanner.Include{}); err != nil { t.Fatal(err) } @@ -716,7 +671,7 @@ func TestInitSnapshotTrustsAWarmCacheOnlyWhileSubscribed(t *testing.T) { t.Run("an empty cache is never warm", func(t *testing.T) { scans := 0 - rt, _ := newRuntime(t, &scans) + rt, _ := countingRuntime(t, &scans) reader := newConn(1, rt.srv, nil) reader.subscribed = true rt.srv.registerConn(reader) @@ -736,52 +691,45 @@ func TestInitSnapshotTrustsAWarmCacheOnlyWhileSubscribed(t *testing.T) { // branch seen from the two sides, so a test that only covers one leaves the // comparison free to invert. func TestPublishConfigWarnsOnlyOnReloadFailure(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, root, target := initHarness(t, ctx) - c := h.dial(ctx) + rig := newInitRig(t) - proposal, e := callInit(t, c, rpc.GroupsInitParams{RootDir: root}) - if e != nil { - t.Fatalf("proposal: %v", e) - } - if err := os.WriteFile(target, []byte(proposal.YAML), 0o644); err != nil { + proposal := rig.proposal() + if err := os.WriteFile(rig.file, []byte(proposal.YAML), 0o644); err != nil { t.Fatal(err) } // The runtime publishConfig needs is the scanner and a logger; giving it a // private one keeps this test's diagnostics out of the daemon's log. - var output bytes.Buffer - rt := &Runtime{Scanner: h.loop, Logger: slog.New(slog.NewTextHandler(&output, nil))} + var logged bytes.Buffer + rt := &Runtime{Scanner: rig.h.loop, Logger: slog.New(slog.NewTextHandler(&logged, nil))} req := &Request{Runtime: rt} - publishConfig(req, target, proposal.Proposal.Name, 0) - if strings.Contains(output.String(), "reloading a config after writing it") { - t.Fatalf("successful reload warned: %s", output.String()) + publishConfig(req, rig.file, proposal.Proposal.Name, 0) + if strings.Contains(logged.String(), "reloading a config after writing it") { + t.Fatalf("a successful reload warned: %s", logged.String()) } var listed rpc.GroupsListResult - if e := c.call("groups.list", nil, &listed); e != nil { + if e := rig.cl.call("groups.list", nil, &listed); e != nil { t.Fatalf("groups.list: %v", e) } - g, ok := groupNamed(listed.Groups, proposal.Proposal.Name) - if !ok || g.Source != "file" { - t.Fatalf("written config was not published: %+v", listed.Groups) + if g, ok := groupNamed(listed.Groups, proposal.Proposal.Name); !ok || g.Source != "file" { + t.Fatalf("the written config was not published: %+v", listed.Groups) } // A file the index cannot parse must be reported with the error it failed // on, not swallowed, and the write itself is still reported afterwards. - if err := os.WriteFile(target, []byte("name: [unterminated\n"), 0o644); err != nil { + if err := os.WriteFile(rig.file, []byte("name: [unterminated\n"), 0o644); err != nil { t.Fatal(err) } - output.Reset() - publishConfig(req, target, proposal.Proposal.Name, 0) - text := output.String() + logged.Reset() + publishConfig(req, rig.file, proposal.Proposal.Name, 0) + text := logged.String() if !strings.Contains(text, "level=WARN") || !strings.Contains(text, "reloading a config after writing it") || !strings.Contains(text, "error=") || strings.Contains(text, "error=") { t.Fatalf("failed reload warning = %s", text) } if !strings.Contains(text, "wrote "+groups.ConfigName) { - t.Fatalf("reload error suppressed write reporting: %s", text) + t.Fatalf("the reload error suppressed the write report: %s", text) } } diff --git a/engine/internal/daemon/groups_read_test.go b/engine/internal/daemon/groups_read_test.go index 3850cc85..0f3cadf8 100644 --- a/engine/internal/daemon/groups_read_test.go +++ b/engine/internal/daemon/groups_read_test.go @@ -1,42 +1,42 @@ package daemon import ( - "context" "testing" "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// TestGroupsListServesTheSnapshotsGroups: `groups.list` is the groups the scan -// already resolved, so the list an agent reads and the groups a subscriber sees -// cannot disagree. +// TestGroupsListServesTheSnapshotsGroups holds `groups.list` to the groups the +// scan already resolved: the list an agent reads and the groups a subscriber +// sees come from one snapshot, so the two cannot drift apart. func TestGroupsListServesTheSnapshotsGroups(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - c := newPortsHarness(t, ctx) + cl := newPortsHarness(t, t.Context()) - var res rpc.GroupsListResult - if e := c.call("groups.list", rpc.Empty{}, &res); e != nil { + var listed rpc.GroupsListResult + if e := cl.call("groups.list", rpc.Empty{}, &listed); e != nil { t.Fatalf("groups.list: %v", e) } - if res.Groups == nil { - t.Fatal("groups must always be an array, never null") + // An empty answer is still an array: a client that ranges over the field + // must never have to special-case "no groups". + if listed.Groups == nil { + t.Fatal("groups.list answered null, want an array") } - var snapshot struct { + var snap struct { Groups []state.Group `json:"groups"` } - if e := c.call("state.snapshot", rpc.StateSnapshotParams{}, &snapshot); e != nil { + if e := cl.call("state.snapshot", rpc.StateSnapshotParams{}, &snap); e != nil { t.Fatalf("state.snapshot: %v", e) } - if len(res.Groups) != len(snapshot.Groups) { - t.Fatalf("groups.list returned %d groups, the snapshot has %d", - len(res.Groups), len(snapshot.Groups)) + if len(snap.Groups) != len(listed.Groups) { + t.Fatalf("the snapshot holds %d groups, groups.list answered %d", + len(snap.Groups), len(listed.Groups)) } - for i := range res.Groups { - if res.Groups[i].Name != snapshot.Groups[i].Name { - t.Errorf("group %d = %q, snapshot says %q", i, res.Groups[i].Name, snapshot.Groups[i].Name) + for i, g := range snap.Groups { + if listed.Groups[i].Name != g.Name { + t.Errorf("group %d: groups.list says %q, the snapshot says %q", + i, listed.Groups[i].Name, g.Name) } } } diff --git a/engine/internal/daemon/groups_rename_test.go b/engine/internal/daemon/groups_rename_test.go index e7afbb3e..d4fe8399 100644 --- a/engine/internal/daemon/groups_rename_test.go +++ b/engine/internal/daemon/groups_rename_test.go @@ -1,11 +1,10 @@ package daemon import ( - "context" "encoding/json" "os" "path/filepath" - "sort" + "slices" "strings" "testing" @@ -15,157 +14,177 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// renameTree is a project checked out twice — the main checkout and one linked -// worktree — next to an unrelated repository called "taken". -type renameTree struct{ main, wt, other string } +// renameTree is a project checked out twice — its main checkout and one linked +// worktree — sitting next to an unrelated repository whose name is already +// taken. +type renameTree struct { + main string // the primary checkout: the project itself + wt string // the linked worktree: the same project on one branch + taken string // somebody else's repository, named "taken" +} -func newRenameTree(t *testing.T, withConfig bool) renameTree { +func newRenameTree(t *testing.T, withManifest bool) renameTree { t.Helper() - base := resolvedDir(t, t.TempDir()) + root := resolvedDir(t, t.TempDir()) tree := renameTree{ - main: filepath.Join(base, "shop"), - wt: filepath.Join(base, "wt", "feat"), - other: filepath.Join(base, "taken"), + main: filepath.Join(root, "shop"), + wt: filepath.Join(root, "wt", "feat"), + taken: filepath.Join(root, "taken"), } admin := filepath.Join(tree.main, ".git", "worktrees", "feat") - for _, dir := range []string{admin, tree.wt, filepath.Join(tree.other, ".git")} { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - } files := map[string]string{ filepath.Join(tree.main, ".git", "HEAD"): "ref: refs/heads/main\n", filepath.Join(admin, "HEAD"): "ref: refs/heads/feat\n", filepath.Join(tree.wt, ".git"): "gitdir: " + admin + "\n", } - if withConfig { + if withManifest { files[filepath.Join(tree.main, groups.ConfigName)] = "# the shop\nname: shop\n" files[filepath.Join(tree.wt, groups.ConfigName)] = "name: shop\n" } + // Writing each file creates its own directory: the worktree's gitdir lives + // inside the main checkout's .git, so the map lays out all three trees. for path, body := range files { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } + if err := os.MkdirAll(filepath.Join(tree.taken, ".git"), 0o755); err != nil { + t.Fatal(err) + } return tree } +// rows is one listening process in each of the three trees, so every group the +// resolver derives has a member. func (tree renameTree) rows() []ports.ListeningPort { + listening := func(port, pid int, cwd string) ports.ListeningPort { + return ports.ListeningPort{Port: port, BindAddress: "127.0.0.1", PID: pid, Process: "node", Cwd: cwd} + } return []ports.ListeningPort{ - {Port: 3000, BindAddress: "127.0.0.1", PID: 501, Process: "node", Cwd: tree.main}, - {Port: 3001, BindAddress: "127.0.0.1", PID: 502, Process: "node", Cwd: tree.wt}, - {Port: 4000, BindAddress: "127.0.0.1", PID: 503, Process: "node", Cwd: tree.other}, + listening(3000, 501, tree.main), + listening(3001, 502, tree.wt), + listening(4000, 503, tree.taken), } } -// listGroups reads groups.list keyed by name. -func (c *testClient) listGroups(t *testing.T) map[string]state.Group { +// groupsByName reads groups.list and keys the answer by group name, refusing a +// list that names one group twice. +func (c *testClient) groupsByName(t *testing.T) map[string]state.Group { t.Helper() var res rpc.GroupsListResult if e := c.call("groups.list", rpc.Empty{}, &res); e != nil { t.Fatalf("groups.list: %v", e) } - out := map[string]state.Group{} + byName := make(map[string]state.Group, len(res.Groups)) for _, g := range res.Groups { - if _, dup := out[g.Name]; dup { - t.Fatalf("groups.list has two groups named %q", g.Name) + if _, clash := byName[g.Name]; clash { + t.Fatalf("groups.list names %q twice", g.Name) } - out[g.Name] = g + byName[g.Name] = g } - return out + return byName } -// renameGroupCollectingDelta sends groups.rename and returns its result with -// the last groups-moving state.delta that arrived before the reply — the -// delta republish promises is queued ahead of the response. -func (c *testClient) renameGroupCollectingDelta(t *testing.T, p rpc.GroupsRenameParams) (rpc.GroupsRenameResult, state.Delta) { +// renameFindingDelta sends groups.rename and returns its result with the last +// groups-moving state.delta that arrived before the reply — the delta +// republish promises is queued ahead of the response. +func (c *testClient) renameFindingDelta(t *testing.T, p rpc.GroupsRenameParams) (rpc.GroupsRenameResult, state.Delta) { t.Helper() id := c.send("groups.rename", p) var ( - res rpc.GroupsRenameResult - delta state.Delta - seen bool + latest state.Delta + carried bool ) - for i := 0; i < 50; i++ { + for range 50 { m := c.read() - switch { - case m.IsNotification() && m.Method == rpc.MethodStateDelta: + if m.IsNotification() && m.Method == rpc.MethodStateDelta { var d state.Delta if err := json.Unmarshal(m.Params, &d); err != nil { t.Fatalf("decoding state.delta: %v", err) } - if len(d.Groups.Added)+len(d.Groups.Removed) == 0 { - continue - } - delta, seen = d, true - case m.IsResponse() && string(m.ID) == id: - if m.Error != nil { - t.Fatalf("groups.rename: %v", m.Error) - } - if err := json.Unmarshal(m.Result, &res); err != nil { - t.Fatalf("decoding the rename result: %v", err) + if len(d.Groups.Added)+len(d.Groups.Removed) > 0 { + latest, carried = d, true } - if !seen { - t.Fatal("the reply arrived before any delta carrying the renamed groups") - } - return res, delta + continue + } + if !m.IsResponse() || string(m.ID) != id { + continue + } + if m.Error != nil { + t.Fatalf("groups.rename: %v", m.Error) + } + if !carried { + t.Fatal("the reply arrived before any delta carrying the renamed groups") + } + var res rpc.GroupsRenameResult + if err := json.Unmarshal(m.Result, &res); err != nil { + t.Fatalf("decoding the rename result: %v", err) } + return res, latest } t.Fatal("no reply to groups.rename") - return res, delta + return rpc.GroupsRenameResult{}, state.Delta{} } +// sortedNames joins the group names in one comma-separated, sorted string, so +// a whole delta can be compared in one assertion. func sortedNames(gg []state.Group) string { - out := make([]string, 0, len(gg)) - for _, g := range gg { - out = append(out, g.Name) + names := make([]string, len(gg)) + for i, g := range gg { + names[i] = g.Name } - sort.Strings(out) - return strings.Join(out, ",") + slices.Sort(names) + return strings.Join(names, ",") } func TestGroupsRenameStoresAnAliasForAProjectWithoutAFile(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() tree := newRenameTree(t, false) - h, st := storeHarness(t, ctx, tree.rows()...) - c := h.dial(ctx) - c.subscribeAndSettle(rpc.StateSubscribeParams{}) + h, st := storeHarness(t, tree.rows()...) + cl := h.dial(t.Context()) + cl.subscribeAndSettle(rpc.StateSubscribeParams{}) - before := c.listGroups(t) - wt, ok := before["shop@feat"] - if !ok || wt.Repo != "shop" || wt.Worktree != "feat" || wt.Branch != "feat" { + before := cl.groupsByName(t) + if wt, ok := before["shop@feat"]; !ok || wt.Repo != "shop" || wt.Worktree != "feat" || wt.Branch != "feat" { t.Fatalf("worktree group before = %+v (%v)", wt, ok) } if main := before["shop"]; main.Repo != "shop" || main.Worktree != "" || main.Branch != "main" { t.Fatalf("main group before = %+v", main) } - // Named by a checkout, routed through an explicit localhost. - res, delta := c.renameGroupCollectingDelta(t, rpc.GroupsRenameParams{ - Name: "shop@feat", To: "market", - }) + // The worktree group is named through a checkout, so the alias it stores + // has to land on the main one. + res, delta := cl.renameFindingDelta(t, rpc.GroupsRenameParams{Name: "shop@feat", To: "market"}) if !res.OK || res.Name != "market" || strings.Join(res.Affected, ",") != "market,market@feat" { t.Fatalf("result = %+v", res) } - removed := append([]string{}, delta.Groups.Removed...) - sort.Strings(removed) - if strings.Join(removed, ",") != "shop,shop@feat" || sortedNames(delta.Groups.Added) != "market,market@feat" { - t.Errorf("delta groups: removed %v, added %s", removed, sortedNames(delta.Groups.Added)) + removed := slices.Clone(delta.Groups.Removed) + slices.Sort(removed) + if got := strings.Join(removed, ","); got != "shop,shop@feat" { + t.Errorf("delta removed %q, want the old names", got) + } + if added := sortedNames(delta.Groups.Added); added != "market,market@feat" { + t.Errorf("delta added %q, want the new names", added) } aliases, err := st.GroupAliases() - if err != nil || aliases[tree.main] != "market" { - t.Fatalf("aliases = %v, %v; want the main checkout named market", aliases, err) + if err != nil { + t.Fatalf("reading the alias table: %v", err) + } + if aliases[tree.main] != "market" { + t.Errorf("aliases = %v, want the main checkout named market", aliases) } - after := c.listGroups(t) - if _, ok := after["shop"]; ok { + + after := cl.groupsByName(t) + if _, still := after["shop"]; still { t.Error("the old project name is still listed") } - if g := after["market@feat"]; g.Repo != "market" || g.Worktree != "feat" || len(g.Members) != 1 || g.Members[0] != 3001 { + if g := after["market@feat"]; g.Repo != "market" || g.Worktree != "feat" || !slices.Equal(g.Members, []int{3001}) { t.Errorf("renamed worktree = %+v", g) } - if g := after["market"]; len(g.Members) != 1 || g.Members[0] != 3000 { + if g := after["market"]; !slices.Equal(g.Members, []int{3000}) { t.Errorf("renamed main = %+v", g) } if _, ok := after["taken"]; !ok { @@ -174,50 +193,49 @@ func TestGroupsRenameStoresAnAliasForAProjectWithoutAFile(t *testing.T) { } func TestGroupsRenameWritesTheMainCheckoutsFile(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() tree := newRenameTree(t, true) - h, st := storeHarness(t, ctx, tree.rows()...) - c := h.dial(ctx) + h, st := storeHarness(t, tree.rows()...) + cl := h.dial(t.Context()) - if g := c.listGroups(t)["shop@feat"]; g.Source != state.SourceFile || + if g := cl.groupsByName(t)["shop@feat"]; g.Source != state.SourceFile || g.ConfigPath == nil || *g.ConfigPath != filepath.Join(tree.wt, groups.ConfigName) { t.Fatalf("worktree group before = %+v", g) } var res rpc.GroupsRenameResult - if e := c.call("groups.rename", rpc.GroupsRenameParams{Name: "shop", To: "market"}, &res); e != nil { - t.Fatalf("groups.rename: %v", e) + if e := cl.call("groups.rename", rpc.GroupsRenameParams{Name: "shop", To: "market"}, &res); e != nil { + t.Fatalf("renaming shop: %v", e) } - if strings.Join(res.Affected, ",") != "market,market@feat" { - t.Fatalf("affected = %v", res.Affected) + if got := strings.Join(res.Affected, ","); got != "market,market@feat" { + t.Fatalf("affected = %q, want the project and its worktree", got) } mainFile, _ := os.ReadFile(filepath.Join(tree.main, groups.ConfigName)) if !strings.Contains(string(mainFile), "# the shop") || !strings.Contains(string(mainFile), "name: market") { - t.Errorf("main checkout's file =\n%s", mainFile) + t.Errorf("the main checkout's manifest =\n%s", mainFile) } // The worktree's copy is not the project's name; it is left alone. if wtFile, _ := os.ReadFile(filepath.Join(tree.wt, groups.ConfigName)); string(wtFile) != "name: shop\n" { - t.Errorf("worktree copy was rewritten:\n%s", wtFile) + t.Errorf("the worktree's copy was rewritten:\n%s", wtFile) } if aliases, _ := st.GroupAliases(); len(aliases) != 0 { t.Errorf("a file project stored an alias: %v", aliases) } - after := c.listGroups(t) + + after := cl.groupsByName(t) for _, name := range []string{"market", "market@feat"} { if _, ok := after[name]; !ok { t.Errorf("%s missing after the rename: %v", name, after) } } - if _, ok := after["shop"]; ok { + if _, still := after["shop"]; still { t.Error("the old project name is still listed") } // And by name, the renamed project's config is found again. name := "market@feat" var got rpc.GroupsConfigGetResult - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, &got); e != nil { + if e := cl.call("groups.config.get", rpc.GroupsConfigGetParams{Name: &name}, &got); e != nil { t.Fatalf("groups.config.get market@feat: %v", e) } if got.Path != filepath.Join(tree.wt, groups.ConfigName) { @@ -226,33 +244,31 @@ func TestGroupsRenameWritesTheMainCheckoutsFile(t *testing.T) { } func TestGroupsRenameRefuses(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() tree := newRenameTree(t, false) - h, _ := storeHarness(t, ctx, tree.rows()...) - c := h.dial(ctx) + h, _ := storeHarness(t, tree.rows()...) + cl := h.dial(t.Context()) - tests := []struct { - name string - p rpc.GroupsRenameParams - want string + refusals := []struct { + what string + call rpc.GroupsRenameParams + code string }{ - {"no name", rpc.GroupsRenameParams{To: "market"}, "invalid_params"}, - {"no new name", rpc.GroupsRenameParams{Name: "shop"}, "invalid_params"}, - {"an @ in the new name", rpc.GroupsRenameParams{Name: "shop", To: "a@b"}, "invalid_params"}, - {"a slash in the new name", rpc.GroupsRenameParams{Name: "shop", To: "a/b"}, "invalid_params"}, - {"an unknown group", rpc.GroupsRenameParams{Name: "nope", To: "market"}, "not_found"}, - {"another project's name", rpc.GroupsRenameParams{Name: "shop", To: "taken"}, "conflict"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - e := c.call("groups.rename", tt.p, nil) - if e == nil || e.Data.Code != tt.want { - t.Fatalf("error = %+v, want %s", e, tt.want) + {"without a name", rpc.GroupsRenameParams{To: "market"}, "invalid_params"}, + {"without a new name", rpc.GroupsRenameParams{Name: "shop"}, "invalid_params"}, + {"an @ inside the new name", rpc.GroupsRenameParams{Name: "shop", To: "a@b"}, "invalid_params"}, + {"a slash inside the new name", rpc.GroupsRenameParams{Name: "shop", To: "a/b"}, "invalid_params"}, + {"a group that is not there", rpc.GroupsRenameParams{Name: "nope", To: "market"}, "not_found"}, + {"a name another project already has", rpc.GroupsRenameParams{Name: "shop", To: "taken"}, "conflict"}, + } + for _, refusal := range refusals { + t.Run(refusal.what, func(t *testing.T) { + e := cl.call("groups.rename", refusal.call, nil) + if e == nil || e.Data.Code != refusal.code { + t.Fatalf("error = %+v, want %s", e, refusal.code) } }) } - if _, ok := c.listGroups(t)["shop"]; !ok { + if _, ok := cl.groupsByName(t)["shop"]; !ok { t.Error("a refused rename renamed the project anyway") } } diff --git a/engine/internal/daemon/handlers_store_test.go b/engine/internal/daemon/handlers_store_test.go index 230471dc..ed2d1ed8 100644 --- a/engine/internal/daemon/handlers_store_test.go +++ b/engine/internal/daemon/handlers_store_test.go @@ -1,7 +1,6 @@ package daemon import ( - "context" "os" "path/filepath" "strings" @@ -13,81 +12,83 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/store" ) -// storeHarness is a harness with a temp database already open and one port -// listening, which is what every write path needs. -func storeHarness(t *testing.T, ctx context.Context, rows ...ports.ListeningPort) (*testHarness, *store.Store) { +// storeHarness is a harness whose temp database is already open and which sees +// one port listening — what every write path needs. +func storeHarness(t *testing.T, rows ...ports.ListeningPort) (*testHarness, *store.Store) { t.Helper() - // The temp directory is claimed first so its removal is the last cleanup - // to run: the harness holds the database inside it and has to close it - // before anything deletes the file. + // The temp directory is claimed first so its removal is the last cleanup to + // run: the harness keeps the database inside it and must close it before + // anything deletes the file. db := filepath.Join(t.TempDir(), "option-berth.db") - h := newHarness(t, ctx) + h := newHarness(t, t.Context()) st := h.withStore(db) if len(rows) == 0 { - rows = []ports.ListeningPort{{ - Port: 8123, PID: 42, Process: "python3", Command: "python3 -m http.server", - }} + // One plain user process is enough: the write paths only need a row to + // attribute history to. + rows = []ports.ListeningPort{ + {Port: 8123, PID: 42, Process: "python3", Command: "python3 -m http.server"}, + } } h.setRows(rows...) return h, st } func TestConfigRoundTripThroughTheDaemon(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - h := newHarness(t, ctx) - c := h.dial(ctx) + h := newHarness(t, t.Context()) + cl := h.dial(t.Context()) - var path rpc.ConfigPathResult - if e := c.call("config.path", rpc.Empty{}, &path); e != nil { + var configPath rpc.ConfigPathResult + if e := cl.call("config.path", rpc.Empty{}, &configPath); e != nil { t.Fatalf("config.path: %v", e) } - // 断言两件事:daemon 解析出来的路径**和这边自己算的一致**(跨进程布局一致), - // 以及它落在隔离的 home 里。刻意不写死 `.config/option-berth` 那种形状 —— - // 布局改过一次,把形状当不变量的断言会在每次搬家时假报警。 - if want := filepath.Join(paths.Dir(), paths.ConfigFile); path.Path != want { - t.Errorf("path = %q, want %q", path.Path, want) + // Two things are asserted here: the daemon resolves the path this side + // computes (one cross-process layout), and it lands inside the isolated + // home. The `.config/option-berth` shape is deliberately not hard-coded — + // the layout moved once, and treating the shape as an invariant would cry + // wolf every time it moves again. + if want := filepath.Join(paths.Dir(), paths.ConfigFile); configPath.Path != want { + t.Errorf("path = %q, want %q", configPath.Path, want) } - if !strings.HasPrefix(path.Path, home+string(filepath.Separator)) { - t.Errorf("path = %q, want it under the temp home %s", path.Path, home) + if !strings.HasPrefix(configPath.Path, home+string(filepath.Separator)) { + t.Errorf("path = %q, want it under the temp home %s", configPath.Path, home) } - var set rpc.ConfigSetResult - if e := c.call("config.set", rpc.ConfigSetParams{ + var setResult rpc.ConfigSetResult + if e := cl.call("config.set", rpc.ConfigSetParams{ Patch: map[string]any{"daemon": map[string]any{"log_level": "debug"}}, - }, &set); e != nil { + }, &setResult); e != nil { t.Fatalf("config.set: %v", e) } - if !set.OK { + if !setResult.OK { t.Error("config.set did not report ok") } - if _, err := os.Stat(path.Path); err != nil { + if _, err := os.Stat(configPath.Path); err != nil { t.Fatalf("config.set wrote no file: %v", err) } - var got rpc.ConfigGetResult - if e := c.call("config.get", rpc.Empty{}, &got); e != nil { + var gotConfig rpc.ConfigGetResult + if e := cl.call("config.get", rpc.Empty{}, &gotConfig); e != nil { t.Fatalf("config.get: %v", e) } - if got.Config["daemon"].(map[string]any)["log_level"] != "debug" { - t.Errorf("config = %v, want daemon.log_level debug", got.Config) + if level := gotConfig.Config["daemon"].(map[string]any)["log_level"]; level != "debug" { + t.Errorf("config = %v, want daemon.log_level debug", gotConfig.Config) } // A second write keeps the previous file next to it. - if e := c.call("config.set", rpc.ConfigSetParams{ + if e := cl.call("config.set", rpc.ConfigSetParams{ Patch: map[string]any{"daemon": map[string]any{"log_level": "info"}}, }, nil); e != nil { - t.Fatalf("config.set: %v", e) + t.Fatalf("the second config.set: %v", e) } - if _, err := os.Stat(path.Path + ".bak"); err != nil { + if _, err := os.Stat(configPath.Path + ".bak"); err != nil { t.Errorf("no backup beside the config: %v", err) } - if e := c.call("config.set", rpc.ConfigSetParams{ + if e := cl.call("config.set", rpc.ConfigSetParams{ Patch: map[string]any{"daemon": map[string]any{"log_level": "sideways"}}, }, nil); e == nil || e.Data.Code != "invalid_params" { t.Errorf("error = %+v, want invalid_params for a rejected value", e) diff --git a/engine/internal/daemon/store_test.go b/engine/internal/daemon/store_test.go index 52640a17..3a08b178 100644 --- a/engine/internal/daemon/store_test.go +++ b/engine/internal/daemon/store_test.go @@ -1,10 +1,10 @@ package daemon import ( - "context" "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" @@ -13,44 +13,45 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/store" ) -// withStore opens the daemon's database at path the way Serve does. Closing it is the harness's -// job (see testHarness.shutdown): the store must outlive the scan loop that -// writes history into it, and a cleanup registered here would run first. +// withStore opens the daemon's database at path the way Serve does and hands +// back the store. Closing it stays the harness's job (testHarness.shutdown): +// the store has to outlive the scan loop that writes history into it, so a +// cleanup registered here would run first and pull the database out from under +// that loop. func (h *testHarness) withStore(path string) *store.Store { h.t.Helper() h.srv.opts.DBPath = path h.srv.openStore() - if h.srv.runtime.Store == nil { - h.t.Fatalf("no store opened at %s", path) + st := h.srv.runtime.Store + if st == nil { + h.t.Fatalf("the daemon opened no store at %s", path) } - return h.srv.runtime.Store + return st } func TestStatusReportsTheDatabasePath(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // The database's directory is claimed before the harness exists, so the - // harness stops before the directory is removed. - path := filepath.Join(t.TempDir(), "option-berth.db") - h := newHarness(t, ctx) - c := h.dial(ctx) + // The database lives in a directory claimed before the harness exists, so + // that directory's removal is the last cleanup to run. + dbPath := filepath.Join(t.TempDir(), "option-berth.db") + h := newHarness(t, t.Context()) + cl := h.dial(t.Context()) var before rpc.DaemonStatusResult - if e := c.call("daemon.status", rpc.Empty{}, &before); e != nil { - t.Fatalf("daemon.status: %v", e) + if e := cl.call("daemon.status", rpc.Empty{}, &before); e != nil { + t.Fatalf("daemon.status before the store is open: %v", e) } if before.DBPath != "" { - t.Errorf("db_path = %q before the store is open, want empty", before.DBPath) + t.Errorf("db_path = %q with no store open, want empty", before.DBPath) } - h.withStore(path) + h.withStore(dbPath) var after rpc.DaemonStatusResult - if e := c.call("daemon.status", rpc.Empty{}, &after); e != nil { - t.Fatalf("daemon.status: %v", e) + if e := cl.call("daemon.status", rpc.Empty{}, &after); e != nil { + t.Fatalf("daemon.status after the store is open: %v", e) } - if after.DBPath != path { - t.Errorf("db_path = %q, want %q", after.DBPath, path) + if after.DBPath != dbPath { + t.Errorf("db_path = %q, want %q", after.DBPath, dbPath) } if h.srv.runtime.DB == nil { t.Error("Runtime.DB is nil; the runtime hands extensions the raw handle") @@ -58,63 +59,61 @@ func TestStatusReportsTheDatabasePath(t *testing.T) { } func TestCorruptDatabaseIsRecreatedAndAnnounced(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - dir := t.TempDir() - path := filepath.Join(dir, "option-berth.db") - if err := os.WriteFile(path, []byte("this is not a SQLite database"), 0o600); err != nil { + dbPath := filepath.Join(dir, "option-berth.db") + if err := os.WriteFile(dbPath, []byte("this is not a SQLite database"), 0o600); err != nil { t.Fatal(err) } - h := newHarness(t, ctx) - c := h.dial(ctx) - if e := c.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, nil); e != nil { + h := newHarness(t, t.Context()) + cl := h.dial(t.Context()) + if e := cl.call("state.subscribe", rpc.StateSubscribeParams{Events: true}, nil); e != nil { t.Fatalf("state.subscribe: %v", e) } - h.withStore(path) + h.withStore(dbPath) // A change gives the queued event something to ride out on. h.setRows(ports.ListeningPort{Port: 8123, PID: 42, Process: "python3"}) h.loop.Wake() - ev := c.waitForEvent(t, "db_reset") + ev := cl.waitForEvent(t, "db_reset") if ev.Data["moved_to"] == nil { - t.Errorf("db_reset carries no moved_to: %+v", ev.Data) + t.Errorf("db_reset reported no moved_to: %+v", ev.Data) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } - moved := false - for _, e := range entries { - if len(e.Name()) > len("option-berth.db.corrupt-") && e.Name()[:len("option-berth.db.corrupt-")] == "option-berth.db.corrupt-" { - moved = true + movedAside := "" + for _, entry := range entries { + if rest, ok := strings.CutPrefix(entry.Name(), "option-berth.db.corrupt-"); ok && rest != "" { + movedAside = entry.Name() } } - if !moved { + if movedAside == "" { t.Errorf("the unusable database was not moved aside: %v", entries) } } -// waitForEvent reads notifications until a state.event of this kind arrives. +// waitForEvent reads notifications until a state.event of the named kind +// arrives. func (c *testClient) waitForEvent(t *testing.T, kind string) state.Event { t.Helper() - for i := 0; i < 50; i++ { - m := c.read() - if !m.IsNotification() || m.Method != rpc.MethodStateEvent { + for range 50 { + msg := c.read() + if !msg.IsNotification() || msg.Method != rpc.MethodStateEvent { continue } - var ev state.Event - if err := json.Unmarshal(m.Params, &ev); err != nil { - t.Fatalf("decoding state.event: %v", err) + var event state.Event + if err := json.Unmarshal(msg.Params, &event); err != nil { + t.Fatalf("decoding the state.event: %v", err) } - if ev.Kind == kind { - return ev + if event.Kind == kind { + return event } } - t.Fatalf("no state.event{kind: %q} arrived", kind) + t.Fatalf("no state.event of kind %q arrived", kind) return state.Event{} } diff --git a/engine/internal/daemon/worktree_ports_test.go b/engine/internal/daemon/worktree_ports_test.go index 9c2f2bfe..14773170 100644 --- a/engine/internal/daemon/worktree_ports_test.go +++ b/engine/internal/daemon/worktree_ports_test.go @@ -1,7 +1,6 @@ package daemon import ( - "context" "encoding/json" "os" "path/filepath" @@ -9,116 +8,127 @@ import ( "testing" "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" + "github.com/sheathedsharp/option-berth/engine/internal/groups" ) +// worktreePortsEditor edits one project's manifest and reads the +// worktree_ports value back out of what the daemon serves. +type worktreePortsEditor struct { + t *testing.T + cl *testClient + path string +} + +// read returns worktree_ports as the daemon serves it, as raw JSON so "absent" +// and "null" stay distinguishable. +func (e *worktreePortsEditor) read() string { + e.t.Helper() + var served struct { + Config map[string]json.RawMessage `json:"config"` + } + if callErr := e.cl.call("groups.config.get", rpc.GroupsConfigGetParams{Path: &e.path}, &served); callErr != nil { + e.t.Fatalf("groups.config.get: %v", callErr) + } + value, ok := served.Config["worktree_ports"] + if !ok { + e.t.Fatalf("the served config has no worktree_ports key: %v", served.Config) + } + return string(value) +} + +// write applies one edit to the project's manifest. +func (e *worktreePortsEditor) write(edit map[string]any) *rpc.GroupsConfigSetResult { + e.t.Helper() + edit["path"] = e.path + var res rpc.GroupsConfigSetResult + if callErr := e.cl.call("groups.config.set", edit, &res); callErr != nil { + e.t.Fatalf("groups.config.set %v: %v", edit, callErr) + } + return &res +} + // TestGroupsConfigWorktreePortsRoundTrip: set writes the key without -// disturbing comments, get reads it back, an edit that does not mention the -// key leaves it alone, and null removes it. +// disturbing comments, get reads it back, an edit that does not mention the key +// leaves it alone, and null removes it. func TestGroupsConfigWorktreePortsRoundTrip(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, _ := storeHarness(t, ctx) - c := h.dial(ctx) - - parent := resolvedDir(t, t.TempDir()) - dir := filepath.Join(parent, "demo") + h, _ := storeHarness(t) + dir := filepath.Join(resolvedDir(t, t.TempDir()), "demo") if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { t.Fatal(err) } - writeConfig(t, dir, configFixture) - path := filepath.Join(dir, "oberth.yaml") - - get := func() json.RawMessage { - t.Helper() - var res struct { - Config map[string]json.RawMessage `json:"config"` - } - if e := c.call("groups.config.get", rpc.GroupsConfigGetParams{Path: &path}, &res); e != nil { - t.Fatalf("groups.config.get: %v", e) - } - v, ok := res.Config["worktree_ports"] - if !ok { - t.Fatalf("config has no worktree_ports key: %v", res.Config) - } - return v - } - set := func(params map[string]any) *rpc.GroupsConfigSetResult { - t.Helper() - params["path"] = path - var res rpc.GroupsConfigSetResult - if e := c.call("groups.config.set", params, &res); e != nil { - t.Fatalf("groups.config.set %v: %v", params, e) - } - return &res + writeConfig(t, dir, demoManifest) + editor := &worktreePortsEditor{ + t: t, + cl: h.dial(t.Context()), + path: filepath.Join(dir, groups.ConfigName), } - if got := string(get()); got != "null" { + if got := editor.read(); got != "null" { t.Fatalf("worktree_ports before any edit = %s, want null", got) } - res := set(map[string]any{"worktree_ports": 4}) + res := editor.write(map[string]any{"worktree_ports": 4}) if res.Config.WorktreePorts == nil || *res.Config.WorktreePorts != 4 { t.Fatalf("set result worktree_ports = %v, want 4", res.Config.WorktreePorts) } if len(res.Affected) != 0 { t.Errorf("affected = %v, want no service names", res.Affected) } - data, err := os.ReadFile(path) + body, err := os.ReadFile(editor.path) if err != nil { t.Fatal(err) } - for _, want := range []string{"# demo project", "# the database goes first", "worktree_ports: 4"} { - if !strings.Contains(string(data), want) { - t.Errorf("file lost %q after the write:\n%s", want, data) + for _, want := range []string{ + "# the demo project", + "# declared first: everything else depends on it", + "worktree_ports: 4", + } { + if !strings.Contains(string(body), want) { + t.Errorf("the file lost %q after the write:\n%s", want, body) } } - if got := string(get()); got != "4" { + if got := editor.read(); got != "4" { t.Errorf("get after set = %s, want 4", got) } - set(map[string]any{"services": []any{map[string]any{"name": "api", "patch": map[string]any{"icon": "server"}}}}) - if got := string(get()); got != "4" { - t.Errorf("get after an unrelated edit = %s, want 4 kept", got) + // An edit that does not mention the key leaves it alone. + editor.write(map[string]any{"services": []any{map[string]any{"name": "api", "patch": map[string]any{"icon": "server"}}}}) + if got := editor.read(); got != "4" { + t.Errorf("get after an unrelated edit = %s, want the 4 kept", got) } - res = set(map[string]any{"worktree_ports": nil}) + res = editor.write(map[string]any{"worktree_ports": nil}) if res.Config.WorktreePorts != nil { t.Errorf("set result after null = %d, want nil", *res.Config.WorktreePorts) } - if data, _ := os.ReadFile(path); strings.Contains(string(data), "worktree_ports") { - t.Errorf("null did not remove the key:\n%s", data) + if body, _ := os.ReadFile(editor.path); strings.Contains(string(body), "worktree_ports") { + t.Errorf("null did not remove the key:\n%s", body) } - if got := string(get()); got != "null" { + if got := editor.read(); got != "null" { t.Errorf("get after null = %s, want null", got) } } -// TestGroupsConfigWorktreePortsErrors: an out-of-range value is -// invalid_config and leaves the file byte-identical; an empty edit is invalid. +// TestGroupsConfigWorktreePortsErrors: an out-of-range value is invalid_config +// and leaves the file byte-identical; an empty edit is invalid. func TestGroupsConfigWorktreePortsErrors(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h, _, path := configHarness(t, ctx) - c := h.dial(ctx) - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } + rig := newConfigRig(t) + before := rig.manifest() for _, n := range []int{0, 101} { - e := c.call("groups.config.set", map[string]any{"path": path, "worktree_ports": n}, nil) + e := rig.cl.call("groups.config.set", map[string]any{"path": rig.file, "worktree_ports": n}, nil) if e == nil { - t.Fatalf("worktree_ports %d should fail", n) + t.Fatalf("worktree_ports %d was accepted", n) } if e.Code != rpc.CodeInvalidConfig || !strings.Contains(e.Data.Detail, "worktree_ports") { t.Errorf("worktree_ports %d: error = %+v, want invalid_config naming the key", n, e) } } - if after, _ := os.ReadFile(path); string(after) != string(before) { + if after := rig.manifest(); after != before { t.Errorf("a failed write changed the file:\n%s", after) } - if e := c.call("groups.config.set", map[string]any{"path": path}, nil); e == nil || e.Data.Code != "invalid_params" { + if e := rig.cl.call("groups.config.set", map[string]any{"path": rig.file}, nil); e == nil || e.Data.Code != "invalid_params" { t.Errorf("an empty edit: error = %+v, want invalid_params", e) } } From 67b890fe684360ac5f910610ad7427b1aab0b623 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 17:23:00 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test(engine):=20daemon=20=E7=9A=84=20kill?= =?UTF-8?q?=E3=80=81runs=20=E4=B8=8E=E9=9B=86=E6=88=90=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E6=8C=89=E8=A1=8C=E4=B8=BA=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kill_handlers、docker、runs、cwd、up、doctor 集成测试重构:连接样板收成 辅助函数,重复的快照读取与解码收成一处(调用点仍 fail 测试),用例主体 按实际动作命名。测试函数数不变,断言覆盖不减少。 Co-Authored-By: Claude Code --- .../internal/daemon/cwd_integration_test.go | 164 +++---- .../daemon/docker_integration_test.go | 323 +++++++------- .../daemon/doctor_integration_test.go | 121 +++--- engine/internal/daemon/kill_handlers_test.go | 411 +++++++++--------- .../internal/daemon/runs_integration_test.go | 244 ++++++----- engine/internal/daemon/up_integration_test.go | 159 +++---- 6 files changed, 724 insertions(+), 698 deletions(-) diff --git a/engine/internal/daemon/cwd_integration_test.go b/engine/internal/daemon/cwd_integration_test.go index 963b3874..d2b9062c 100644 --- a/engine/internal/daemon/cwd_integration_test.go +++ b/engine/internal/daemon/cwd_integration_test.go @@ -1,13 +1,13 @@ //go:build integration -// The acceptance path: a listener started inside a git +// The acceptance path for git-root grouping: a listener started inside a git // checkout is grouped under the repository, on every platform, with no // `oberth.yaml` anywhere near it. // -// Nothing here is guarded by OS, and that is the whole point. Until the PEB -// reader landed, `batchGetCwds` was a no-op on Windows, so every row there had -// an empty cwd, no project_root and no git-root group — and no test said so, -// because the ones that could have were written to skip. Run with +// Nothing here is guarded by OS, which is the whole point. Before the PEB +// reader landed `batchGetCwds` was a no-op on Windows: every row there had an +// empty cwd, no project_root and no git-root group, and no test said so, since +// the ones that could have were written to skip. Run with // `go test -tags integration ./internal/daemon/...`. package daemon_test @@ -27,51 +27,52 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// TestCwdGroupsPortsUnderTheGitRoot is the demo: two listeners -// started inside one checkout — one at its root, one in a subdirectory — carry -// the working directory they were started in, resolve a project_root at the -// checkout, and land in the same `` group in the daemon snapshot without -// any config file claiming them. +// TestCwdGroupsPortsUnderTheGitRoot is the demo: two listeners started inside +// one checkout — one at its root, one in a subdirectory — carry the working +// directory they were started in, resolve a project_root at the checkout, and +// land in the same `` group in the daemon snapshot without any config +// file claiming them. func TestCwdGroupsPortsUnderTheGitRoot(t *testing.T) { - listener, err := buildListener() + helper, err := buildListener() if err != nil { t.Fatal(err) } - e := newEnv(t) + sandbox := newEnv(t) - // A checkout is a directory with a `.git` entry — groups.Find only ever - // Lstats it — so no git binary is needed to make one. + // A checkout is nothing more than a directory holding a `.git` entry — + // groups.Find only ever Lstats it — so no git binary is needed here. repoName := "option-berth-cwd-demo" - repo := filepath.Join(e.home, repoName) - if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { - t.Fatal(err) + repo := filepath.Join(sandbox.home, repoName) + checkout := filepath.Join(repo, ".git") + if err := os.MkdirAll(checkout, 0o755); err != nil { + t.Fatalf("making the checkout marker %s: %v", checkout, err) } nested := filepath.Join(repo, "services", "api") if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatal(err) + t.Fatalf("making the nested directory %s: %v", nested, err) } // Deliberately no oberth.yaml: the group under test is the git-root one. if _, err := os.Stat(filepath.Join(repo, groups.ConfigName)); err == nil { t.Fatalf("%s exists; this test is about grouping without one", groups.ConfigName) } - e.serve() + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) // A subscriber keeps the daemon's scan loop running: with nobody listening // it parks between requests. - sub, err := c.Subscribe(ctx, client.SubscribeOptions{Buffer: 256}) + sub, err := cl.Subscribe(ctx, client.SubscribeOptions{Buffer: 256}) if err != nil { t.Fatalf("state.subscribe: %v", err) } go drain(ctx, sub) rootPort, nestedPort := unusedPort(t), unusedPort(t) - startListener(t, listener, repo, rootPort) - startListener(t, listener, nested, nestedPort) + startListener(t, helper, repo, rootPort) + startListener(t, helper, nested, nestedPort) wantCwd := map[int]string{ rootPort: groups.Canonical(repo), @@ -79,127 +80,128 @@ func TestCwdGroupsPortsUnderTheGitRoot(t *testing.T) { } wantRoot := groups.Canonical(repo) - rows := waitForCwds(t, ctx, c, wantCwd, 90*time.Second) + rows := waitForCwds(t, ctx, cl, wantCwd, 90*time.Second) for port, want := range wantCwd { - p := rows[port] - if p.Cwd == "" { + row := rows[port] + if row.Cwd == "" { t.Errorf("port %d has no cwd; the scanner cannot read this platform's per-process working directory", port) continue } // The raw field is compared canonically because each platform spells - // the same directory its own way: /private/var on macOS, the long path - // on Windows, the resolved symlink on Linux. - if got := groups.Canonical(p.Cwd); got != want { - t.Errorf("port %d cwd = %q (canonical %q), want %q", port, p.Cwd, got, want) + // one directory its own way: /private/var on macOS, the long path on + // Windows, the resolved symlink on Linux. + if got := groups.Canonical(row.Cwd); got != want { + t.Errorf("port %d cwd = %q (canonical %q), want %q", port, row.Cwd, got, want) } - // Spellings the rest of option-berth must never see, whatever the platform. - if strings.HasPrefix(p.Cwd, `\\?\`) { - t.Errorf("port %d cwd = %q keeps the extended-length prefix", port, p.Cwd) + // Spellings the rest of option-berth must never see, on any platform. + if strings.HasPrefix(row.Cwd, `\\?\`) { + t.Errorf("port %d cwd = %q keeps the extended-length prefix", port, row.Cwd) } - if len(p.Cwd) > 1 && strings.HasSuffix(p.Cwd, string(filepath.Separator)) { - t.Errorf("port %d cwd = %q keeps a trailing separator", port, p.Cwd) + if len(row.Cwd) > 1 && strings.HasSuffix(row.Cwd, string(filepath.Separator)) { + t.Errorf("port %d cwd = %q keeps a trailing separator", port, row.Cwd) } - if p.ProjectRoot == nil || *p.ProjectRoot == "" { - t.Errorf("port %d has no project_root even though its cwd is %q", port, p.Cwd) - } else if got := groups.Canonical(*p.ProjectRoot); got != wantRoot { + if row.ProjectRoot == nil || *row.ProjectRoot == "" { + t.Errorf("port %d has no project_root even though its cwd is %q", port, row.Cwd) + } else if got := groups.Canonical(*row.ProjectRoot); got != wantRoot { t.Errorf("port %d project_root = %q (canonical %q), want the checkout %q", - port, *p.ProjectRoot, got, wantRoot) + port, *row.ProjectRoot, got, wantRoot) } - if p.Group == nil || *p.Group != repoName { - t.Errorf("port %d group = %v, want %q from the git root", port, deref(p.Group), repoName) + if row.Group == nil || *row.Group != repoName { + t.Errorf("port %d group = %v, want %q from the git root", port, deref(row.Group), repoName) } - if p.GroupSource == nil || *p.GroupSource != state.SourceAuto { + if row.GroupSource == nil || *row.GroupSource != state.SourceAuto { t.Errorf("port %d group_source = %v, want %q (no oberth.yaml claims it)", - port, deref((*string)(p.GroupSource)), state.SourceAuto) + port, deref((*string)(row.GroupSource)), state.SourceAuto) } } - } -// startListener spawns the helper in dir and registers its cleanup. dir, not -// the test's own working directory, is what the scanner has to read back. -func startListener(t *testing.T, listener, dir string, port int) { +// startListener spawns the helper inside dir and registers its cleanup. dir, +// rather than the test's own working directory, is the cwd the scanner has to +// read back. +func startListener(t *testing.T, helper, dir string, port int) { t.Helper() - cmd := exec.Command(listener, strconv.Itoa(port)) - cmd.Dir = dir - cmd.WaitDelay = waitDelay - var out safeBuffer - cmd.Stdout, cmd.Stderr = &out, &out - ownProcessGroup(cmd) - if err := cmd.Start(); err != nil { + run := exec.Command(helper, strconv.Itoa(port)) + run.Dir = dir + run.WaitDelay = waitDelay + var log safeBuffer + run.Stdout, run.Stderr = &log, &log + ownProcessGroup(run) + if err := run.Start(); err != nil { t.Fatalf("starting the listener in %s: %v", dir, err) } t.Cleanup(func() { - stopCommand(cmd) - if t.Failed() && out.String() != "" { - t.Logf("listener on %d:\n%s", port, out.String()) + stopCommand(run) + if t.Failed() && log.String() != "" { + t.Logf("listener on %d:\n%s", port, log.String()) } }) - deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { + limit := time.Now().Add(30 * time.Second) + for time.Now().Before(limit) { if portOpen(port) { return } time.Sleep(100 * time.Millisecond) } - t.Fatalf("the listener never came up on %d:\n%s", port, out.String()) + t.Fatalf("the listener never came up on %d:\n%s", port, log.String()) } // waitForCwds polls state.snapshot until every wanted port is present with a // non-empty cwd, then returns the rows. The scan that first sees a port can // race the process's own startup, so a row without a cwd is retried rather than // failed on; the timeout is the real assertion. -func waitForCwds(t *testing.T, ctx context.Context, c *client.Client, want map[int]string, timeout time.Duration) map[int]state.Port { +func waitForCwds(t *testing.T, ctx context.Context, cl *client.Client, want map[int]string, timeout time.Duration) map[int]state.Port { t.Helper() rows := map[int]state.Port{} - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - var res state.Snapshot - if err := c.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &res); err != nil { + limit := time.Now().Add(timeout) + for time.Now().Before(limit) { + var snapshot state.Snapshot + if err := cl.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snapshot); err != nil { t.Fatalf("state.snapshot: %v", err) } - found := 0 - for _, p := range res.Ports { - if _, ok := want[p.Port]; !ok { + withCwd := 0 + for _, row := range snapshot.Ports { + if _, wanted := want[row.Port]; !wanted { continue } - rows[p.Port] = p - if p.Cwd != "" { - found++ + rows[row.Port] = row + if row.Cwd != "" { + withCwd++ } } - if found == len(want) { + if withCwd == len(want) { return rows } time.Sleep(500 * time.Millisecond) } for port := range want { - if p, ok := rows[port]; !ok { + row, seen := rows[port] + if !seen { t.Errorf("the daemon never reported port %d at all", port) - } else { - t.Errorf("port %d never got a cwd; last row: %+v", port, p) + continue } + t.Errorf("port %d never got a cwd; last row: %+v", port, row) } - t.Fatalf("no cwd on the spawned listeners within %s", timeout) + t.Fatalf("the spawned listeners never reported a cwd within %s", timeout) return rows } // drain keeps a subscription alive: a subscriber that stops reading is dropped, // and the scan loop parks again with it. func drain(ctx context.Context, sub *client.Subscription) { - for { + for ctx.Err() == nil { select { case <-ctx.Done(): return - case _, ok := <-sub.Deltas: - if !ok { + case _, open := <-sub.Deltas: + if !open { return } - case _, ok := <-sub.Events: - if !ok { + case _, open := <-sub.Events: + if !open { return } } diff --git a/engine/internal/daemon/docker_integration_test.go b/engine/internal/daemon/docker_integration_test.go index 77237ffb..2b7c98fa 100644 --- a/engine/internal/daemon/docker_integration_test.go +++ b/engine/internal/daemon/docker_integration_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/sheathedsharp/option-berth/engine/internal/daemon/client" "github.com/sheathedsharp/option-berth/engine/internal/daemon/rpc" "github.com/sheathedsharp/option-berth/engine/internal/state" ) @@ -20,290 +21,296 @@ import ( // fake docker's hang, and far under the 10 s a client gives up after. const replyBudget = 2 * time.Second -// fakeDockerOnPath writes a `docker` shell script that logs its arguments to -// calls and then runs body, and returns the PATH entry that puts it first. -func fakeDockerOnPath(t *testing.T, body string) (path, calls string) { +// fakeDockerOnPath puts a `docker` shell script first on PATH. Each run of it +// appends its arguments to a log file and then executes body. The first result +// is the environment entry that pins PATH, the second the log dockerCalls reads. +func fakeDockerOnPath(t *testing.T, body string) (pathEntry, logPath string) { t.Helper() if runtime.GOOS == "windows" { t.Skip("the fake docker is a shell script") } dir := t.TempDir() - calls = filepath.Join(dir, "calls.log") - script := "#!/bin/sh\necho \"$*\" >> '" + calls + "'\n" + body + logPath = filepath.Join(dir, "calls.log") + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '" + logPath + "'\n" + body if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { t.Fatal(err) } - return "PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH"), calls + return "PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH"), logPath } -// dockerCalls is how many times the fake docker ran with first argument verb. -func dockerCalls(calls, verb string) int { - b, err := os.ReadFile(calls) +// dockerCalls counts the logged runs whose first argument was verb. +func dockerCalls(logPath, verb string) int { + raw, err := os.ReadFile(logPath) if err != nil { return 0 } - n := 0 - for _, line := range strings.Split(string(b), "\n") { - if line == verb || strings.HasPrefix(line, verb+" ") { - n++ + seen := 0 + for _, line := range strings.Split(string(raw), "\n") { + if fields := strings.Fields(line); len(fields) > 0 && fields[0] == verb { + seen++ } } - return n + return seen } -// TestAHungDockerDoesNotStallKillOrSnapshot is the regression test. -// With a Docker whose backend never answers, `docker ps` hangs; the daemon's -// scans used to wait out the CLI timeout on every call, so a `ports.kill` took -// over 25 s and the `state.snapshot` calls queued behind it timed out. +// portRow is the snapshot row for one port, or nil when the snapshot has none. +func portRow(snapshot state.Snapshot, port int) *state.Port { + for i := range snapshot.Ports { + if snapshot.Ports[i].Port == port { + return &snapshot.Ports[i] + } + } + return nil +} + +// snapshotReader binds state.snapshot to one client, because the tests below +// poll it while they wait for the watcher to catch up. +func snapshotReader(t *testing.T, ctx context.Context, cl *client.Client) func() state.Snapshot { + t.Helper() + return func() state.Snapshot { + t.Helper() + var snapshot state.Snapshot + if err := cl.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snapshot); err != nil { + t.Fatalf("state.snapshot: %v", err) + } + return snapshot + } +} + +// TestAHungDockerDoesNotStallKillOrSnapshot is the regression test. The fake +// Docker never answers, so `docker ps` hangs; the daemon's scans used to wait +// out the CLI timeout on every call, which stretched a `ports.kill` past 25 s +// and timed out the `state.snapshot` calls queued behind it. func TestAHungDockerDoesNotStallKillOrSnapshot(t *testing.T) { - path, calls := fakeDockerOnPath(t, "exec sleep 60\n") - listener, err := buildListener() + pathEntry, logPath := fakeDockerOnPath(t, "exec sleep 60\n") + helper, err := buildListener() if err != nil { t.Fatal(err) } - e := newEnv(t) - e.extra = []string{path} - e.serve() + sandbox := newEnv(t) + sandbox.extra = []string{pathEntry} + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) port := freePort(t) - startListener(t, listener, e.home, port) + startListener(t, helper, sandbox.home, port) - // The kill should land while a `docker ps` is hanging: give the daemon a - // moment to ask the fake. A daemon that only asks Docker from inside a - // scan (the bug) has not asked yet, and its kill pays for the hang itself. - deadline := time.Now().Add(5 * time.Second) - for dockerCalls(calls, "ps") == 0 && time.Now().Before(deadline) { + // The kill has to land while a `docker ps` is hanging, so first give the + // daemon a moment to ask the fake. A daemon that asks Docker only from + // inside a scan (the bug) has not asked yet, and its kill pays for the + // hang itself. + asked := time.Now().Add(5 * time.Second) + for dockerCalls(logPath, "ps") == 0 && time.Now().Before(asked) { time.Sleep(50 * time.Millisecond) } - begin := time.Now() - var killed rpc.KillEnvelope - if err := c.Call(ctx, "ports.kill", rpc.PortsKillParams{ + killBegan := time.Now() + var envelope rpc.KillEnvelope + if err := cl.Call(ctx, "ports.kill", rpc.PortsKillParams{ Targets: []rpc.Selector{{Port: &port}}, - }, &killed); err != nil { + }, &envelope); err != nil { t.Fatalf("ports.kill: %v", err) } - killTook := time.Since(begin) - if len(killed.Results) == 0 || !killed.Results[0].OK { - t.Fatalf("ports.kill did not kill the listener: %+v", killed.Results) + killTook := time.Since(killBegan) + if len(envelope.Results) == 0 || !envelope.Results[0].OK { + t.Fatalf("ports.kill did not kill the listener: %+v", envelope.Results) } - if m := killed.Results[0].Method; m == state.MethodDockerStop { - t.Errorf("the listener was stopped with %s", m) + if method := envelope.Results[0].Method; method == state.MethodDockerStop { + t.Errorf("the listener was stopped with %s", method) } t.Logf("ports.kill took %s", killTook) if killTook > replyBudget { t.Errorf("ports.kill took %s with docker hanging, want under %s", killTook, replyBudget) } - // Three snapshots, spaced past the RPC cache so each one scans. - for i := 0; i < 3; i++ { - if i > 0 { + // Three snapshots, spaced past the RPC cache so that each one really scans. + read := snapshotReader(t, ctx, cl) + for round := 1; round <= 3; round++ { + if round > 1 { time.Sleep(2100 * time.Millisecond) } - begin := time.Now() - var snap state.Snapshot - if err := c.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snap); err != nil { - t.Fatalf("state.snapshot %d: %v", i+1, err) - } - took := time.Since(begin) - t.Logf("state.snapshot %d took %s", i+1, took) + began := time.Now() + snapshot := read() + took := time.Since(began) + t.Logf("state.snapshot %d took %s", round, took) if took > replyBudget { - t.Errorf("state.snapshot %d took %s with docker hanging, want under %s", i+1, took, replyBudget) + t.Errorf("state.snapshot %d took %s with docker hanging, want under %s", round, took, replyBudget) } - for _, p := range snap.Ports { + for _, p := range snapshot.Ports { if p.Port == port { - t.Errorf("snapshot %d still lists the killed port %d", i+1, port) + t.Errorf("snapshot %d still lists the killed port %d", round, port) } } } // Backoff: one hung attempt, then 5 s before the next. Scans poked the // watcher dozens of times meanwhile; none of them may have reached Docker. - n := dockerCalls(calls, "ps") - t.Logf("docker ps ran %d times in the %s since the kill began", n, time.Since(begin)) - if n == 0 { + attempts := dockerCalls(logPath, "ps") + t.Logf("docker ps ran %d times in the %s since the kill began", attempts, time.Since(killBegan)) + if attempts == 0 { t.Error("the daemon never ran docker ps, so this test proved nothing") } - if n > 3 { - t.Errorf("docker ps ran %d times against a hung Docker; the watcher is not backing off", n) + if attempts > 3 { + t.Errorf("docker ps ran %d times against a hung Docker; the watcher is not backing off", attempts) } } -// TestAHealthyDockerStillEnrichesAndGroups is the other half: with a Docker -// that answers, a port it publishes still carries the container, the compose -// labels and the compose project's group — now from the watcher's cache. +// TestAHealthyDockerStillEnrichesAndGroups is the other half: a Docker that +// answers still gives the port it publishes its container, its compose labels +// and the compose project's group — now out of the watcher's cache. func TestAHealthyDockerStillEnrichesAndGroups(t *testing.T) { - listener, err := buildListener() + helper, err := buildListener() if err != nil { t.Fatal(err) } port := freePort(t) composeDir := t.TempDir() - ps := "itest-web-1\tnginx:itest\t0.0.0.0:" + strconv.Itoa(port) + "->80/tcp\tweb\titestproj\t" + composeDir - path, calls := fakeDockerOnPath(t, "case \"$1\" in\nps) cat <<'EOF'\n"+ps+"\nEOF\n;;\n*) exit 1 ;;\nesac\n") + psLine := "itest-web-1\tnginx:itest\t0.0.0.0:" + strconv.Itoa(port) + "->80/tcp\tweb\titestproj\t" + composeDir + pathEntry, logPath := fakeDockerOnPath(t, + "case \"$1\" in\nps) cat <<'EOF'\n"+psLine+"\nEOF\n;;\n*) exit 1 ;;\nesac\n") - e := newEnv(t) - e.extra = []string{path} - e.serve() + sandbox := newEnv(t) + sandbox.extra = []string{pathEntry} + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) - // Since 5A.9 only a port Docker's forwarder holds takes container data, - // so the listener runs under the forwarder's name. - startListener(t, forwarderCopy(t, listener), e.home, port) + // Since the forwarder rule landed, only a port Docker's own forwarder holds + // takes container data, so the helper runs under that name. + startListener(t, forwarderCopy(t, helper), sandbox.home, port) - var row *state.Port - deadline := time.Now().Add(20 * time.Second) - for row == nil || row.Docker == nil { - if time.Now().After(deadline) { + read := snapshotReader(t, ctx, cl) + var found *state.Port + limit := time.Now().Add(20 * time.Second) + for found == nil || found.Docker == nil { + if time.Now().After(limit) { t.Fatalf("port %d was never enriched from docker (docker ps ran %d times); last row: %+v", - port, dockerCalls(calls, "ps"), row) + port, dockerCalls(logPath, "ps"), found) } time.Sleep(250 * time.Millisecond) - var snap state.Snapshot - if err := c.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snap); err != nil { - t.Fatalf("state.snapshot: %v", err) - } - row = nil - for i := range snap.Ports { - if snap.Ports[i].Port == port { - row = &snap.Ports[i] - } - } + found = portRow(read(), port) } - d := row.Docker - if row.Type != state.TypeDocker { - t.Errorf("type = %s, want docker", row.Type) + container := found.Docker + if found.Type != state.TypeDocker { + t.Errorf("type = %s, want docker", found.Type) } - if d.Container != "itest-web-1" || d.Image != "nginx:itest" || d.ComposeService != "web" || - d.ComposeProject != "itestproj" || d.ContainerPort != 80 { - t.Errorf("docker = %+v", *d) + if container.Container != "itest-web-1" || container.Image != "nginx:itest" || + container.ComposeService != "web" || container.ComposeProject != "itestproj" || + container.ContainerPort != 80 { + t.Errorf("docker = %+v", *container) } - if row.Group == nil || *row.Group != "itestproj" { - t.Errorf("group = %v, want the compose project itestproj", deref(row.Group)) + if found.Group == nil || *found.Group != "itestproj" { + t.Errorf("group = %v, want the compose project itestproj", deref(found.Group)) } } // forwarderCopy copies the listener helper under the name of this platform's -// Docker port forwarder, so the scanner reports it the way it reports a real -// published container port: "com.docke" through lsof on macOS, "docker-proxy" -// through ss on Linux. -func forwarderCopy(t *testing.T, listener string) string { +// Docker port forwarder, so the scanner reports the helper the way it reports a +// real published container port: "com.docke" through lsof on macOS, +// "docker-proxy" through ss on Linux. +func forwarderCopy(t *testing.T, helper string) string { t.Helper() name := "com.docker.backend" if runtime.GOOS == "linux" { name = "docker-proxy" } - b, err := os.ReadFile(listener) + image, err := os.ReadFile(helper) if err != nil { t.Fatal(err) } - bin := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(bin, b, 0o755); err != nil { + copied := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(copied, image, 0o755); err != nil { t.Fatal(err) } - return bin + return copied } -// TestAStaleContainerListDoesNotClaimANativePort is the regression -// test (issue #95). Docker answers once, publishing the port as itest-web-1, -// then wedges, so the watcher keeps serving that list. A native process then -// takes the port. The row must not carry the container, and `ports.kill` must -// signal the process instead of running `docker stop`. +// TestAStaleContainerListDoesNotClaimANativePort is the regression test for +// issue #95. Docker answers once, publishing the port as itest-web-1, then +// wedges so the watcher keeps serving that list; a native process then takes +// the port. Its row must not carry the container, and `ports.kill` must signal +// the process instead of running `docker stop`. func TestAStaleContainerListDoesNotClaimANativePort(t *testing.T) { - listener, err := buildListener() + helper, err := buildListener() if err != nil { t.Fatal(err) } port := freePort(t) wedge := filepath.Join(t.TempDir(), "wedged") - ps := "itest-web-1\tnginx:itest\t0.0.0.0:" + strconv.Itoa(port) + "->80/tcp\tweb\titestproj\t" + t.TempDir() - path, calls := fakeDockerOnPath(t, "case \"$1\" in\nps)\n"+ + psLine := "itest-web-1\tnginx:itest\t0.0.0.0:" + strconv.Itoa(port) + "->80/tcp\tweb\titestproj\t" + t.TempDir() + pathEntry, logPath := fakeDockerOnPath(t, "case \"$1\" in\nps)\n"+ " if [ -e '"+wedge+"' ]; then exec sleep 60; fi\n"+ - " cat <<'EOF'\n"+ps+"\nEOF\n;;\n*) exit 1 ;;\nesac\n") + " cat <<'EOF'\n"+psLine+"\nEOF\n;;\n*) exit 1 ;;\nesac\n") - e := newEnv(t) - e.extra = []string{path} - e.serve() + sandbox := newEnv(t) + sandbox.extra = []string{pathEntry} + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) + read := snapshotReader(t, ctx, cl) - snapshot := func() state.Snapshot { - t.Helper() - var snap state.Snapshot - if err := c.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snap); err != nil { - t.Fatalf("state.snapshot: %v", err) + // Let the watcher cache the list: the second `docker ps` only starts once + // the first has answered, and the snapshots keep the scanner — and with it + // the watcher — running. + limit := time.Now().Add(20 * time.Second) + for dockerCalls(logPath, "ps") < 2 { + if time.Now().After(limit) { + t.Fatalf("docker ps ran %d times, want 2 before wedging it", dockerCalls(logPath, "ps")) } - return snap - } - - // Let the watcher cache the list: the second `docker ps` only starts - // once the first has answered. Snapshots keep the scanner (and so the - // watcher) running. - deadline := time.Now().Add(20 * time.Second) - for dockerCalls(calls, "ps") < 2 { - if time.Now().After(deadline) { - t.Fatalf("docker ps ran %d times, want 2 before wedging it", dockerCalls(calls, "ps")) - } - snapshot() + read() time.Sleep(250 * time.Millisecond) } - // Docker wedges; from here the cached list can only go stale. + // Docker wedges; from here on the cached list can only go stale. if err := os.WriteFile(wedge, nil, 0o644); err != nil { t.Fatal(err) } - startListener(t, listener, e.home, port) + startListener(t, helper, sandbox.home, port) - var row *state.Port - deadline = time.Now().Add(20 * time.Second) - for row == nil { - if time.Now().After(deadline) { + var found *state.Port + limit = time.Now().Add(20 * time.Second) + for found == nil { + if time.Now().After(limit) { t.Fatalf("port %d never showed up", port) } time.Sleep(250 * time.Millisecond) - snap := snapshot() - for i := range snap.Ports { - if snap.Ports[i].Port == port { - row = &snap.Ports[i] - } - } + found = portRow(read(), port) } - t.Logf("row: process=%q type=%s docker=%+v", row.Process, row.Type, row.Docker) - if row.Docker != nil || row.Type == state.TypeDocker { - t.Errorf("the native listener's row carries the cached container: type=%s docker=%+v", row.Type, row.Docker) + t.Logf("row: process=%q type=%s docker=%+v", found.Process, found.Type, found.Docker) + if found.Docker != nil || found.Type == state.TypeDocker { + t.Errorf("the native listener's row carries the cached container: type=%s docker=%+v", + found.Type, found.Docker) } - begin := time.Now() - var killed rpc.KillEnvelope - if err := c.Call(ctx, "ports.kill", rpc.PortsKillParams{ + killBegan := time.Now() + var envelope rpc.KillEnvelope + if err := cl.Call(ctx, "ports.kill", rpc.PortsKillParams{ Targets: []rpc.Selector{{Port: &port}}, - }, &killed); err != nil { + }, &envelope); err != nil { t.Fatalf("ports.kill: %v", err) } - t.Logf("ports.kill took %s: %+v", time.Since(begin), killed.Results) - if len(killed.Results) == 0 { + t.Logf("ports.kill took %s: %+v", time.Since(killBegan), envelope.Results) + if len(envelope.Results) == 0 { t.Fatal("ports.kill returned no rows") } - if r := killed.Results[0]; !r.OK || r.Method == state.MethodDockerStop { - t.Errorf("ports.kill row = %+v, want the listener signalled", r) + if row := envelope.Results[0]; !row.OK || row.Method == state.MethodDockerStop { + t.Errorf("ports.kill row = %+v, want the listener signalled", row) } - if n := dockerCalls(calls, "stop"); n != 0 { - t.Errorf("docker stop ran %d times for a port a native process held", n) + if stopped := dockerCalls(logPath, "stop"); stopped != 0 { + t.Errorf("docker stop ran %d times for a port a native process held", stopped) } - deadline = time.Now().Add(5 * time.Second) + limit = time.Now().Add(5 * time.Second) for portOpen(port) { - if time.Now().After(deadline) { + if time.Now().After(limit) { t.Fatalf("port %d is still open after the kill", port) } time.Sleep(100 * time.Millisecond) diff --git a/engine/internal/daemon/doctor_integration_test.go b/engine/internal/daemon/doctor_integration_test.go index 246603f5..bc66ee7f 100644 --- a/engine/internal/daemon/doctor_integration_test.go +++ b/engine/internal/daemon/doctor_integration_test.go @@ -13,97 +13,111 @@ import ( ) // doctorEnvironmentChecks are the rows that must come back clean on a machine -// where option-berth itself is set up correctly: the daemon, its socket, its database -// and the user's config. The integration checks — MCP clients, the skill, the -// hooks, a project's oberth.yaml, docker — describe the *user's* machine and -// are warnings on a fresh temp HOME by design, so they are not asserted here. +// where option-berth itself is set up correctly: the daemon, its socket, its +// database and the user's config. The integration checks — MCP clients, the +// skill, the hooks, a project's oberth.yaml, docker — describe the *user's* +// machine and are warnings on a fresh temp HOME by design, so nothing here +// asserts them. var doctorEnvironmentChecks = []string{ "config_parses", "config_dir_writable", "daemon_reachable", "daemon_version_matches", "daemon_protocol", "socket_permissions", "db_ok", } -// TestDoctorAgainstARunningDaemon is the step's acceptance demo: `oberth doctor` -// against a daemon this test started reports every environment check ok and -// exits 0. -func TestDoctorAgainstARunningDaemon(t *testing.T) { - e := newEnv(t) - e.serve() +// checksByID keys a doctor report's rows by check id. +func checksByID(report rpc.DaemonDoctorResult) map[string]rpc.DoctorCheck { + byID := make(map[string]rpc.DoctorCheck, len(report.Checks)) + for _, check := range report.Checks { + byID[check.ID] = check + } + return byID +} - out, err := e.command("doctor", "--json", - "--only", strings.Join(doctorEnvironmentChecks, ",")).CombinedOutput() +// doctorReport runs `oberth doctor --json` over the named checks and hands back +// the decoded report together with the raw output. +func doctorReport(t *testing.T, sandbox *env, only ...string) (rpc.DaemonDoctorResult, []byte) { + t.Helper() + out, err := sandbox.command("doctor", "--json", "--only", strings.Join(only, ",")).CombinedOutput() if err != nil { t.Fatalf("oberth doctor exited non-zero: %v\n%s", err, out) } - var report rpc.DaemonDoctorResult if err := json.Unmarshal(out, &report); err != nil { t.Fatalf("oberth doctor --json did not print JSON: %v\n%s", err, out) } + return report, out +} + +// TestDoctorAgainstARunningDaemon is the acceptance demo: `oberth doctor` +// against a daemon this test started reports every environment check ok and +// exits 0. +func TestDoctorAgainstARunningDaemon(t *testing.T) { + sandbox := newEnv(t) + sandbox.serve() + + report, raw := doctorReport(t, sandbox, doctorEnvironmentChecks...) if !report.OK { - t.Errorf("report is not ok:\n%s", out) + t.Errorf("report is not ok:\n%s", raw) } assertHealthy(t, report, doctorEnvironmentChecks) if report.DaemonVersion == "" { - t.Error("the report did not name the daemon's version") + t.Error("the report named no daemon version") } } -// TestDoctorExitsNonZeroWithNoDaemon is the other half of the exit-code +// TestDoctorExitsNonZeroWithNoDaemon covers the other half of the exit-code // contract: a script can branch on it. func TestDoctorExitsNonZeroWithNoDaemon(t *testing.T) { - e := newEnv(t) - out, err := e.command("doctor", "--only", "daemon_reachable").CombinedOutput() + sandbox := newEnv(t) + out, err := sandbox.command("doctor", "--only", "daemon_reachable").CombinedOutput() if err == nil { t.Fatalf("oberth doctor exited 0 with no daemon running:\n%s", out) } - if !strings.Contains(string(out), "no daemon is listening") { - t.Errorf("output does not say what is wrong:\n%s", out) + if said := string(out); !strings.Contains(said, "no daemon is listening") { + t.Errorf("output does not say what is wrong:\n%s", said) } } // TestDaemonDoctorRPC is what the desktop app calls: the same rows, without // shelling out, with the CLI-only ones marked as such. func TestDaemonDoctorRPC(t *testing.T) { - e := newEnv(t) - e.serve() + sandbox := newEnv(t) + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) + only := append([]string{"cli_on_path"}, doctorEnvironmentChecks...) var report rpc.DaemonDoctorResult - if err := c.Call(ctx, "daemon.doctor", rpc.DaemonDoctorParams{ - Only: append([]string{"cli_on_path"}, doctorEnvironmentChecks...), - }, &report); err != nil { + if err := cl.Call(ctx, "daemon.doctor", rpc.DaemonDoctorParams{Only: only}, &report); err != nil { t.Fatalf("daemon.doctor: %v", err) } if !report.OK { t.Errorf("daemon.doctor is not ok: %+v", report.Checks) } - byID := map[string]rpc.DoctorCheck{} - for _, c := range report.Checks { - byID[c.ID] = c - } + byID := checksByID(report) cli, ok := byID["cli_on_path"] if !ok { t.Fatal("daemon.doctor dropped the cli_on_path row instead of skipping it") } if cli.Status != "skip" || !strings.Contains(cli.Detail, "CLI-only") { - t.Errorf("cli_on_path = %+v, want a skip whose detail says it is CLI-only", cli) + t.Errorf("cli_on_path = %+v, want a skip whose detail calls it CLI-only", cli) } - // The daemon answers for itself rather than dialling its own socket. - if d := byID["daemon_reachable"]; d.Status != "ok" || !strings.Contains(d.Detail, "from the daemon itself") { - t.Errorf("daemon_reachable = %+v", d) + // The daemon reports on itself rather than dialling its own socket. + if reachable := byID["daemon_reachable"]; reachable.Status != "ok" || + !strings.Contains(reachable.Detail, "from the daemon itself") { + t.Errorf("daemon_reachable = %+v", reachable) } assertHealthy(t, report, doctorEnvironmentChecks) - // Same daemon, because a second one costs a whole process: an unknown id - // must be refused rather than silently answered with an empty report. + // The same daemon serves the refusals below: a second one costs a whole + // process, and an unknown id must be refused rather than silently answered + // with an empty report. t.Run("an unknown check is refused", func(t *testing.T) { var out rpc.DaemonDoctorResult - err := c.Call(ctx, "daemon.doctor", rpc.DaemonDoctorParams{Only: []string{"nope"}}, &out) + err := cl.Call(ctx, "daemon.doctor", rpc.DaemonDoctorParams{Only: []string{"nope"}}, &out) if err == nil { t.Fatal("an unknown check id was accepted") } @@ -112,37 +126,36 @@ func TestDaemonDoctorRPC(t *testing.T) { } }) - // A relative --project would mean the daemon's own working directory, - // which is never what the caller meant. + // A relative --project would mean the daemon's own working directory, which + // is never what the caller meant. t.Run("a relative project is refused", func(t *testing.T) { var out rpc.DaemonDoctorResult - if err := c.Call(ctx, "daemon.doctor", rpc.DaemonDoctorParams{Project: "."}, &out); err == nil { + err := cl.Call(ctx, "daemon.doctor", rpc.DaemonDoctorParams{Project: "."}, &out) + if err == nil { t.Fatal("a relative project path was accepted") } }) } -// assertHealthy fails the test for any of ids that is not ok. A skip is allowed -// only where the platform genuinely has nothing to look at — Windows has no -// socket file — and never silently: the check has to say so. +// assertHealthy fails the test for every id that did not come back ok. A skip +// passes only where the platform genuinely has nothing to look at — Windows has +// no socket file — and never silently: the row still has to say so. func assertHealthy(t *testing.T, report rpc.DaemonDoctorResult, ids []string) { t.Helper() - byID := map[string]rpc.DoctorCheck{} - for _, c := range report.Checks { - byID[c.ID] = c - } + byID := checksByID(report) for _, id := range ids { - got, ok := byID[id] + check, ok := byID[id] if !ok { t.Errorf("%s is missing from the report", id) continue } - if got.Status == "ok" { - continue - } - if got.Status == "skip" && (id == "socket_permissions" || id == "daemon_version_matches") { - continue // no socket file on Windows; no CLI to compare over RPC + switch { + case check.Status == "ok": + // The row is what the environment check is supposed to say. + case check.Status == "skip" && (id == "socket_permissions" || id == "daemon_version_matches"): + // No socket file on Windows; no CLI to compare over RPC. + default: + t.Errorf("%s = %s (%s / %s), want ok", id, check.Status, check.Summary, check.Detail) } - t.Errorf("%s = %s (%s / %s), want ok", id, got.Status, got.Summary, got.Detail) } } diff --git a/engine/internal/daemon/kill_handlers_test.go b/engine/internal/daemon/kill_handlers_test.go index 62535cee..2c313799 100644 --- a/engine/internal/daemon/kill_handlers_test.go +++ b/engine/internal/daemon/kill_handlers_test.go @@ -15,155 +15,179 @@ import ( func ptr[T any](v T) *T { return &v } +// killHarness is the pair every handler test below needs: a harness whose fake +// scan the test owns, and a client talking to it. Both hang off a context that +// ends with the test. +func killHarness(t *testing.T) (*testHarness, *testClient) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + harness := newHarness(t, ctx) + return harness, harness.dial(ctx) +} + // snapshotWith builds the snapshot the kill handlers resolve against. func snapshotWith(pp ...state.Port) state.Snapshot { return state.Snapshot{Ports: pp} } func TestSelectorTargetRequiresExactlyOneKey(t *testing.T) { - snap := snapshotWith() - cases := map[string]rpc.Selector{ - "empty": {}, - "two keys": {Port: ptr(3000), PID: ptr(42)}, - "bind only": {BindAddress: ptr("127.0.0.1")}, - } - for name, sel := range cases { - t.Run(name, func(t *testing.T) { - if _, err := selectorTarget(sel, snap); err == nil { + empty := snapshotWith() + cases := []struct { + name string + selector rpc.Selector + }{ + {name: "empty", selector: rpc.Selector{}}, + {name: "two keys", selector: rpc.Selector{Port: ptr(3000), PID: ptr(42)}}, + {name: "bind only", selector: rpc.Selector{BindAddress: ptr("127.0.0.1")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := selectorTarget(tc.selector, empty) + if err == nil { t.Fatal("want an invalid_selector error") - } else if killer.Code(err) != killer.CodeInvalidSelector { - t.Fatalf("code = %q, want invalid_selector", killer.Code(err)) + } + if code := killer.Code(err); code != killer.CodeInvalidSelector { + t.Fatalf("code = %q, want invalid_selector", code) } }) } } func TestSelectorTargetPinsAnUnambiguousBindAddress(t *testing.T) { - snap := snapshotWith(state.Port{Port: 3000, BindAddress: "127.0.0.1"}) - got, err := selectorTarget(rpc.Selector{Port: ptr(3000)}, snap) + snapshot := snapshotWith(state.Port{Port: 3000, BindAddress: "127.0.0.1"}) + target, err := selectorTarget(rpc.Selector{Port: ptr(3000)}, snapshot) if err != nil { t.Fatalf("selectorTarget: %v", err) } - if got.Port != 3000 || got.BindAddress != "127.0.0.1" { - t.Fatalf("target = %+v, want the snapshot's bind address", got) + if target.Port != 3000 { + t.Errorf("port = %d, want the 3000 the selector named", target.Port) + } + if target.BindAddress != "127.0.0.1" { + t.Errorf("bind_address = %q, want the snapshot's bind address", target.BindAddress) } } func TestSelectorTargetLeavesAMultiBindPortOpen(t *testing.T) { - snap := snapshotWith( + snapshot := snapshotWith( state.Port{Port: 3000, BindAddress: "127.0.0.1"}, state.Port{Port: 3000, BindAddress: "::1"}, ) - got, err := selectorTarget(rpc.Selector{Port: ptr(3000)}, snap) + target, err := selectorTarget(rpc.Selector{Port: ptr(3000)}, snapshot) if err != nil { t.Fatalf("selectorTarget: %v", err) } - if got.BindAddress != "" { - t.Fatalf("bind_address = %q, want it left to the killer to report as ambiguous", got.BindAddress) + if target.BindAddress != "" { + t.Fatalf("bind_address = %q, want it left to the killer to report as ambiguous", target.BindAddress) } } func TestSelectorTargetCarriesTheOtherSelectorKeys(t *testing.T) { - snap := snapshotWith() - if got, err := selectorTarget(rpc.Selector{PID: ptr(4242)}, snap); err != nil || got.PID != 4242 { - t.Fatalf("pid selector = %+v, %v", got, err) + snapshot := snapshotWith() + byPID, err := selectorTarget(rpc.Selector{PID: ptr(4242)}, snapshot) + if err != nil || byPID.PID != 4242 { + t.Fatalf("pid selector = %+v, %v", byPID, err) } - if got, err := selectorTarget(rpc.Selector{RunID: ptr("run-1")}, snap); err != nil || got.RunID != "run-1" { - t.Fatalf("run_id selector = %+v, %v", got, err) + byRun, err := selectorTarget(rpc.Selector{RunID: ptr("run-1")}, snapshot) + if err != nil || byRun.RunID != "run-1" { + t.Fatalf("run_id selector = %+v, %v", byRun, err) } - } func TestGroupTargetsMatchesGroupRunAndComposeProject(t *testing.T) { - snap := snapshotWith( + snapshot := snapshotWith( state.Port{Port: 3000, BindAddress: "127.0.0.1", Group: ptr("my-app")}, state.Port{Port: 3001, BindAddress: "127.0.0.1", Run: &state.Run{Group: "MY-APP"}}, state.Port{Port: 5432, BindAddress: "0.0.0.0", Docker: &state.Docker{ComposeProject: "my-app"}}, state.Port{Port: 9999, BindAddress: "127.0.0.1", Group: ptr("other")}, ) - got := groupTargets(snap, "my-app") - if len(got) != 3 { - t.Fatalf("targets = %+v, want the three my-app rows", got) + targets := groupTargets(snapshot, "my-app") + if len(targets) != 3 { + t.Fatalf("targets = %+v, want the three my-app rows", targets) } - for _, tg := range got { - if tg.Port == 9999 { - t.Fatalf("group kill picked up an unrelated port: %+v", got) + for _, target := range targets { + if target.Port == 9999 { + t.Fatalf("group kill picked up an unrelated port: %+v", targets) } } } func TestRunsWithoutPortsLeavesOnlySilentRuns(t *testing.T) { - snap := snapshotWith( + snapshot := snapshotWith( state.Port{Port: 3000, PID: 100, Group: ptr("demo"), Run: &state.Run{RootPID: 100}}, state.Port{Port: 3001, PID: 101, Run: &state.Run{Group: "demo", RootPID: 101}}, ) - got := runsWithoutPorts(snap, "demo", []int{100, 101, 200}) - if len(got) != 1 || got[0] != 200 { - t.Fatalf("pids = %v, want only the silent run", got) + silent := runsWithoutPorts(snapshot, "demo", []int{100, 101, 200}) + if len(silent) != 1 || silent[0] != 200 { + t.Fatalf("pids = %v, want only the silent run", silent) } } func TestKillEnvelopeListsAffectedKeysOnce(t *testing.T) { - env := killEnvelope([]state.KillResult{ - {Port: 3000, BindAddress: "127.0.0.1", PID: 400, Method: state.MethodSIGTERM, OK: true}, - {Port: 3000, BindAddress: "127.0.0.1", PID: 300, Method: state.MethodSIGKILL, OK: true}, - {Port: 0, PID: 12345, Method: state.MethodSIGTERM, OK: true}, + envelope := killEnvelope([]state.KillResult{ + {Host: state.LocalhostName, Port: 3000, BindAddress: "127.0.0.1", PID: 400, + Method: state.MethodSIGTERM, OK: true}, + {Host: state.LocalhostName, Port: 3000, BindAddress: "127.0.0.1", PID: 300, + Method: state.MethodSIGKILL, OK: true}, + {Host: state.LocalhostName, Port: 0, PID: 12345, Method: state.MethodSIGTERM, OK: true}, }) - if !env.OK { + if !envelope.OK { t.Error("ok = false, want true when every row succeeded") } - if len(env.Affected) != 1 || env.Affected[0] != "3000:127.0.0.1" { - t.Errorf("affected = %v, want one port key", env.Affected) + if len(envelope.Affected) != 1 { + t.Fatalf("affected = %v, want one port key", envelope.Affected) + } + if key := envelope.Affected[0]; key != "3000:127.0.0.1" { + t.Errorf("affected = %q, want the key the two 3000 rows share", key) } } func TestKillEnvelopeIsNotOKWhenARowFailed(t *testing.T) { - env := killEnvelope([]state.KillResult{ - {Port: 3000, BindAddress: "127.0.0.1", PID: 400, Method: state.MethodSIGTERM, OK: true}, - {Port: 8080, BindAddress: "127.0.0.1", Method: state.MethodNone, OK: false, Error: "boom"}, + envelope := killEnvelope([]state.KillResult{ + {Host: state.LocalhostName, Port: 3000, BindAddress: "127.0.0.1", PID: 400, + Method: state.MethodSIGTERM, OK: true}, + {Host: state.LocalhostName, Port: 8080, BindAddress: "127.0.0.1", + Method: state.MethodNone, OK: false, Error: "boom"}, }) - if env.OK { + if envelope.OK { t.Error("ok = true, want false when a row failed") } - if len(env.Affected) != 1 || env.Affected[0] != "3000:127.0.0.1" { - t.Errorf("affected = %v, want only the successful row", env.Affected) + if len(envelope.Affected) != 1 { + t.Fatalf("affected = %v, want only the successful row", envelope.Affected) + } + if key := envelope.Affected[0]; key != "3000:127.0.0.1" { + t.Errorf("affected = %q, want the successful row's key", key) } } func TestKillEnvelopeAlwaysHasArrays(t *testing.T) { - env := killEnvelope(nil) - if env.Results == nil || env.Affected == nil { - t.Fatalf("envelope = %+v, want empty arrays rather than null", env) + envelope := killEnvelope(nil) + if envelope.Results == nil || envelope.Affected == nil { + t.Fatalf("envelope = %+v, want empty arrays rather than null", envelope) } } func TestPortsKillRejectsEmptyTargets(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + _, cl := killHarness(t) - e := c.call("ports.kill", rpc.PortsKillParams{}, nil) - if e == nil { + rpcErr := cl.call("ports.kill", rpc.PortsKillParams{}, nil) + if rpcErr == nil { t.Fatal("ports.kill with no targets succeeded") } - if e.Code != rpc.CodeInvalidParams { - t.Fatalf("code = %d, want %d", e.Code, rpc.CodeInvalidParams) + if rpcErr.Code != rpc.CodeInvalidParams { + t.Fatalf("code = %d, want %d", rpcErr.Code, rpc.CodeInvalidParams) } } func TestPortsKillRejectsAnInvalidSelector(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + _, cl := killHarness(t) - e := c.call("ports.kill", rpc.PortsKillParams{Targets: []rpc.Selector{{}}}, nil) - if e == nil { + rpcErr := cl.call("ports.kill", rpc.PortsKillParams{Targets: []rpc.Selector{{}}}, nil) + if rpcErr == nil { t.Fatal("ports.kill with an empty selector succeeded") } - if e.Code != rpc.CodeInvalidSelector || e.Data.Code != "invalid_selector" { - t.Fatalf("error = %d/%q, want 1009/invalid_selector", e.Code, e.Data.Code) + if rpcErr.Code != rpc.CodeInvalidSelector || rpcErr.Data.Code != "invalid_selector" { + t.Fatalf("error = %d/%q, want 1009/invalid_selector", rpcErr.Code, rpcErr.Data.Code) } } @@ -172,29 +196,29 @@ func TestPortsKillRejectsAnInvalidSelector(t *testing.T) { // envelope stays ok, and the row is kept so the reader sees why nothing was // signalled. func TestPortsKillDryRunReportsAnUnknownPortAsNothingToStop(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + _, cl := killHarness(t) port := freeTestPort(t) - var env rpc.KillEnvelope - if e := c.call("ports.kill", rpc.PortsKillParams{ - Targets: []rpc.Selector{{Port: ptr(port)}}, DryRun: true, - }, &env); e != nil { - t.Fatalf("ports.kill: %v", e) + var envelope rpc.KillEnvelope + params := rpc.PortsKillParams{Targets: []rpc.Selector{{Port: ptr(port)}}, DryRun: true} + if rpcErr := cl.call("ports.kill", params, &envelope); rpcErr != nil { + t.Fatalf("ports.kill: %v", rpcErr) } - if !env.OK { + if !envelope.OK { t.Errorf("ok = false; a port nothing listens on is already in the wanted state") } - if len(env.Results) != 1 || env.Results[0].Method != state.MethodNone || env.Results[0].OK { - t.Fatalf("results = %+v, want one none row saying it was not there", env.Results) + if len(envelope.Results) != 1 { + t.Fatalf("results = %+v, want the single row the port resolved to", envelope.Results) } - if env.Results[0].Code != killer.CodeNotFound { - t.Errorf("code = %q, want %q so the reader does not parse the prose", env.Results[0].Code, killer.CodeNotFound) + row := envelope.Results[0] + if row.Method != state.MethodNone || row.OK { + t.Fatalf("results[0] = %+v, want a none row that says it was not there", row) } - if len(env.Affected) != 0 { - t.Errorf("affected = %v, want nothing affected", env.Affected) + if row.Code != killer.CodeNotFound { + t.Errorf("code = %q, want %q so the reader does not parse the prose", row.Code, killer.CodeNotFound) + } + if len(envelope.Affected) != 0 { + t.Errorf("affected = %v, want nothing affected", envelope.Affected) } } @@ -203,110 +227,98 @@ func TestPortsKillDryRunReportsAnUnknownPortAsNothingToStop(t *testing.T) { // `down` on a project whose last process was gone used to report a failure // while everything really was stopped. func TestKillEnvelopeStaysOKWhenTheTargetWasAlreadyGone(t *testing.T) { - env := killEnvelope([]state.KillResult{ + envelope := killEnvelope([]state.KillResult{ {Host: state.LocalhostName, Port: 3000, BindAddress: "127.0.0.1", PID: 300, Method: state.MethodSIGTERM, OK: true}, {Host: state.LocalhostName, PID: 400, Method: state.MethodNone, OK: false, Error: "no process with PID 400", Code: killer.CodeNotFound}, }) - if !env.OK { + if !envelope.OK { t.Error("ok = false; an already-gone target is not a failure") } - if len(env.Results) != 2 { - t.Fatalf("results = %+v, want the row kept so the reader sees why", env.Results) + if len(envelope.Results) != 2 { + t.Fatalf("results = %+v, want the row kept so the reader sees why", envelope.Results) } } -// TestKillResolvesAgainstTheDaemonsOwnScan: the handler hands the killer the -// rows it just scanned, instead of letting killer.KillPorts scan the machine a -// second time inside the RPC. The proof is a listener that exists only in the -// daemon's scan: a killer that went looking for itself would find nothing on -// that port and report a failed "none" row, so a planned signal carrying the -// snapshot's own pid and name is the daemon's scan being used. +// TestKillResolvesAgainstTheDaemonsOwnScan: the handler resolves targets +// against the rows it just scanned rather than letting killer.KillPorts scan +// the machine a second time inside the RPC. The proof is a listener that only +// exists in the daemon's scan — a killer that went looking for itself would +// find nothing on that port and answer a failed "none" row — so a planned +// signal carrying the snapshot's pid and name is the daemon's scan being used. func TestKillResolvesAgainstTheDaemonsOwnScan(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + harness, cl := killHarness(t) const fakePID = 424242 port := freeTestPort(t) - h.setRows(ports.ListeningPort{ + harness.setRows(ports.ListeningPort{ Port: port, BindAddress: "127.0.0.1", PID: fakePID, Process: "node", Display: "fake-api", }) - var env rpc.KillEnvelope - if e := c.call("ports.kill", rpc.PortsKillParams{ - Targets: []rpc.Selector{{Port: ptr(port)}}, DryRun: true, - }, &env); e != nil { - t.Fatalf("ports.kill: %v", e) + var envelope rpc.KillEnvelope + params := rpc.PortsKillParams{Targets: []rpc.Selector{{Port: ptr(port)}}, DryRun: true} + if rpcErr := cl.call("ports.kill", params, &envelope); rpcErr != nil { + t.Fatalf("ports.kill: %v", rpcErr) } - if len(env.Results) != 1 { - t.Fatalf("results = %+v, want one row for the snapshot's listener", env.Results) + if len(envelope.Results) != 1 { + t.Fatalf("results = %+v, want one row for the snapshot's listener", envelope.Results) } - row := env.Results[0] + row := envelope.Results[0] if row.Method == state.MethodNone { t.Fatalf("row = %+v, want a planned signal: the killer resolved against its own scan, not the daemon's", row) } - if row.PID != fakePID || row.Name != "fake-api" || row.Port != port { - t.Fatalf("row = %+v, want the daemon snapshot's pid %d and name fake-api on port %d", - row, fakePID, port) + if row.PID != fakePID { + t.Errorf("pid = %d, want the snapshot's fake pid %d", row.PID, fakePID) + } + if row.Name != "fake-api" || row.Port != port { + t.Errorf("row = %+v, want the snapshot's name fake-api on port %d", row, port) } } // TestKillCostsExactlyOneScan pins the other half: resolving targets rescans // once and nothing scans again behind it. func TestKillCostsExactlyOneScan(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + harness, cl := killHarness(t) - h.setRows(ports.ListeningPort{Port: 4323, BindAddress: "127.0.0.1", PID: 424243, Process: "node"}) - if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { + harness.setRows(ports.ListeningPort{Port: 4323, BindAddress: "127.0.0.1", PID: 424243, Process: "node"}) + if _, err := harness.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming: %v", err) } - before := h.loop.Status().Scans + before := harness.loop.Status().Scans - var env rpc.KillEnvelope - if e := c.call("ports.kill", rpc.PortsKillParams{ - Targets: []rpc.Selector{{Port: ptr(4323)}}, DryRun: true, - }, &env); e != nil { - t.Fatalf("ports.kill: %v", e) + var envelope rpc.KillEnvelope + params := rpc.PortsKillParams{Targets: []rpc.Selector{{Port: ptr(4323)}}, DryRun: true} + if rpcErr := cl.call("ports.kill", params, &envelope); rpcErr != nil { + t.Fatalf("ports.kill: %v", rpcErr) } - if after := h.loop.Status().Scans; after != before+1 { - t.Errorf("scans = %d, want exactly one (target resolution) more than %d", after, before) + if scans := harness.loop.Status().Scans; scans != before+1 { + t.Errorf("scans = %d, want exactly one (target resolution) more than %d", scans, before) } } func TestGroupsKillReportsAnUnknownGroupAsNotFound(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + _, cl := killHarness(t) - e := c.call("groups.kill", rpc.GroupsKillParams{Name: "no-such-group"}, nil) - if e == nil { + rpcErr := cl.call("groups.kill", rpc.GroupsKillParams{Name: "no-such-group"}, nil) + if rpcErr == nil { t.Fatal("groups.kill on an unknown group succeeded") } - if e.Code != rpc.CodeNotFound || e.Data.Code != "not_found" { - t.Fatalf("error = %d/%q, want 1001/not_found", e.Code, e.Data.Code) + if rpcErr.Code != rpc.CodeNotFound || rpcErr.Data.Code != "not_found" { + t.Fatalf("error = %d/%q, want 1001/not_found", rpcErr.Code, rpcErr.Data.Code) } - if e.Data.Hint == "" { + if rpcErr.Data.Hint == "" { t.Error("hint is empty; the contract asks for an actionable one") } } func TestGroupsKillRequiresAName(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + _, cl := killHarness(t) - if e := c.call("groups.kill", rpc.GroupsKillParams{Name: " "}, nil); e == nil || - e.Code != rpc.CodeInvalidParams { - t.Fatalf("error = %v, want invalid_params", e) + rpcErr := cl.call("groups.kill", rpc.GroupsKillParams{Name: " "}, nil) + if rpcErr == nil || rpcErr.Code != rpc.CodeInvalidParams { + t.Fatalf("error = %v, want invalid_params", rpcErr) } } @@ -325,40 +337,36 @@ services: // `down` stops the whole group — nothing in the manifest is exempt. func TestDownStopsEveryDeclaredService(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - dir := resolvedDir(t, t.TempDir()) writeConfig(t, dir, downFixture) - h := newHarness(t, ctx) - c := h.dial(ctx) + harness, cl := killHarness(t) listener := func(port int) ports.ListeningPort { return ports.ListeningPort{ Port: port, BindAddress: "127.0.0.1", PID: 53000 + port, Process: "listener", Cwd: dir, } } - h.setRows(listener(53306), listener(8000)) - if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { + harness.setRows(listener(53306), listener(8000)) + if _, err := harness.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming the scanner: %v", err) } - var env rpc.KillEnvelope - if e := c.call("groups.kill", rpc.GroupsKillParams{Name: "demo", Release: true, DryRun: true}, &env); e != nil { - t.Fatalf("groups.kill: %v", e) + var envelope rpc.KillEnvelope + if rpcErr := cl.call("groups.kill", rpc.GroupsKillParams{Name: "demo", Release: true, DryRun: true}, &envelope); rpcErr != nil { + t.Fatalf("groups.kill: %v", rpcErr) } - got := map[int]bool{} - for _, r := range env.Results { - got[r.Port] = true + resolved := map[int]bool{} + for _, row := range envelope.Results { + resolved[row.Port] = true } - if len(got) != 2 || !got[8000] || !got[53306] { - t.Fatalf("down resolved %v, want both ports the file declares", got) + if len(resolved) != 2 || !resolved[8000] || !resolved[53306] { + t.Fatalf("down resolved %v, want both ports the file declares", resolved) } } func TestKillCapabilityIsAdvertised(t *testing.T) { - for _, c := range Capabilities() { - if c == "ports.kill" { + for _, capability := range Capabilities() { + if capability == "ports.kill" { return } } @@ -368,12 +376,12 @@ func TestKillCapabilityIsAdvertised(t *testing.T) { // freeTestPort reserves a port and releases it, so nothing is listening on it. func freeTestPort(t *testing.T) int { t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") + reservation, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserving a port: %v", err) } - defer ln.Close() - return ln.Addr().(*net.TCPAddr).Port + defer reservation.Close() + return reservation.Addr().(*net.TCPAddr).Port } // TestKillRepublishesSoTheNextReadIsFresh: a kill drops the cached scan, so the @@ -381,29 +389,27 @@ func freeTestPort(t *testing.T) int { // and the port_down row reaches the history ring with nobody subscribed // . func TestKillRepublishesSoTheNextReadIsFresh(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + harness, cl := killHarness(t) - h.setRows(ports.ListeningPort{Port: 4321, BindAddress: "127.0.0.1", PID: 999, Process: "node"}) - if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { + harness.setRows(ports.ListeningPort{Port: 4321, BindAddress: "127.0.0.1", PID: 999, Process: "node"}) + if _, err := harness.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming: %v", err) } - before := h.loop.Status().Scans + before := harness.loop.Status().Scans // The kill itself fails (there is no pid 999), which is beside the point: // the handler still has to leave the cache invalidated. - h.setRows() - var env rpc.KillEnvelope - if e := c.call("ports.kill", rpc.PortsKillParams{Targets: []rpc.Selector{{Port: ptr(4321)}}}, &env); e != nil { - t.Fatalf("ports.kill: %v", e) + harness.setRows() + var envelope rpc.KillEnvelope + if rpcErr := cl.call("ports.kill", + rpc.PortsKillParams{Targets: []rpc.Selector{{Port: ptr(4321)}}}, &envelope); rpcErr != nil { + t.Fatalf("ports.kill: %v", rpcErr) } - if after := h.loop.Status().Scans; after <= before { - t.Fatalf("scans = %d, want a rescan after the kill (was %d)", after, before) + if scans := harness.loop.Status().Scans; scans <= before { + t.Fatalf("scans = %d, want a rescan after the kill (was %d)", scans, before) } - if snap := h.loop.Cached(); len(snap.Ports) != 0 { - t.Errorf("the cached snapshot still carries %d ports after the kill", len(snap.Ports)) + if cached := harness.loop.Cached(); len(cached.Ports) != 0 { + t.Errorf("the cached snapshot still carries %d ports after the kill", len(cached.Ports)) } } @@ -412,12 +418,9 @@ func TestKillRepublishesSoTheNextReadIsFresh(t *testing.T) { // well under CacheTTL, and resolving `oberth kill -g` against the cached // snapshot killed only the ones the last tick happened to see. func TestGroupsKillResolvesAgainstAFreshScan(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + harness, cl := killHarness(t) // Ownership comes from the installed registry, not untrusted OS rows. - h.srv.runtime.SetRuns(&fakeRuns{live: []sessions.Live{ + harness.srv.runtime.SetRuns(&fakeRuns{live: []sessions.Live{ {RunID: "run-1", PID: 900 + 4331, Group: "demo", Name: "svc-1"}, {RunID: "run-2", PID: 900 + 4332, Group: "demo", Name: "svc-2"}, }}) @@ -428,51 +431,47 @@ func TestGroupsKillResolvesAgainstAFreshScan(t *testing.T) { } } - h.setRows(inDemo(4331)) - if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { + harness.setRows(inDemo(4331)) + if _, err := harness.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming: %v", err) } // A second service of the same group comes up right after that scan. - h.setRows(inDemo(4331), inDemo(4332)) + harness.setRows(inDemo(4331), inDemo(4332)) - var env rpc.KillEnvelope - if e := c.call("groups.kill", rpc.GroupsKillParams{Name: "demo", DryRun: true}, &env); e != nil { - t.Fatalf("groups.kill: %v", e) + var envelope rpc.KillEnvelope + if rpcErr := cl.call("groups.kill", rpc.GroupsKillParams{Name: "demo", DryRun: true}, &envelope); rpcErr != nil { + t.Fatalf("groups.kill: %v", rpcErr) } - if len(env.Results) != 2 { - t.Fatalf("results = %+v, want a row for each of the group's two ports", env.Results) + if len(envelope.Results) != 2 { + t.Fatalf("results = %+v, want a row for each of the group's two ports", envelope.Results) } seen := map[int]bool{} - for _, r := range env.Results { - seen[r.Port] = true + for _, row := range envelope.Results { + seen[row.Port] = true } if !seen[4331] || !seen[4332] { t.Fatalf("killed ports %v, want both 4331 and 4332", seen) } } -// TestDryRunKillDoesNotRepublish: resolving the targets costs one scan, and a -// dry run stops there — nothing changed, so nothing needs republishing. +// TestDryRunKillDoesNotRepublish: resolving the targets costs one scan and a +// dry run stops right there — nothing changed, so nothing needs republishing. func TestDryRunKillDoesNotRepublish(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - h := newHarness(t, ctx) - c := h.dial(ctx) + harness, cl := killHarness(t) - h.setRows(ports.ListeningPort{Port: 4322, BindAddress: "127.0.0.1", PID: 998, Process: "node"}) - if _, err := h.loop.Snapshot(scanner.Include{}); err != nil { + harness.setRows(ports.ListeningPort{Port: 4322, BindAddress: "127.0.0.1", PID: 998, Process: "node"}) + if _, err := harness.loop.Snapshot(scanner.Include{}); err != nil { t.Fatalf("priming: %v", err) } - before := h.loop.Status().Scans + before := harness.loop.Status().Scans - var env rpc.KillEnvelope - if e := c.call("ports.kill", rpc.PortsKillParams{ - Targets: []rpc.Selector{{Port: ptr(4322)}}, DryRun: true, - }, &env); e != nil { - t.Fatalf("ports.kill: %v", e) + var envelope rpc.KillEnvelope + params := rpc.PortsKillParams{Targets: []rpc.Selector{{Port: ptr(4322)}}, DryRun: true} + if rpcErr := cl.call("ports.kill", params, &envelope); rpcErr != nil { + t.Fatalf("ports.kill: %v", rpcErr) } - if after := h.loop.Status().Scans; after != before+1 { - t.Errorf("scans = %d, want exactly one (target resolution) more than %d", after, before) + if scans := harness.loop.Status().Scans; scans != before+1 { + t.Errorf("scans = %d, want exactly one (target resolution) more than %d", scans, before) } } diff --git a/engine/internal/daemon/runs_integration_test.go b/engine/internal/daemon/runs_integration_test.go index 97492225..0a50bbb1 100644 --- a/engine/internal/daemon/runs_integration_test.go +++ b/engine/internal/daemon/runs_integration_test.go @@ -1,7 +1,8 @@ //go:build integration -// Integration coverage for `oberth start` and the daemon's runs registry: spec -// integration test 2. Run with `go test -tags integration ./internal/daemon/...`. +// Integration coverage for `oberth start` and the daemon's runs registry, which +// is spec integration test 2. Run them with +// `go test -tags integration ./internal/daemon/...`. package daemon_test import ( @@ -22,15 +23,15 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// buildListener compiles a helper that listens on the port it is given and -// blocks. It is a real process with a real socket, so the daemon's own scan has -// to find it and attribute it through the PPID chain. +// buildListener compiles a helper that listens on the port it is handed and +// then blocks. Behind it are a real process and a real socket, so the daemon's +// own scan has to find it and attribute the port through the PPID chain. var buildListener = sync.OnceValues(func() (string, error) { dir, err := os.MkdirTemp("", "option-berth-itest-listener") if err != nil { return "", err } - src := filepath.Join(dir, "main.go") + source := filepath.Join(dir, "main.go") program := `package main import ( @@ -41,141 +42,139 @@ import ( ) func main() { - ln, err := net.Listen("tcp", "127.0.0.1:"+os.Args[1]) + sock, err := net.Listen("tcp", "127.0.0.1:"+os.Args[1]) if err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, "listen:", err) os.Exit(1) } - defer ln.Close() - fmt.Println("listening on", os.Args[1]) + defer sock.Close() + fmt.Println("helper listening on", os.Args[1]) for { - time.Sleep(time.Second) + time.Sleep(500 * time.Millisecond) } } ` - if err := os.WriteFile(src, []byte(program), 0o644); err != nil { + if err := os.WriteFile(source, []byte(program), 0o644); err != nil { return "", err } - bin := filepath.Join(dir, "listener") + binary := filepath.Join(dir, "listener") if runtime.GOOS == "windows" { - bin += ".exe" + binary += ".exe" } - cmd := exec.Command("go", "build", "-o", bin, src) - if out, err := cmd.CombinedOutput(); err != nil { + build := exec.Command("go", "build", "-o", binary, source) + if out, err := build.CombinedOutput(); err != nil { return "", fmt.Errorf("building the listener helper: %v: %s", err, out) } - return bin, nil + return binary, nil }) -// freePort returns a port nothing is listening on right now. +// freePort hands back a port nothing is listening on right now. func freePort(t *testing.T) int { t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") + reservation, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - t.Fatal(err) + t.Fatalf("reserving a port: %v", err) } - port := ln.Addr().(*net.TCPAddr).Port - ln.Close() - return port + defer reservation.Close() + return reservation.Addr().(*net.TCPAddr).Port } // TestStartAttributesItsPortToTheGroup is spec integration test 2: a helper -// started through `oberth start --group itest --name web` shows up in a delta +// started through `oberth start --group itest --name web` turns up in a delta // carrying the group, the source and the name the run gave it. func TestStartAttributesItsPortToTheGroup(t *testing.T) { - listener, err := buildListener() + helper, err := buildListener() if err != nil { t.Fatal(err) } - e := newEnv(t) - e.serve() + sandbox := newEnv(t) + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) - sub, err := c.Subscribe(ctx, client.SubscribeOptions{Events: true}) + sub, err := cl.Subscribe(ctx, client.SubscribeOptions{Events: true}) if err != nil { t.Fatalf("state.subscribe: %v", err) } port := freePort(t) - start := e.command("start", "--group", "itest", "--name", "web", "--port", strconv.Itoa(port), - "--", listener, strconv.Itoa(port)) - start.Dir = e.home - var out safeBuffer - start.Stdout, start.Stderr = &out, &out - ownProcessGroup(start) - if err := start.Start(); err != nil { + starter := sandbox.command("start", "--group", "itest", "--name", "web", "--port", strconv.Itoa(port), + "--", helper, strconv.Itoa(port)) + starter.Dir = sandbox.home + var log safeBuffer + starter.Stdout, starter.Stderr = &log, &log + ownProcessGroup(starter) + if err := starter.Start(); err != nil { t.Fatalf("oberth start: %v", err) } t.Cleanup(func() { - // The listener is in a process group of its own and holds the pipe - // `out` reads from, so the whole tree has to go before Wait can - // return. - stopCommand(start) + // The listener sits in a process group of its own and holds the pipe + // `log` reads from, so the whole tree has to go before Wait can return. + stopCommand(starter) if t.Failed() { - t.Logf("oberth start output:\n%s", out.String()) + t.Logf("oberth start output:\n%s", log.String()) } }) - added, ok := waitForAttributedPort(t, sub, port, 45*time.Second) + row, ok := waitForAttributedPort(t, sub, port, 45*time.Second) if !ok { - t.Fatalf("no state.delta carried port %d with its run within 45s\n%s", port, out.String()) + t.Fatalf("no state.delta carried port %d with its run within 45s\n%s", port, log.String()) } - if added.Group == nil || *added.Group != "itest" { - t.Errorf("group = %v, want itest", added.Group) + if row.Group == nil || *row.Group != "itest" { + t.Errorf("group = %v, want itest", row.Group) } - if added.GroupSource == nil || string(*added.GroupSource) != "start" { - t.Errorf("group_source = %v, want start", added.GroupSource) + if row.GroupSource == nil || string(*row.GroupSource) != "start" { + t.Errorf("group_source = %v, want start", row.GroupSource) } - if added.DisplayName != "web" { - t.Errorf("display_name = %q, want web", added.DisplayName) + if row.DisplayName != "web" { + t.Errorf("display_name = %q, want web", row.DisplayName) } - if added.Run == nil { + if row.Run == nil { t.Fatalf("run is null; the port was not attributed to the run") } - if added.Run.Name != "web" || added.Run.Group != "itest" { - t.Errorf("run = %+v, want group itest name web", *added.Run) + if row.Run.Name != "web" || row.Run.Group != "itest" { + t.Errorf("run = %+v, want group itest name web", *row.Run) } - if added.Run.RootPID <= 0 { - t.Errorf("run.root_pid = %d, want the `oberth start` pid", added.Run.RootPID) + if row.Run.RootPID <= 0 { + t.Errorf("run.root_pid = %d, want the `oberth start` pid", row.Run.RootPID) } - // The same run through runs.list, with the port it ended up binding. - var list rpc.RunsListResult - if err := c.Call(ctx, "runs.list", rpc.Empty{}, &list); err != nil { + // The same run seen through runs.list, with the port it ended up binding. + var runs rpc.RunsListResult + if err := cl.Call(ctx, "runs.list", rpc.Empty{}, &runs); err != nil { t.Fatalf("runs.list: %v", err) } found := false - for _, r := range list.Runs { - if r.Group != "itest" || r.Name != "web" { + for _, run := range runs.Runs { + if run.Group != "itest" || run.Name != "web" { continue } found = true - if r.Status != "running" { - t.Errorf("status = %q, want running once the port is bound", r.Status) + if run.Status != "running" { + t.Errorf("status = %q, want running once the port is bound", run.Status) } - if r.PortHint == nil || *r.PortHint != port { - t.Errorf("port_hint = %v, want %d", r.PortHint, port) + if run.PortHint == nil || *run.PortHint != port { + t.Errorf("port_hint = %v, want %d", run.PortHint, port) } - if len(r.Ports) != 1 || r.Ports[0] != port { - t.Errorf("ports = %v, want [%d]", r.Ports, port) + if len(run.Ports) != 1 || run.Ports[0] != port { + t.Errorf("ports = %v, want [%d]", run.Ports, port) } } if !found { - t.Fatalf("runs.list did not carry the run: %+v", list.Runs) + t.Fatalf("runs.list did not carry the run: %+v", runs.Runs) } // Interrupting `oberth start` takes the listener down with it, and the run // leaves the registry. - interrupt(t, start) + interrupt(t, starter) if !waitForRemoved(t, sub, port, 45*time.Second) { t.Fatalf("port %d never went away after interrupting oberth start", port) } - waitForEmptyRunsList(t, ctx, c, 20*time.Second) + waitForEmptyRunsList(t, ctx, cl, 20*time.Second) } // TestExitedRunSurvivesDaemonRestart is the durable half of the runs registry: @@ -183,17 +182,17 @@ func TestStartAttributesItsPortToTheGroup(t *testing.T) { // runs.list after that daemon has been stopped and a new one has opened the // same store. func TestExitedRunSurvivesDaemonRestart(t *testing.T) { - e := newEnv(t) - first := e.serve() + sandbox := newEnv(t) + first := sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - c := e.connect(ctx) + cl := sandbox.connect(ctx) var spawned rpc.RunsSpawnResult - if err := c.Call(ctx, "runs.spawn", rpc.RunsSpawnParams{ + if err := cl.Call(ctx, "runs.spawn", rpc.RunsSpawnParams{ Argv: []string{"sh", "-c", "echo durable-exit; exit 7"}, - Cwd: e.home, + Cwd: sandbox.home, Group: stringPtr("durability"), Name: stringPtr("job"), }, &spawned); err != nil { @@ -204,9 +203,9 @@ func TestExitedRunSurvivesDaemonRestart(t *testing.T) { } var before rpc.RunsListResult - deadline := time.Now().Add(20 * time.Second) - for time.Now().Before(deadline) { - if err := c.Call(ctx, "runs.list", rpc.Empty{}, &before); err != nil { + limit := time.Now().Add(20 * time.Second) + for time.Now().Before(limit) { + if err := cl.Call(ctx, "runs.list", rpc.Empty{}, &before); err != nil { t.Fatalf("runs.list before restart: %v", err) } if hasExitedRun(before.Exited, spawned.RunID) { @@ -218,37 +217,37 @@ func TestExitedRunSurvivesDaemonRestart(t *testing.T) { t.Fatalf("run %s never appeared in exited list before restart: %+v", spawned.RunID, before) } - e.stopDaemon(first.Process.Pid) - second := e.serve() + sandbox.stopDaemon(first.Process.Pid) + second := sandbox.serve() defer stopCommand(second) - c2 := e.connect(ctx) + reconnected := sandbox.connect(ctx) var after rpc.RunsListResult - if err := c2.Call(ctx, "runs.list", rpc.Empty{}, &after); err != nil { + if err := reconnected.Call(ctx, "runs.list", rpc.Empty{}, &after); err != nil { t.Fatalf("runs.list after restart: %v", err) } - var found *rpc.RunRecord + var reloaded *rpc.RunRecord for i := range after.Exited { if after.Exited[i].ID == spawned.RunID { - found = &after.Exited[i] + reloaded = &after.Exited[i] break } } - if found == nil { + if reloaded == nil { t.Fatalf("run %s disappeared after daemon restart: %+v", spawned.RunID, after.Exited) } - if found.ExitCode == nil || *found.ExitCode != 7 || found.Reason != "crashed" { - t.Fatalf("reloaded exit = %+v, want code 7/crashed", *found) + if reloaded.ExitCode == nil || *reloaded.ExitCode != 7 || reloaded.Reason != "crashed" { + t.Fatalf("reloaded exit = %+v, want code 7/crashed", *reloaded) } - if len(found.LastLines) == 0 || found.LastLines[len(found.LastLines)-1] != "durable-exit" { - t.Fatalf("reloaded log tail = %v, want durable-exit", found.LastLines) + if tail := reloaded.LastLines; len(tail) == 0 || tail[len(tail)-1] != "durable-exit" { + t.Fatalf("reloaded log tail = %v, want durable-exit", tail) } } func stringPtr(s string) *string { return &s } -func hasExitedRun(rows []rpc.RunRecord, id string) bool { - for _, row := range rows { - if row.ID == id { +func hasExitedRun(exited []rpc.RunRecord, want string) bool { + for _, record := range exited { + if record.ID == want { return true } } @@ -256,18 +255,18 @@ func hasExitedRun(rows []rpc.RunRecord, id string) bool { } // interrupt asks a process to stop the way a terminal's Ctrl+C would. -func interrupt(t *testing.T, cmd *exec.Cmd) { +func interrupt(t *testing.T, starter *exec.Cmd) { t.Helper() - if cmd.Process == nil { + if starter.Process == nil { return } if runtime.GOOS == "windows" { - // Windows has no SIGINT to send from another console; killing the - // starter is what the Job Object turns into a tree kill. - _ = cmd.Process.Kill() + // There is no SIGINT to send from another console on Windows; killing + // the starter is what the Job Object turns into a tree kill. + _ = starter.Process.Kill() return } - if err := cmd.Process.Signal(os.Interrupt); err != nil { + if err := starter.Process.Signal(os.Interrupt); err != nil { t.Fatalf("interrupting oberth start: %v", err) } } @@ -275,72 +274,71 @@ func interrupt(t *testing.T, cmd *exec.Cmd) { // waitForAttributedPort is waitForAdded, but it hands back the row so the test // can assert on the group and the run the resolver put on it. // -// It waits for the delta that carries the run rather than the first delta that -// mentions the port. The two are usually the same one, but they need not be: -// the child binds its port before `oberth start` has had a chance to tell the -// daemon who owns it, and a scan tick landing in that window publishes a real +// It waits for the delta that carries the run, not the first delta that +// mentions the port: a scan tick can land between the child's bind and +// `oberth start` telling the daemon who owns it, and that tick publishes a real // listener that genuinely has no run yet. The daemon corrects it in the very // next delta, which is the row this test is about. func waitForAttributedPort(t *testing.T, sub *client.Subscription, port int, timeout time.Duration) (state.Port, bool) { t.Helper() - deadline := time.After(timeout) + expired := time.After(timeout) for { select { - case d, ok := <-sub.Deltas: - if !ok { + case delta, open := <-sub.Deltas: + if !open { return state.Port{}, false } - for _, rows := range [][]state.Port{d.Ports.Added, d.Ports.Updated} { - for _, p := range rows { - if p.Port != port { + for _, rows := range [][]state.Port{delta.Ports.Added, delta.Ports.Updated} { + for _, row := range rows { + if row.Port != port { continue } - if p.Run != nil { - return p, true + if row.Run != nil { + return row, true } t.Logf("delta seq %d has port %d before its run was registered (group %v)", - d.Seq, port, p.Group) + delta.Seq, port, row.Group) } } - case <-deadline: + case <-expired: return state.Port{}, false } } } // waitForEmptyRunsList polls until the registry has pruned the finished run. -func waitForEmptyRunsList(t *testing.T, ctx context.Context, c *client.Client, timeout time.Duration) { +func waitForEmptyRunsList(t *testing.T, ctx context.Context, cl *client.Client, timeout time.Duration) { t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - var list rpc.RunsListResult - if err := c.Call(ctx, "runs.list", rpc.Empty{}, &list); err != nil { + limit := time.Now().Add(timeout) + for time.Now().Before(limit) { + var runs rpc.RunsListResult + if err := cl.Call(ctx, "runs.list", rpc.Empty{}, &runs); err != nil { t.Fatalf("runs.list: %v", err) } - if len(list.Runs) == 0 { + if len(runs.Runs) == 0 { return } time.Sleep(250 * time.Millisecond) } - t.Fatal("the run never left runs.list") + t.Fatalf("the finished run was still in runs.list after %s", timeout) } -// safeBuffer is a strings.Builder that survives concurrent writes from a -// command's stdout and stderr. +// safeBuffer is a strings.Builder that survives the concurrent writes a +// command's stdout and stderr make. type safeBuffer struct { - mu sync.Mutex - b []byte + mu sync.Mutex + text []byte } func (s *safeBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() - s.b = append(s.b, p...) + s.text = append(s.text, p...) return len(p), nil } func (s *safeBuffer) String() string { s.mu.Lock() defer s.mu.Unlock() - return string(s.b) + return string(s.text) } diff --git a/engine/internal/daemon/up_integration_test.go b/engine/internal/daemon/up_integration_test.go index f2ac88a3..5a883280 100644 --- a/engine/internal/daemon/up_integration_test.go +++ b/engine/internal/daemon/up_integration_test.go @@ -1,8 +1,8 @@ //go:build integration -// Spec integration test 4, in the current worktree-service shape: `oberth up` -// starts a three-service group in depends_on order and `oberth down` stops all -// three. Run with +// Spec integration test 4, in the worktree-service shape it has today: `oberth +// up` starts a three-service group in depends_on order and `oberth down` stops +// all three. Run with // `go test -tags integration ./internal/daemon/...`. package daemon_test @@ -26,58 +26,72 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// TestUpStartsAGroupInOrderAndDownStopsIt is the step's acceptance path end to -// end, through the real binary: a config with db, api and frontend, one of them +// upSummary is the `oberth up --json` document: what started, what was already +// running, and what failed. +type upSummary struct { + Services []rpc.GroupsStartChunk `json:"services"` + Started []string `json:"started"` + Skipped []string `json:"skipped"` + Errors []string `json:"errors"` +} + +// TestUpStartsAGroupInOrderAndDownStopsIt is the acceptance path end to end +// through the real binary: a config with db, api and frontend, one of them // waiting on another, started detached and then stopped as a worktree. func TestUpStartsAGroupInOrderAndDownStopsIt(t *testing.T) { - listener, buildErr := buildListener() + helper, buildErr := buildListener() if buildErr != nil { t.Fatal(buildErr) } - e := newEnv(t) + sandbox := newEnv(t) // The project lives inside the temp HOME: the daemon refuses to start a // command outside the user's home unless it is asked to. - project := filepath.Join(e.home, "demo") + project := filepath.Join(sandbox.home, "demo") if err := os.MkdirAll(project, 0o755); err != nil { - t.Fatal(err) + t.Fatalf("making the project directory: %v", err) } - dbPort, apiPort, webPort := unusedPort(t), unusedPort(t), unusedPort(t) - config := fmt.Sprintf(`# the demo project + dbPort := unusedPort(t) + apiPort := unusedPort(t) + webPort := unusedPort(t) + config := fmt.Sprintf(`# a demo project whose api depends on its db name: demo services: + # the database, and the service api waits on - name: db cmd: '%s %d' port: %d + # the api, which may only start once db is listening - name: api cmd: '%s %d' port: %d depends_on: [db] + # a service nothing depends on - name: frontend cmd: '%s %d' port: %d -`, listener, dbPort, dbPort, listener, apiPort, apiPort, listener, webPort, webPort) +`, helper, dbPort, dbPort, helper, apiPort, apiPort, helper, webPort, webPort) configPath := filepath.Join(project, groups.ConfigName) if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { t.Fatal(err) } - e.serve() + sandbox.serve() ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() - // A subscriber keeps the daemon's scan loop running: with nobody - // listening it parks, and the down events below are published from deltas. - c := e.connect(ctx) - sub, err := c.Subscribe(ctx, client.SubscribeOptions{Events: true, Buffer: 512}) + // A subscriber keeps the daemon's scan loop running: with nobody listening + // it parks, and the down events below are published from deltas. + cl := sandbox.connect(ctx) + sub, err := cl.Subscribe(ctx, client.SubscribeOptions{Events: true, Buffer: 512}) if err != nil { t.Fatalf("state.subscribe: %v", err) } // `oberth up` from inside the project: no argument, so the group comes from // the cwd's oberth.yaml. - up := e.command("up", "--json") + up := sandbox.command("up", "--json") up.Dir = project upOut, upErr, err := runSplit(up) if err != nil { @@ -85,12 +99,7 @@ services: } t.Logf("oberth up --json:\n%s\nstderr:\n%s", upOut, upErr) - var summary struct { - Services []rpc.GroupsStartChunk `json:"services"` - Started []string `json:"started"` - Skipped []string `json:"skipped"` - Errors []string `json:"errors"` - } + var summary upSummary if err := json.Unmarshal(upOut, &summary); err != nil { t.Fatalf("decoding oberth up --json: %v\n%s", err, upOut) } @@ -104,20 +113,19 @@ services: if len(summary.Started) != 3 { t.Fatalf("started = %v, want all three services", summary.Started) } - if summary.Services[0].Service != "db" { - t.Errorf("the first service started was %q, want db (nothing depends_on it)", - summary.Services[0].Service) + if first := summary.Services[0].Service; first != "db" { + t.Errorf("the first service started was %q, want db (nothing depends_on it)", first) } var apiChunk rpc.GroupsStartChunk - for _, c := range summary.Services { - if c.Service == "api" { - apiChunk = c + for _, chunk := range summary.Services { + if chunk.Service == "api" { + apiChunk = chunk } - if c.PID <= 0 || c.LogPath == "" { - t.Errorf("chunk %+v carries no pid or log path", c) + if chunk.PID <= 0 || chunk.LogPath == "" { + t.Errorf("chunk %+v carries no pid or log path", chunk) } - if !strings.Contains(c.LogPath, filepath.Join("logs", "demo", c.Service+".log")) { - t.Errorf("log path %q is not ~/.option-berth/logs/demo/%s.log", c.LogPath, c.Service) + if !strings.Contains(chunk.LogPath, filepath.Join("logs", "demo", chunk.Service+".log")) { + t.Errorf("log path %q is not ~/.option-berth/logs/demo/%s.log", chunk.LogPath, chunk.Service) } } if apiChunk.PID == 0 { @@ -126,19 +134,16 @@ services: // api only starts once db is listening; seeing all three ports in the // daemon snapshot confirms the dependency order reached its running state. - waitForPorts(t, ctx, c, []int{dbPort, apiPort, webPort}, 45*time.Second) + waitForPorts(t, ctx, cl, []int{dbPort, apiPort, webPort}, 45*time.Second) // Running `up` again is a no-op: everything is already running. - again := e.command("up", "--json") + again := sandbox.command("up", "--json") again.Dir = project againOut, againErr, err := runSplit(again) if err != nil { t.Fatalf("second oberth up: %v\nstdout:\n%s\nstderr:\n%s", err, againOut, againErr) } - var second struct { - Skipped []string `json:"skipped"` - Started []string `json:"started"` - } + var second upSummary if err := json.Unmarshal(againOut, &second); err != nil { t.Fatalf("decoding the second oberth up: %v\n%s", err, againOut) } @@ -148,7 +153,7 @@ services: } // `oberth down` stops all three services declared by this worktree. - down := e.command("down", "--json") + down := sandbox.command("down", "--json") down.Dir = project downOut, downErr, err := runSplit(down) if err != nil { @@ -166,27 +171,27 @@ services: waitForDownEvents(t, sub, []int{dbPort, apiPort, webPort}, 45*time.Second) } -// waitForDownEvents drains the subscription until every port has been reported -// as gone. +// waitForDownEvents drains the subscription until the daemon has reported every +// port as gone. func waitForDownEvents(t *testing.T, sub *client.Subscription, want []int, timeout time.Duration) { t.Helper() - pending := map[int]bool{} - for _, p := range want { - pending[p] = true + pending := make(map[int]bool, len(want)) + for _, port := range want { + pending[port] = true } - deadline := time.After(timeout) + expired := time.After(timeout) for len(pending) > 0 { select { - case ev, ok := <-sub.Events: - if !ok { + case event, open := <-sub.Events: + if !open { t.Fatal("the subscription closed before the ports went down") } - if ev.Kind == "port_down" && ev.Port != nil { - delete(pending, ev.Port.Port) + if event.Kind == "port_down" && event.Port != nil { + delete(pending, event.Port.Port) } case <-sub.Deltas: // Deltas have to be drained or the subscription is dropped. - case <-deadline: + case <-expired: t.Fatalf("no port_down event for %v within %s", keys(pending), timeout) } } @@ -194,36 +199,36 @@ func waitForDownEvents(t *testing.T, sub *client.Subscription, want []int, timeo func keys(m map[int]bool) []int { out := make([]int, 0, len(m)) - for k := range m { - out = append(out, k) + for port := range m { + out = append(out, port) } return out } -// runSplit runs cmd with stdout and stderr captured separately. `up --json` -// writes in-flight progress to stderr, so only stdout is a JSON document; -// merging the streams would corrupt it. +// runSplit runs cmd with stdout and stderr captured apart. `up --json` writes +// in-flight progress to stderr, so only stdout is a JSON document; merging the +// two streams would corrupt it. func runSplit(cmd *exec.Cmd) (stdout, stderr []byte, err error) { - var out, errOut bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errOut + var out, problems bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &problems err = cmd.Run() - return out.Bytes(), errOut.Bytes(), err + return out.Bytes(), problems.Bytes(), err } -func waitForPorts(t *testing.T, ctx context.Context, c *client.Client, want []int, timeout time.Duration) { +func waitForPorts(t *testing.T, ctx context.Context, cl *client.Client, want []int, timeout time.Duration) { t.Helper() pending := map[int]bool{} for _, port := range want { pending[port] = true } - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - var snap state.Snapshot - if err := c.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snap); err != nil { + limit := time.Now().Add(timeout) + for time.Now().Before(limit) { + var snapshot state.Snapshot + if err := cl.Call(ctx, "state.snapshot", rpc.StateSnapshotParams{}, &snapshot); err != nil { t.Fatalf("state.snapshot: %v", err) } - for _, p := range snap.Ports { - delete(pending, p.Port) + for _, row := range snapshot.Ports { + delete(pending, row.Port) } if len(pending) == 0 { return @@ -239,13 +244,13 @@ func waitForPorts(t *testing.T, ctx context.Context, c *client.Client, want []in // `oberth up` skip everything as "already running". func unusedPort(t *testing.T) int { t.Helper() - seen := map[int]bool{} - for i := 0; i < 50; i++ { + tried := map[int]bool{} + for attempt := 0; attempt < 50; attempt++ { port := freePort(t) - if seen[port] || portOpen(port) { + if tried[port] || portOpen(port) { continue } - seen[port] = true + tried[port] = true return port } t.Fatal("could not find a port nothing is listening on") @@ -257,18 +262,20 @@ func portOpen(port int) bool { if err != nil { return false } - conn.Close() + defer conn.Close() return true } // killChunks stops anything a failed run left behind. func killChunks(chunks []rpc.GroupsStartChunk) { - for _, c := range chunks { - if c.PID <= 0 { + for _, chunk := range chunks { + if chunk.PID <= 0 { continue } - if p, err := os.FindProcess(c.PID); err == nil { - _ = p.Kill() + process, err := os.FindProcess(chunk.PID) + if err != nil { + continue } + _ = process.Kill() } } From fad4dbb21b84a5ca9367e268309669b91319f215 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 17:39:52 +0800 Subject: [PATCH 6/7] =?UTF-8?q?test(engine):=20daemon=20=E5=AD=90=E5=8C=85?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=8C=89=E8=A1=8C=E4=B8=BA=E9=87=8D=E8=BF=B0?= =?UTF-8?q?=EF=BC=8Cschema=20=E6=AF=94=E5=AF=B9=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rpc、runsreg、groupstart 的测试重写:schema 断言收成读取辅助函数,逐字节 比对仍是「磁盘文件 == Marshal()」;注册、退出、会话与地址分配的用例换 等价夹具并同步断言。测试函数数与改动前一致,未改 schema 文件本身。 审计输出归零:对照 26dbcae 已无 5 行以上的复制段。 Co-Authored-By: Claude Code --- .../internal/daemon/groupstart/start_test.go | 95 +++--- engine/internal/daemon/rpc/codec_test.go | 121 +++++--- engine/internal/daemon/rpc/schema_test.go | 278 ++++++++++-------- engine/internal/daemon/runsreg/exits_test.go | 155 +++++----- .../internal/daemon/runsreg/registry_test.go | 240 ++++++++------- engine/internal/daemon/runsreg/rename_test.go | 39 ++- .../internal/daemon/runsreg/session_test.go | 83 +++--- 7 files changed, 579 insertions(+), 432 deletions(-) diff --git a/engine/internal/daemon/groupstart/start_test.go b/engine/internal/daemon/groupstart/start_test.go index 223103ce..b99f4051 100644 --- a/engine/internal/daemon/groupstart/start_test.go +++ b/engine/internal/daemon/groupstart/start_test.go @@ -91,12 +91,13 @@ func TestStartsInDependencyOrderAndWaits(t *testing.T) { cmd := h.service() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: api - cmd: %s + - cmd: %s + name: api port: %d - depends_on: [db] - - name: db - cmd: %s + depends_on: + - db + - cmd: %s + name: db port: %d ready_timeout: 45s `, cmd, apiPort, cmd, dbPort)) @@ -257,15 +258,16 @@ func TestAutoPortsAreAssignedAndReferenced(t *testing.T) { cmd := h.service() path := h.manifest(fmt.Sprintf(`name: autotest services: - - name: api - cmd: %s + - cmd: %s + name: api port: auto - depends_on: [db] + depends_on: + - db env: - BERTH_TEST_ECHO: DB_URL,FROM_CALLER,PORT DB_URL: postgres://localhost:${db.port}/app - - name: db - cmd: %s + BERTH_TEST_ECHO: DB_URL,FROM_CALLER,PORT + - cmd: %s + name: db port: auto `, cmd, cmd)) @@ -387,8 +389,8 @@ func TestSkipsServicesThatAreAlreadyRunning(t *testing.T) { taken, _ := h.occupiedPort() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: web - cmd: %s + - cmd: %s + name: web port: %d `, h.service(), taken)) @@ -420,13 +422,14 @@ func TestReportsADependencyThatExits(t *testing.T) { deadPort, livePort := h.freePort(), h.freePort() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: ghost - cmd: %s + - cmd: %s + name: ghost port: %d - - name: api - cmd: %s + - cmd: %s + name: api port: %d - depends_on: [ghost] + depends_on: + - ghost `, exitsImmediately(t), deadPort, h.service(), livePort)) chunks, end := h.startChunks(rpc.GroupsStartParams{ConfigPath: &path}) @@ -458,13 +461,14 @@ func TestReportsATrueDependencyTimeout(t *testing.T) { deadPort, livePort := h.freePort(), h.freePort() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: ghost - cmd: sleep 30 + - cmd: sleep 30 + name: ghost port: %d - - name: api - cmd: %s + - cmd: %s + name: api port: %d - depends_on: [ghost] + depends_on: + - ghost `, deadPort, h.service(), livePort)) chunks, end := h.startChunks(rpc.GroupsStartParams{ConfigPath: &path}) @@ -502,12 +506,13 @@ func TestReportsNoPortDependencyTimeout(t *testing.T) { port := h.freePort() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: worker - cmd: %s - - name: api - cmd: %s + - cmd: %s + name: worker + - cmd: %s + name: api port: %d - depends_on: [worker] + depends_on: + - worker `, exitsImmediately(t), h.service(), port)) chunks, end := h.startChunks(rpc.GroupsStartParams{ @@ -559,11 +564,11 @@ func TestOnlyFiltersAndReportsATypo(t *testing.T) { cmd := h.service() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: one - cmd: %s + - cmd: %s + name: one port: %d - - name: two - cmd: %s + - cmd: %s + name: two port: %d `, cmd, one, cmd, two)) @@ -603,16 +608,18 @@ func TestStartIncludesDependencyClosure(t *testing.T) { cmd := h.service() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: web - cmd: %s + - cmd: %s + name: web port: %d - depends_on: [api] - - name: api - cmd: %s + depends_on: + - api + - cmd: %s + name: api port: %d - depends_on: [db] - - name: db - cmd: %s + depends_on: + - db + - cmd: %s + name: db port: %d `, cmd, webPort, cmd, apiPort, cmd, dbPort)) @@ -649,11 +656,11 @@ func TestStartWithoutClosureKeepsTheNamedOnly(t *testing.T) { cmd := h.service() path := h.manifest(fmt.Sprintf(`name: itest services: - - name: one - cmd: %s + - cmd: %s + name: one port: %d - - name: two - cmd: %s + - cmd: %s + name: two port: %d `, cmd, one, cmd, two)) diff --git a/engine/internal/daemon/rpc/codec_test.go b/engine/internal/daemon/rpc/codec_test.go index 41d31740..696ad70f 100644 --- a/engine/internal/daemon/rpc/codec_test.go +++ b/engine/internal/daemon/rpc/codec_test.go @@ -9,70 +9,105 @@ import ( "testing" ) +// helloCall is the one message the round-trip case puts through the encoder. +func helloCall() Request { + return Request{JSONRPC: "2.0", ID: json.RawMessage(`1`), Method: "daemon.hello"} +} + +// A message written by the encoder must come back from the decoder as the same +// message; a message longer than the decoder's limit is refused as a whole +// rather than truncated into whatever follows it. func TestCodecRoundTripAndOversize(t *testing.T) { - var buf bytes.Buffer - enc := NewEncoder(&buf) - if err := enc.Encode(Request{JSONRPC: "2.0", ID: json.RawMessage(`1`), Method: "daemon.hello"}); err != nil { - t.Fatal(err) - } - dec := NewDecoder(&buf, 4<<20) - msg, err := dec.Next() - if err != nil || msg.Method != "daemon.hello" { - t.Fatalf("got %+v, %v", msg, err) - } - big := strings.Repeat("x", 5<<20) - dec = NewDecoder(strings.NewReader(`{"jsonrpc":"2.0","method":"`+big+`"}`+"\n"), 4<<20) - if _, err := dec.Next(); !errors.Is(err, ErrOversize) { - t.Fatalf("want ErrOversize, got %v", err) - } + t.Run("round trip", func(t *testing.T) { + var wire bytes.Buffer + if err := NewEncoder(&wire).Encode(helloCall()); err != nil { + t.Fatalf("Encode: %v", err) + } + + msg, err := NewDecoder(&wire, 4<<20).Next() + if err != nil { + t.Fatalf("Next: %v", err) + } + if msg.Method != "daemon.hello" || !msg.IsRequest() { + t.Fatalf("decoded %+v, want the daemon.hello call that was encoded", msg) + } + }) + + t.Run("over the limit", func(t *testing.T) { + payload := strings.Repeat("y", 5<<20) + wire := `{"jsonrpc":"2.0","method":"` + payload + `"}` + "\n" + if _, err := NewDecoder(strings.NewReader(wire), 4<<20).Next(); !errors.Is(err, ErrOversize) { + t.Fatalf("%d-byte message: err = %v, want ErrOversize", len(wire), err) + } + }) } +// A method without an id is a notification the client must not answer, and a +// message with an id and no method is a reply. Both arrive on the same stream. func TestDecoderClassifiesNotifications(t *testing.T) { - in := `{"jsonrpc":"2.0","method":"stream.chunk","params":{"id":"s1"}}` + "\n" + + wire := `{"jsonrpc":"2.0","method":"stream.chunk","params":{"id":"s1"}}` + "\n" + `{"jsonrpc":"2.0","id":7,"result":{"ok":true}}` + "\n" - dec := NewDecoder(strings.NewReader(in), 1<<20) - n, err := dec.Next() - if err != nil || !n.IsNotification() { - t.Fatalf("first message: %+v %v", n, err) + dec := NewDecoder(strings.NewReader(wire), 1<<20) + + first, err := dec.Next() + if err != nil { + t.Fatalf("first message: %v", err) + } + if !first.IsNotification() || first.Method != "stream.chunk" { + t.Fatalf("first message = %+v, want a stream.chunk notification", first) } - r, err := dec.Next() - if err != nil || r.IsNotification() { - t.Fatalf("second message: %+v %v", r, err) + + second, err := dec.Next() + if err != nil { + t.Fatalf("second message: %v", err) } - if string(r.ID) != "7" { - t.Fatalf("id = %s", r.ID) + if second.IsNotification() { + t.Fatalf("a message carrying an id was classified as a notification: %+v", second) } - if !r.IsResponse() { - t.Fatalf("second message should be a response: %+v", r) + if !second.IsResponse() { + t.Fatalf("second message = %+v, want a reply", second) + } + if string(second.ID) != "7" { + t.Fatalf("id = %s, want 7", second.ID) } } +// A stream that ends before any message is a clean end, not an error to report. func TestDecoderEOF(t *testing.T) { - dec := NewDecoder(strings.NewReader(""), 1<<20) - if _, err := dec.Next(); !errors.Is(err, io.EOF) { - t.Fatalf("want io.EOF, got %v", err) + if _, err := NewDecoder(strings.NewReader(""), 1<<20).Next(); !errors.Is(err, io.EOF) { + t.Fatalf("empty stream: err = %v, want io.EOF", err) } } +// A keepalive blank line is skipped rather than parsed, and an oversize message +// is dropped whole so the message after it is still readable. func TestDecoderSkipsBlankLinesAndRecoversAfterOversize(t *testing.T) { - big := strings.Repeat("x", 300) - in := "\n" + `{"jsonrpc":"2.0","method":"` + big + `"}` + "\n" + - `{"jsonrpc":"2.0","id":1,"method":"daemon.status"}` + "\n" - dec := NewDecoder(strings.NewReader(in), 128) + oversize := `{"jsonrpc":"2.0","method":"` + strings.Repeat("z", 300) + `"}` + next := `{"jsonrpc":"2.0","id":1,"method":"daemon.status"}` + dec := NewDecoder(strings.NewReader("\n"+oversize+"\n"+next+"\n"), 128) + if _, err := dec.Next(); !errors.Is(err, ErrOversize) { - t.Fatalf("want ErrOversize, got %v", err) + t.Fatalf("err = %v, want ErrOversize", err) } - m, err := dec.Next() - if err != nil || m.Method != "daemon.status" { - t.Fatalf("decoder did not resync: %+v %v", m, err) + msg, err := dec.Next() + if err != nil { + t.Fatalf("the decoder did not resynchronise after an oversize message: %v", err) + } + if msg.Method != "daemon.status" { + t.Fatalf("message after the oversize one = %+v, want daemon.status", msg) } } +// A line that is not JSON at all is the client's mistake, reported with the +// JSON-RPC code that says so. func TestDecoderMalformedJSONIsInvalidParams(t *testing.T) { - dec := NewDecoder(strings.NewReader("{not json}\n"), 1<<20) - _, err := dec.Next() - var e *Error - if !errors.As(err, &e) || e.Code != CodeInvalidParams { - t.Fatalf("want invalid_params Error, got %v", err) + _, err := NewDecoder(strings.NewReader("{not json}\n"), 1<<20).Next() + + var invalid *Error + if !errors.As(err, &invalid) { + t.Fatalf("err = %v, want a *Error", err) + } + if invalid.Code != CodeInvalidParams { + t.Fatalf("code = %d, want %d (invalid_params)", invalid.Code, CodeInvalidParams) } } diff --git a/engine/internal/daemon/rpc/schema_test.go b/engine/internal/daemon/rpc/schema_test.go index 4c55bf5f..2d9ddcb7 100644 --- a/engine/internal/daemon/rpc/schema_test.go +++ b/engine/internal/daemon/rpc/schema_test.go @@ -1,9 +1,11 @@ package rpc import ( + "bytes" "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" @@ -34,11 +36,69 @@ var contractMethods = []string{ "config.get", "config.set", "config.path", } +// schemaDocument decodes the generated document into the generic shape a client +// generator reads it in, so the checks below are about the wire document rather +// than about the Go types it was reflected from. +func schemaDocument(t *testing.T) map[string]any { + t.Helper() + var document map[string]any + if err := json.Unmarshal(Marshal(), &document); err != nil { + t.Fatalf("decoding the generated schema: %v", err) + } + return document +} + +// schemaObject returns one object-valued member of the document, failing when +// the member is absent or is not an object. +func schemaObject(t *testing.T, parent map[string]any, key string) map[string]any { + t.Helper() + object, ok := parent[key].(map[string]any) + if !ok { + t.Fatalf("schema has no %s object", key) + } + return object +} + +// definitionProperties returns the properties of one named definition, failing +// when the definition or its properties are missing. +func definitionProperties(t *testing.T, definitions map[string]any, name string) map[string]any { + t.Helper() + definition, ok := definitions[name].(map[string]any) + if !ok { + t.Fatalf("definition %q is missing from the schema", name) + } + properties, ok := definition["properties"].(map[string]any) + if !ok { + t.Fatalf("definition %q has no properties", name) + } + return properties +} + +// requireEntries fails for every named entry the collection does not carry. +func requireEntries(t *testing.T, collection string, entries map[string]any, names ...string) { + t.Helper() + for _, name := range names { + if _, ok := entries[name]; !ok { + t.Errorf("%s[%q] is missing from the schema", collection, name) + } + } +} + +// requireProperties fails for every property the object does not carry. +func requireProperties(t *testing.T, subject string, object map[string]any, properties ...string) { + t.Helper() + for _, property := range properties { + if _, ok := object[property]; !ok { + t.Errorf("%s has no %q property", subject, property) + } + } +} + func TestEveryContractMethodIsDescribed(t *testing.T) { - got := Methods() - for _, m := range contractMethods { - if _, ok := got[m]; !ok { - t.Errorf("method %q is in the contract but not described", m) + described := Methods() + for _, method := range contractMethods { + if _, ok := described[method]; !ok { + t.Errorf("method %q is in the contract but not described", method) } } } @@ -68,89 +128,66 @@ func TestEveryDescribedMethodIsInTheContract(t *testing.T) { // expose.* was renamed to share.* and its provider methods deleted before any // handler served them (docs/SHARE.md in option-berth-relay, "The rename"). func TestNoExposeMethods(t *testing.T) { - for _, m := range MethodNames() { - if strings.HasPrefix(m, "expose.") { - t.Errorf("method %q is described; expose.* was renamed to share.*", m) + for _, method := range MethodNames() { + if strings.HasPrefix(method, "expose.") { + t.Errorf("method %q is described; expose.* was renamed to share.*", method) } } - doc := string(Marshal()) - for _, gone := range []string{`"expose.`, "ProviderInfo", "ExposeInstallProvider", `"Tunnel"`, "ChangeTunnel"} { - if strings.Contains(doc, gone) { - t.Errorf("the generated schema still mentions %s", gone) + document := string(Marshal()) + for _, retired := range []string{`"expose.`, "ProviderInfo", "ExposeInstallProvider", `"Tunnel"`, "ChangeTunnel"} { + if strings.Contains(document, retired) { + t.Errorf("the generated schema still mentions %s", retired) } } } +// A streaming method answers with a subscription id and then pushes payloads, so +// a client cannot type its stream unless the description names both payloads. func TestStreamingMethodsDeclareChunkAndEnd(t *testing.T) { - for _, m := range []string{"groups.start"} { - d, ok := Methods()[m] - if !ok { - t.Fatalf("method %q not described", m) - } - if d.Chunk == nil || d.End == nil { - t.Errorf("streaming method %q must declare chunk and end types", m) - } + const streaming = "groups.start" + described, ok := Methods()[streaming] + if !ok { + t.Fatalf("method %q not described", streaming) + } + if described.Chunk == nil || described.End == nil { + t.Errorf("streaming method %q must declare chunk and end types", streaming) } } func TestBuildSchemaHasContractDefinitions(t *testing.T) { - b, err := json.Marshal(BuildSchema()) - if err != nil { - t.Fatal(err) - } - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - t.Fatal(err) - } + document := schemaDocument(t) - defs, ok := m["definitions"].(map[string]any) - if !ok { - t.Fatalf("schema has no definitions map") - } - for _, name := range []string{"Port", "Group", "Session", - "Snapshot", "Delta", "Event", "Error"} { - if _, ok := defs[name]; !ok { - t.Errorf("definition %q missing", name) - } - } + definitions := schemaObject(t, document, "definitions") + requireEntries(t, "definitions", definitions, + "Port", "Group", "Session", "Snapshot", "Delta", "Event", "Error") - port, ok := defs["Port"].(map[string]any)["properties"].(map[string]any) - if !ok { - t.Fatalf("Port definition has no properties") - } - for _, prop := range []string{"display_name", "ppid", "project_root", "group", - "group_source", "run", "stats", "started_at", "session"} { - if _, ok := port[prop]; !ok { - t.Errorf("Port has no %q property", prop) - } - } + port := definitionProperties(t, definitions, "Port") + requireProperties(t, "Port", port, + "display_name", "ppid", "project_root", "group", "group_source", + "run", "stats", "started_at", "session") // The dead share/tunnel/proxy fields were removed (R3.1): they must be gone. - for _, prop := range []string{"shares", "exposed_urls", "proxy_id", "proxy_target_port"} { - if _, ok := port[prop]; ok { - t.Errorf("Port still has dead %q property", prop) + for _, dead := range []string{"shares", "exposed_urls", "proxy_id", "proxy_target_port"} { + if _, ok := port[dead]; ok { + t.Errorf("Port still has dead %q property", dead) } } - methods, ok := m["methods"].(map[string]any) - if !ok { - t.Fatalf("schema has no methods map") - } - for _, name := range contractMethods { - entry, ok := methods[name].(map[string]any) + methods := schemaObject(t, document, "methods") + for _, method := range contractMethods { + entry, ok := methods[method].(map[string]any) if !ok { - t.Errorf("methods[%q] missing from schema", name) + t.Errorf("methods[%q] missing from schema", method) continue } - if _, ok := entry["params"]; !ok { - t.Errorf("methods[%q] has no params schema", name) - } - if _, ok := entry["result"]; !ok { - t.Errorf("methods[%q] has no result schema", name) + for _, half := range []string{"params", "result"} { + if _, ok := entry[half]; !ok { + t.Errorf("methods[%q] has no %s schema", method, half) + } } } - if m["protocol_version"] != ProtocolVersion { - t.Errorf("protocol_version = %v, want %s", m["protocol_version"], ProtocolVersion) + if document["protocol_version"] != ProtocolVersion { + t.Errorf("protocol_version = %v, want %s", document["protocol_version"], ProtocolVersion) } } @@ -166,64 +203,73 @@ func TestStateFilterUsesTheAgreedWireField(t *testing.T) { } } +// The document a client reads and the document the daemon generates must be the +// same bytes, so a type change cannot ship beside a stale schema file. func TestCheckedInSchemaIsUpToDate(t *testing.T) { path := filepath.Join("..", "..", "..", "docs", "schema", "protocol.schema.json") - onDisk, err := os.ReadFile(path) + checkedIn, err := os.ReadFile(path) if err != nil { t.Fatalf("%v (run `go generate ./...`)", err) } - if unixEOL(string(onDisk)) != unixEOL(string(Marshal())) { + if generated := Marshal(); !bytes.Equal(foldLineEndings(checkedIn), foldLineEndings(generated)) { t.Fatalf("%s is stale; run `go generate ./...`", path) } } -// unixEOL folds CRLF to LF. .gitattributes pins the checked-in schema to LF so -// the file a Windows CI checkout compares is the file `go generate` wrote, but -// a developer's checkout made before that (or with core.autocrlf forced on) +// foldLineEndings folds CRLF to LF. .gitattributes pins the checked-in schema to +// LF so the file a Windows CI checkout compares is the file `go generate` wrote, +// but a developer's checkout made before that (or with core.autocrlf forced on) // still has CRLF on disk, and the schema's content is what this test is about, // not its line endings. -func unixEOL(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") } +func foldLineEndings(b []byte) []byte { + return bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) +} // TestSchemaEnumsMatchGoEnums guards against the Go enum constants and the // struct-tag enums in the generated schema drifting apart. func TestSchemaEnumsMatchGoEnums(t *testing.T) { - var doc struct { + var document struct { Definitions map[string]struct { Properties map[string]json.RawMessage `json:"properties"` } `json:"definitions"` } - if err := json.Unmarshal(Marshal(), &doc); err != nil { - t.Fatal(err) + if err := json.Unmarshal(Marshal(), &document); err != nil { + t.Fatalf("decoding the generated schema: %v", err) } - wantTypes := make([]string, 0, len(state.AllPortTypes)) - for _, v := range state.AllPortTypes { - wantTypes = append(wantTypes, string(v)) + portTypes := make([]string, 0, len(state.AllPortTypes)) + for _, value := range state.AllPortTypes { + portTypes = append(portTypes, string(value)) + } + groupSources := make([]string, 0, len(state.AllGroupSources)) + for _, value := range state.AllGroupSources { + groupSources = append(groupSources, string(value)) } - assertEnum(t, doc.Definitions["Port"].Properties["type"], wantTypes) - wantSources := make([]string, 0, len(state.AllGroupSources)) - for _, v := range state.AllGroupSources { - wantSources = append(wantSources, string(v)) + for _, want := range []struct { + definition string + property string + enum []string + }{ + {"Port", "type", portTypes}, + {"Group", "source", groupSources}, + } { + assertEnum(t, document.Definitions[want.definition].Properties[want.property], want.enum) } - assertEnum(t, doc.Definitions["Group"].Properties["source"], wantSources) } +// assertEnum checks one property's enum against the Go values it is generated +// from: same members, same order. func assertEnum(t *testing.T, raw json.RawMessage, want []string) { t.Helper() - var got struct { + var property struct { Enum []string `json:"enum"` } - if err := json.Unmarshal(raw, &got); err != nil { + if err := json.Unmarshal(raw, &property); err != nil { t.Fatalf("decoding %s: %v", raw, err) } - if len(got.Enum) != len(want) { - t.Fatalf("enum = %v, want %v", got.Enum, want) - } - for i := range want { - if got.Enum[i] != want[i] { - t.Fatalf("enum = %v, want %v", got.Enum, want) - } + if !slices.Equal(property.Enum, want) { + t.Fatalf("enum = %v, want %v", property.Enum, want) } } @@ -235,57 +281,37 @@ var contractNotifications = []string{ } func TestEveryNotificationIsDescribed(t *testing.T) { - got := Notifications() - for _, n := range contractNotifications { - if _, ok := got[n]; !ok { - t.Errorf("notification %q is in the contract but not described", n) + described := Notifications() + for _, name := range contractNotifications { + if _, ok := described[name]; !ok { + t.Errorf("notification %q is in the contract but not described", name) } } - if names := NotificationNames(); len(names) != len(got) { - t.Errorf("NotificationNames returned %d of %d notifications", len(names), len(got)) + if names := NotificationNames(); len(names) != len(described) { + t.Errorf("NotificationNames returned %d of %d notifications", len(names), len(described)) } } // TestSchemaCarriesNotificationsAndTheStreamEnvelope: a generator reading the // document alone must be able to type every inbound message, envelope included. func TestSchemaCarriesNotificationsAndTheStreamEnvelope(t *testing.T) { - var m map[string]any - if err := json.Unmarshal(Marshal(), &m); err != nil { - t.Fatal(err) - } + document := schemaDocument(t) - notifications, ok := m["notifications"].(map[string]any) - if !ok { - t.Fatalf("schema has no notifications map") - } - for _, name := range contractNotifications { - entry, ok := notifications[name].(map[string]any) + notifications := schemaObject(t, document, "notifications") + for _, notification := range contractNotifications { + entry, ok := notifications[notification].(map[string]any) if !ok { - t.Errorf("notifications[%q] missing from schema", name) + t.Errorf("notifications[%q] missing from schema", notification) continue } if _, ok := entry["params"]; !ok { - t.Errorf("notifications[%q] has no params schema", name) + t.Errorf("notifications[%q] has no params schema", notification) } } - defs, ok := m["definitions"].(map[string]any) - if !ok { - t.Fatalf("schema has no definitions map") - } - for _, name := range []string{"StreamChunk", "StreamEnd", "StreamStart"} { - if _, ok := defs[name]; !ok { - t.Errorf("definition %q missing; a client cannot read a stream without it", name) - } - } + definitions := schemaObject(t, document, "definitions") + requireEntries(t, "definitions", definitions, "StreamChunk", "StreamEnd", "StreamStart") - chunk, ok := defs["StreamChunk"].(map[string]any)["properties"].(map[string]any) - if !ok { - t.Fatalf("StreamChunk definition has no properties") - } - for _, prop := range []string{"id", "data"} { - if _, ok := chunk[prop]; !ok { - t.Errorf("StreamChunk has no %q property", prop) - } - } + chunk := definitionProperties(t, definitions, "StreamChunk") + requireProperties(t, "StreamChunk", chunk, "id", "data") } diff --git a/engine/internal/daemon/runsreg/exits_test.go b/engine/internal/daemon/runsreg/exits_test.go index 26a1a65b..f1369e13 100644 --- a/engine/internal/daemon/runsreg/exits_test.go +++ b/engine/internal/daemon/runsreg/exits_test.go @@ -57,37 +57,41 @@ func TestExitRowDistinguishesMissingAndMinimumPortHint(t *testing.T) { // TestExitedRecordsHowARunEnded: code 0 exited, anything else crashed, and a // run option-berth was stopping is stopped whatever its code. func TestExitedRecordsHowARunEnded(t *testing.T) { - r := testRegistry(1, 2, 3) - for _, rec := range []Record{ - {ID: "a", PID: 1, Group: "g", Name: "api"}, - {ID: "b", PID: 2, Group: "g", Name: "web"}, - {ID: "c", PID: 3, Group: "g", Name: "job"}, - } { - r.Register(rec) - } - r.Stopping([]int{2}) + reg := testRegistry(11, 12, 13) + registerAll(reg, + Record{ID: "api-run", PID: 11, Group: "shop", Name: "api"}, + Record{ID: "web-run", PID: 12, Group: "shop", Name: "web"}, + Record{ID: "job-run", PID: 13, Group: "shop", Name: "job"}, + ) + reg.Stopping([]int{12}) - for _, tt := range []struct { - pid, code int - want string + for _, tc := range []struct { + pid int + code int + reason string }{ - {1, 1, ReasonCrashed}, - {2, 143, ReasonStopped}, - {3, 0, ReasonExited}, + {11, 1, ReasonCrashed}, + {12, 143, ReasonStopped}, + {13, 0, ReasonExited}, } { - e, ok := r.Exited(tt.pid, tt.code, false) - if !ok || e.Reason != tt.want || e.Code != tt.code { - t.Errorf("Exited(%d, %d) = %+v, %v; want reason %s", tt.pid, tt.code, e, ok, tt.want) + e, ok := reg.Exited(tc.pid, tc.code, false) + if !ok { + t.Errorf("Exited(%d, %d) recorded nothing", tc.pid, tc.code) + continue + } + if e.Reason != tc.reason || e.Code != tc.code { + t.Errorf("Exited(%d, %d) = %+v, want reason %s and the code it exited with", + tc.pid, tc.code, e, tc.reason) } } - if got := r.List(); len(got) != 0 { - t.Errorf("List = %+v, want the exited runs gone from the live ones", got) + if live := reg.List(); len(live) != 0 { + t.Errorf("List = %v, want the exited runs gone from the live ones", runIDs(live)) } - exits := r.Exits() - if len(exits) != 3 || exits[0].ID != "c" { + exits := reg.Exits() + if len(exits) != 3 || exits[0].ID != "job-run" { t.Fatalf("Exits = %+v, want three, newest first", exits) } - if _, ok := r.Exited(1, 0, false); ok { + if _, ok := reg.Exited(11, 0, false); ok { t.Error("the same run was recorded as exited twice") } } @@ -139,17 +143,18 @@ func TestStartFailedWithReasonPreservesDependencyOutcome(t *testing.T) { // TestStoppingCorrectsARunThatAlreadyExited: the kill can win the race against // being marked, so the record is corrected afterwards. func TestStoppingCorrectsARunThatAlreadyExited(t *testing.T) { - r := testRegistry(1) - r.Register(Record{ID: "a", PID: 1, Group: "g", Name: "api"}) - if _, ok := r.Exited(1, 143, false); !ok { + reg := testRegistry(11) + reg.Register(Record{ID: "api-run", PID: 11, Group: "shop", Name: "api"}) + if _, ok := reg.Exited(11, 143, false); !ok { t.Fatal("Exited reported nothing") } - if got := r.Exits()[0].Reason; got != ReasonCrashed { - t.Fatalf("reason = %s, want it to look like a crash first", got) + if reason := reg.Exits()[0].Reason; reason != ReasonCrashed { + t.Fatalf("reason = %s, want it to look like a crash first", reason) } - r.Stopping([]int{1}) - if got := r.Exits()[0].Reason; got != ReasonStopped { - t.Errorf("reason = %s, want %s", got, ReasonStopped) + + reg.Stopping([]int{11}) + if reason := reg.Exits()[0].Reason; reason != ReasonStopped { + t.Errorf("reason = %s after the kill was recorded, want %s", reason, ReasonStopped) } } @@ -166,36 +171,42 @@ func TestStoppingWithReasonPersistsReadinessTimeout(t *testing.T) { // TestLastExitOnlyWhileTheServiceIsDown: a service that has been started again // reports nothing, so a group row never says "crashed" about a running one. func TestLastExitOnlyWhileTheServiceIsDown(t *testing.T) { - r := testRegistry(1, 2) - r.Register(Record{ID: "a", PID: 1, Group: "g", Name: "api"}) - r.Exited(1, 1, false) + reg := testRegistry(11, 12) + reg.Register(Record{ID: "api-run", PID: 11, Group: "shop", Name: "api"}) + reg.Exited(11, 1, false) - e, ok := r.LastExit("g", "api") - if !ok || e.Code != 1 || e.Reason != ReasonCrashed || e.RunID != "a" || e.At == "" { - t.Fatalf("LastExit = %+v, %v", e, ok) + e, ok := reg.LastExit("shop", "api") + if !ok { + t.Fatal("the exited service reported no last exit") } - if _, ok := r.LastExit("g", "web"); ok { - t.Error("a service that never ran reported an exit") + if e.Code != 1 || e.Reason != ReasonCrashed || e.RunID != "api-run" || e.At == "" { + t.Fatalf("LastExit = %+v, want the crashed run and the time it ended", e) + } + if e, ok := reg.LastExit("shop", "web"); ok { + t.Errorf("a service that never ran reported %+v", e) } - r.Register(Record{ID: "b", PID: 2, Group: "g", Name: "api"}) - if _, ok := r.LastExit("g", "api"); ok { - t.Error("a running service reported its last exit") + reg.Register(Record{ID: "api-again", PID: 12, Group: "shop", Name: "api"}) + if e, ok := reg.LastExit("shop", "api"); ok { + t.Errorf("a running service reported its last exit: %+v", e) } } +// The exit history is bounded: once more runs than the cap have exited, the +// oldest records make way for the newest. func TestExitHistoryIsBounded(t *testing.T) { - r := testRegistry() - for i := 1; i <= maxExits+5; i++ { - r.Register(Record{ID: itoa(i), PID: i, Group: "g", Name: "job"}) - r.Exited(i, 0, false) + reg := testRegistry() + for pid := 1; pid <= maxExits+5; pid++ { + reg.Register(Record{ID: itoa(pid), PID: pid, Group: "shop", Name: "job"}) + reg.Exited(pid, 0, false) } - exits := r.Exits() - if len(exits) != maxExits { - t.Fatalf("Exits = %d, want it capped at %d", len(exits), maxExits) + + kept := reg.Exits() + if len(kept) != maxExits { + t.Fatalf("Exits = %d, want it capped at %d", len(kept), maxExits) } - if exits[0].PID != maxExits+5 { - t.Errorf("newest exit = %d, want the last one recorded", exits[0].PID) + if kept[0].PID != maxExits+5 { + t.Errorf("newest exit = %d, want the last one recorded", kept[0].PID) } } @@ -237,41 +248,45 @@ func TestExitHistorySurvivesRegistryReload(t *testing.T) { } } +// A rename carries the group's exits with it, so the row for the service keeps +// saying why it stopped. func TestRenameGroupsMovesTheExitsToo(t *testing.T) { - r := testRegistry(1) - r.Register(Record{ID: "a", PID: 1, Group: "old", Name: "api"}) - r.Exited(1, 1, false) - r.RenameGroups(map[string]string{"old": "new"}) - if _, ok := r.LastExit("new", "api"); !ok { - t.Errorf("the exit kept the old group: %+v", r.Exits()) + reg := testRegistry(11) + reg.Register(Record{ID: "api-run", PID: 11, Group: "old", Name: "api"}) + reg.Exited(11, 1, false) + + reg.RenameGroups(map[string]string{"old": "new"}) + if _, ok := reg.LastExit("new", "api"); !ok { + t.Errorf("the exit kept the old group: %+v", reg.Exits()) } } // TestTailLinesReadsOnlyThisRun: the log file is appended to across runs, so // the lines kept with an exit start where the run did. func TestTailLinesReadsOnlyThisRun(t *testing.T) { - path := filepath.Join(t.TempDir(), "api.log") - if err := os.WriteFile(path, []byte("an earlier run\nfirst\nsecond\n"), 0o600); err != nil { - t.Fatal(err) + logPath := filepath.Join(t.TempDir(), "api.log") + earlier := "an earlier run\n" + if err := os.WriteFile(logPath, []byte(earlier+"first\nsecond\n"), 0o600); err != nil { + t.Fatalf("writing the run log: %v", err) } - if got := tailLines(path, int64(len("an earlier run\n")), 20); strings.Join(got, "|") != "first|second" { + if got := tailLines(logPath, int64(len(earlier)), 20); strings.Join(got, "|") != "first|second" { t.Errorf("tailLines = %v, want this run's two lines", got) } // An offset past the end means the file was rotated: read the new one. - if got := tailLines(path, 1<<20, 20); len(got) != 3 { + if got := tailLines(logPath, 1<<20, 20); len(got) != 3 { t.Errorf("tailLines after a rotation = %v, want the whole file", got) } - var b strings.Builder - for i := 0; i < 50; i++ { - fmt.Fprintf(&b, "line %d\n", i) + var log strings.Builder + for i := range 50 { + fmt.Fprintf(&log, "line %d\n", i) } - if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { - t.Fatal(err) + if err := os.WriteFile(logPath, []byte(log.String()), 0o600); err != nil { + t.Fatalf("rewriting the run log: %v", err) } - got := tailLines(path, 0, 20) - if len(got) != 20 || got[19] != "line 49" { - t.Errorf("tailLines = %d lines ending %q, want the last 20", len(got), got[len(got)-1]) + tail := tailLines(logPath, 0, 20) + if len(tail) != 20 || tail[19] != "line 49" { + t.Errorf("tailLines = %d lines ending %q, want the last 20", len(tail), tail[len(tail)-1]) } } diff --git a/engine/internal/daemon/runsreg/registry_test.go b/engine/internal/daemon/runsreg/registry_test.go index d26f1786..c6c1fc95 100644 --- a/engine/internal/daemon/runsreg/registry_test.go +++ b/engine/internal/daemon/runsreg/registry_test.go @@ -3,6 +3,7 @@ package runsreg import ( "encoding/json" "os" + "strconv" "testing" "time" @@ -11,20 +12,38 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// testRegistry is a registry with no process table and no runs.json behind it. +// testRegistry hands back a registry whose liveness answer comes from the pids +// named, and which reads neither the host process table nor a runs.json. func testRegistry(alive ...int) *Registry { - live := map[int]bool{} + live := make(map[int]bool, len(alive)) for _, pid := range alive { live[pid] = true } - r := New() - r.Mirror = false - r.Alive = func(pid int) bool { return live[pid] } - r.Parents = func() map[int]int { return nil } - // Synthetic PIDs have no host identity. Tests exercising PID reuse inject - // StartTime explicitly, just as ancestry tests inject Parents. - r.StartTime = nil - return r + reg := New() + reg.Mirror = false + reg.Alive = func(pid int) bool { return live[pid] } + reg.Parents = func() map[int]int { return nil } + // A synthetic pid has no host identity to read. Tests exercising PID reuse + // inject StartTime explicitly, just as ancestry tests inject Parents. + reg.StartTime = nil + return reg +} + +// registerAll fills the registry with the records in the order given. +func registerAll(reg *Registry, recs ...Record) { + for _, rec := range recs { + reg.Register(rec) + } +} + +// runIDs names the runs in list order, so a failure message shows the order the +// registry answered with rather than a struct dump. +func runIDs(recs []Record) []string { + ids := make([]string, len(recs)) + for i, rec := range recs { + ids[i] = rec.ID + } + return ids } // The helper must stay independent of the host even when a synthetic PID @@ -49,66 +68,77 @@ func TestNewRegistryKeepsProcessIdentityReader(t *testing.T) { } } +// List answers oldest first whatever order the runs arrived in, and Unregister +// removes a run exactly once. func TestRegisterListAndUnregister(t *testing.T) { - r := testRegistry(100, 200) - base := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) - - r.Register(Record{ID: "b", PID: 200, Group: "g", Name: "api", StartedAt: base.Add(time.Minute)}) - r.Register(Record{ID: "a", PID: 100, Group: "g", Name: "web", StartedAt: base}) + reg := testRegistry(4100, 4200) + base := time.Date(2026, 2, 3, 9, 15, 0, 0, time.UTC) + registerAll(reg, + Record{ID: "later", PID: 4200, Group: "shop", Name: "api", StartedAt: base.Add(90 * time.Second)}, + Record{ID: "earlier", PID: 4100, Group: "shop", Name: "web", StartedAt: base}, + ) - got := r.List() - if len(got) != 2 { - t.Fatalf("List = %d runs, want 2", len(got)) + listed := reg.List() + if len(listed) != 2 { + t.Fatalf("List = %d runs, want 2", len(listed)) } - if got[0].ID != "a" || got[1].ID != "b" { - t.Fatalf("List is not oldest first: %v", []string{got[0].ID, got[1].ID}) + if listed[0].ID != "earlier" || listed[1].ID != "later" { + t.Fatalf("List = %v, want the older run first", runIDs(listed)) } - if !r.Unregister(100) { - t.Fatal("Unregister(100) reported nothing to remove") + if !reg.Unregister(4100) { + t.Fatal("Unregister(4100) reported nothing to remove") } - if r.Unregister(100) { - t.Fatal("Unregister(100) removed the same run twice") + if reg.Unregister(4100) { + t.Fatal("Unregister(4100) removed the same run twice") } - if len(r.List()) != 1 { - t.Fatalf("List after unregister = %v", r.List()) + if remaining := reg.List(); len(remaining) != 1 { + t.Fatalf("List after unregister = %v, want one run left", runIDs(remaining)) } } +// A pid registered again keeps the run id it already had, so the code snapshot +// and the exit history recorded under that id stay attached to it. func TestRegisterKeepsTheIDOfAReregisteredPID(t *testing.T) { - r := testRegistry(100) - r.Register(Record{ID: "keep", PID: 100, Group: "g", Name: "web"}) - rec := r.Register(Record{PID: 100, Group: "g", Name: "web"}) - if rec.ID != "keep" { - t.Fatalf("id = %q, want keep", rec.ID) + reg := testRegistry(4300) + first := reg.Register(Record{ID: "keep", PID: 4300, Group: "shop", Name: "web"}) + second := reg.Register(Record{PID: 4300, Group: "shop", Name: "web"}) + if second.ID != first.ID || second.ID != "keep" { + t.Fatalf("re-registered id = %q (first %q), want keep", second.ID, first.ID) } } +// Prune drops the run whose process is gone and keeps the live one. func TestPruneDropsDeadRuns(t *testing.T) { - r := testRegistry(100) - r.Register(Record{ID: "a", PID: 100, Group: "g", Name: "web"}) - r.Register(Record{ID: "b", PID: 999, Group: "g", Name: "gone"}) + reg := testRegistry(4400) + registerAll(reg, + Record{ID: "live", PID: 4400, Group: "shop", Name: "web"}, + Record{ID: "dead", PID: 4499, Group: "shop", Name: "gone"}, + ) - r.Prune() - got := r.List() - if len(got) != 1 || got[0].PID != 100 { - t.Fatalf("Prune left %v, want only pid 100", got) + reg.Prune() + left := reg.List() + if len(left) != 1 || left[0].PID != 4400 { + t.Fatalf("Prune left %v, want only pid 4400", runIDs(left)) } } func TestRunAttributesAPortByItsPPIDAncestry(t *testing.T) { - r := testRegistry(100) - r.Register(Record{ID: "a", PID: 100, Group: "itest", Name: "web"}) - // oberth start (100) -> npm (200) -> node (300) -> esbuild (400) - r.Parents = func() map[int]int { return map[int]int{400: 300, 300: 200, 200: 100} } + reg := testRegistry(4500) + reg.Register(Record{ID: "run-4500", PID: 4500, Group: "itest", Name: "web"}) + // oberth start (4500) -> npm (4600) -> node (4700) -> esbuild (4800) + reg.Parents = func() map[int]int { return map[int]int{4800: 4700, 4700: 4600, 4600: 4500} } - run, ok := r.Run(state.Port{PID: 400, PPID: 300}) - if !ok || run.Group != "itest" || run.Name != "web" || run.RootPID != 100 { - t.Fatalf("Run(descendant) = %+v/%v, want itest/web rooted at 100", run, ok) + run, ok := reg.Run(state.Port{PID: 4800, PPID: 4700}) + if !ok { + t.Fatal("a descendant's port was attributed to no run") + } + if run.Group != "itest" || run.Name != "web" || run.RootPID != 4500 { + t.Fatalf("Run(descendant) = %+v, want itest/web rooted at 4500", run) } - if _, ok := r.Run(state.Port{PID: 777, PPID: 1}); ok { - t.Fatal("an unrelated listener was attributed to a run") + if unrelated, ok := reg.Run(state.Port{PID: 4900, PPID: 1}); ok { + t.Fatalf("an unrelated listener was attributed to %+v", unrelated) } } @@ -118,42 +148,47 @@ func TestRunAttributesAPortByItsPPIDAncestry(t *testing.T) { // process table to work with, and it has to reach the `oberth start` that owns // the listener through it. func TestRunAttributesTheLinuxScannerShape(t *testing.T) { - r := testRegistry(100) - r.Register(Record{ID: "a", PID: 100, Group: "itest", Name: "web"}) - // oberth start (100) -> the listener it spawned (300). ss gave no ppid. - r.Parents = func() map[int]int { return map[int]int{300: 100, 100: 42} } + reg := testRegistry(4500) + reg.Register(Record{ID: "run-4500", PID: 4500, Group: "itest", Name: "web"}) + // oberth start (4500) -> the listener it spawned (4900); ss gave no ppid. + reg.Parents = func() map[int]int { return map[int]int{4900: 4500, 4500: 42} } - run, ok := r.Run(state.Port{PID: 300, PPID: 0}) + run, ok := reg.Run(state.Port{PID: 4900, PPID: 0}) if !ok { t.Fatal("a listener spawned by a run was not attributed to it") } - if run.ID != "a" || run.Group != "itest" || run.Name != "web" || run.RootPID != 100 { - t.Fatalf("Run = %+v, want a/itest/web rooted at 100", run) + if run.ID != "run-4500" || run.Group != "itest" || run.Name != "web" || run.RootPID != 4500 { + t.Fatalf("Run = %+v, want itest/web rooted at 4500", run) } } // TestRunAttributesTheRegisteredPIDItself covers `oberth start` in the // foreground: the run is registered under the pid of the child it spawned, and -// that child is the process holding the socket, so no walk is needed and the -// answer must not depend on a process table being readable at all. +// that child holds the socket. No walk is needed, so the answer must not depend +// on the process table being readable at all. func TestRunAttributesTheRegisteredPIDItself(t *testing.T) { - r := testRegistry(300) - r.Register(Record{ID: "a", PID: 300, Group: "itest", Name: "web"}) - r.Parents = func() map[int]int { t.Fatal("the process table should not be needed"); return nil } + reg := testRegistry(4300) + reg.Register(Record{ID: "run-4300", PID: 4300, Group: "itest", Name: "web"}) + reg.Parents = func() map[int]int { t.Fatal("the process table should not be needed"); return nil } - run, ok := r.Run(state.Port{PID: 300}) - if !ok || run.ID != "a" || run.Name != "web" || run.RootPID != 300 { - t.Fatalf("Run = %+v/%v, want the registered run", run, ok) + run, ok := reg.Run(state.Port{PID: 4300}) + if !ok { + t.Fatal("the registered run's own port was attributed to nothing") + } + if run.ID != "run-4300" || run.Name != "web" || run.RootPID != 4300 { + t.Fatalf("Run = %+v, want the registered run", run) } } +// The parent the scanner already resolved is evidence the registry holds, so it +// answers before the process table is read. func TestRunUsesTheDirectParentBeforeTheProcessTable(t *testing.T) { - r := testRegistry(100) - r.Register(Record{ID: "a", PID: 100, Group: "itest", Name: "web"}) - r.Parents = func() map[int]int { t.Fatal("the process table should not be needed"); return nil } + reg := testRegistry(4500) + reg.Register(Record{ID: "run-4500", PID: 4500, Group: "itest", Name: "web"}) + reg.Parents = func() map[int]int { t.Fatal("the process table should not be needed"); return nil } - if run, ok := r.Run(state.Port{PID: 200, PPID: 100}); !ok || run.Group != "itest" { - t.Fatalf("Run(child) = %+v/%v", run, ok) + if run, ok := reg.Run(state.Port{PID: 4600, PPID: 4500}); !ok || run.Group != "itest" { + t.Fatalf("Run(child) = %+v/%v, want the parent's group", run, ok) } } @@ -171,76 +206,79 @@ func TestRunIgnoresAStaleStamp(t *testing.T) { } } -func TestImportLegacyTakesOverRunsJSON(t *testing.T) { +// legacyRunsHome points HOME and USERPROFILE at a throwaway directory: that is +// where the runs.json the import takes over lives. +func legacyRunsHome(t *testing.T) { + t.Helper() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) +} - live := os.Getpid() - if err := runs.Add(runs.Entry{PID: live, Tag: "legacy", ID: "old-1", Cmd: "npm run dev"}); err != nil { - t.Fatal(err) +func TestImportLegacyTakesOverRunsJSON(t *testing.T) { + legacyRunsHome(t) + pid := os.Getpid() + if err := runs.Add(runs.Entry{PID: pid, Tag: "legacy", ID: "old-1", Cmd: "npm run dev"}); err != nil { + t.Fatalf("writing the legacy runs.json: %v", err) } if _, err := os.Stat(runs.Path()); err != nil { t.Fatalf("runs.json was not written: %v", err) } - r := New() - r.Mirror = false - if n, err := r.ImportLegacy(); err != nil || n != 1 { - t.Fatalf("ImportLegacy = %d/%v, want 1/nil", n, err) + reg := New() + reg.Mirror = false + moved, err := reg.ImportLegacy() + if err != nil || moved != 1 { + t.Fatalf("ImportLegacy = %d/%v, want 1/nil", moved, err) } if _, err := os.Stat(runs.Path()); !os.IsNotExist(err) { t.Fatalf("runs.json survived the import: %v", err) } - got := r.List() - if len(got) != 1 { - t.Fatalf("List = %v", got) + imported := reg.List() + if len(imported) != 1 { + t.Fatalf("List after the import = %v, want the imported run", runIDs(imported)) } // A `option-berth run --tag legacy` entry carries no group or name of its own, so // the tag stands for both (the documented alias). - if got[0].Group != "legacy" || got[0].Name != "legacy" || got[0].ID != "old-1" { - t.Fatalf("imported record = %+v", got[0]) + if imported[0].Group != "legacy" || imported[0].Name != "legacy" || imported[0].ID != "old-1" { + t.Fatalf("imported record = %+v", imported[0]) } } func TestImportLegacyRewritesTheFileItOwns(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) + legacyRunsHome(t) + pid := os.Getpid() + if err := runs.Add(runs.Entry{PID: pid, Tag: "legacy", ID: "old-1"}); err != nil { + t.Fatalf("writing the legacy runs.json: %v", err) + } - live := os.Getpid() - if err := runs.Add(runs.Entry{PID: live, Tag: "legacy", ID: "old-1"}); err != nil { - t.Fatal(err) + reg := New() // mirroring on: the daemon owns the file from here + moved, err := reg.ImportLegacy() + if err != nil || moved != 1 { + t.Fatalf("ImportLegacy = %d/%v, want 1/nil", moved, err) } - r := New() // mirroring on: the daemon owns the file from here - if n, err := r.ImportLegacy(); err != nil || n != 1 { - t.Fatalf("ImportLegacy = %d/%v, want 1/nil", n, err) + var rewritten struct { + Entries map[string]runs.Entry `json:"runs"` } data, err := os.ReadFile(runs.Path()) if err != nil { t.Fatalf("the daemon did not rewrite runs.json: %v", err) } - var file struct { - Runs map[string]runs.Entry `json:"runs"` - } - if err := json.Unmarshal(data, &file); err != nil { - t.Fatal(err) + if err := json.Unmarshal(data, &rewritten); err != nil { + t.Fatalf("runs.json is not the documented shape: %v", err) } - entry, ok := file.Runs[itoa(live)] + entry, ok := rewritten.Entries[itoa(pid)] if !ok { - t.Fatalf("runs.json = %s", data) + t.Fatalf("runs.json = %s, want an entry for pid %d", data, pid) } if entry.Group != "legacy" || entry.Name != "legacy" { - t.Fatalf("mirrored entry = %+v", entry) + t.Fatalf("mirrored entry = %+v, want the tag standing in for group and name", entry) } } -func itoa(i int) string { - b, _ := json.Marshal(i) - return string(b) -} +func itoa(i int) string { return strconv.Itoa(i) } // TestPruneDropsARecycledPID: a pid that now belongs to a newer process is not // the run the record names. This is the imported-runs case after a daemon diff --git a/engine/internal/daemon/runsreg/rename_test.go b/engine/internal/daemon/runsreg/rename_test.go index ad9d526c..c7a14f02 100644 --- a/engine/internal/daemon/runsreg/rename_test.go +++ b/engine/internal/daemon/runsreg/rename_test.go @@ -4,24 +4,35 @@ import "testing" // TestRenameGroupsMovesRunsWithTheProject: a service started before its // project was renamed follows the rename instead of keeping a group of the old -// name to itself. +// name to itself, while a run in an unrelated group is left where it is. An +// empty rename is a no-op that reports nothing moved. func TestRenameGroupsMovesRunsWithTheProject(t *testing.T) { - r := New() - r.Mirror = false - r.Register(Record{PID: 101, Group: "shop", Name: "api"}) - r.Register(Record{PID: 102, Group: "shop@feat", Name: "api"}) - r.Register(Record{PID: 103, Group: "other", Name: "job"}) + reg := New() + reg.Mirror = false + for _, rec := range []Record{ + {PID: 611, Group: "shop", Name: "api"}, + {PID: 612, Group: "shop@feat", Name: "api"}, + {PID: 613, Group: "other", Name: "job"}, + } { + reg.Register(rec) + } - if n := r.RenameGroups(map[string]string{"shop": "market", "shop@feat": "market@feat"}); n != 2 { - t.Fatalf("RenameGroups moved %d runs, want 2", n) + renames := map[string]string{"shop": "market", "shop@feat": "market@feat"} + if n := reg.RenameGroups(renames); n != 2 { + t.Fatalf("RenameGroups(%v) moved %d runs, want 2", renames, n) } - for pid, want := range map[int]string{101: "market", 102: "market@feat", 103: "other"} { - rec, ok := r.Lookup(pid) - if !ok || rec.Group != want { - t.Errorf("pid %d group = %q (%v), want %q", pid, rec.Group, ok, want) + for pid, want := range map[int]string{611: "market", 612: "market@feat", 613: "other"} { + rec, ok := reg.Lookup(pid) + if !ok { + t.Errorf("pid %d is no longer registered after the rename", pid) + continue + } + if rec.Group != want { + t.Errorf("pid %d group = %q, want %q", pid, rec.Group, want) } } - if n := r.RenameGroups(nil); n != 0 { - t.Errorf("an empty rename moved %d runs", n) + + if n := reg.RenameGroups(nil); n != 0 { + t.Errorf("a nil rename moved %d runs, want none", n) } } diff --git a/engine/internal/daemon/runsreg/session_test.go b/engine/internal/daemon/runsreg/session_test.go index 0bbe2356..249ea814 100644 --- a/engine/internal/daemon/runsreg/session_test.go +++ b/engine/internal/daemon/runsreg/session_test.go @@ -8,83 +8,98 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -func agentSession() state.Session { +// fixtureSession is one attributed coding-agent session, filled in on every +// field so that one dropped on the way through the registry shows up as a +// mismatch rather than as an equal zero value. +func fixtureSession() state.Session { return state.Session{ - ID: "claude-code:abc", Tool: sessions.ToolClaudeCode, - Label: "ship 2A.4", Worktree: "feature-x", Branch: "feature/x", Detected: true, + ID: "claude-code:zz9", Tool: sessions.ToolClaudeCode, + Label: "port the runsreg tests", Worktree: "feature-y", Branch: "feature/y", + Detected: true, } } // A session registered with a run comes back on the run, on the ports the run // owns, and in the list the daemon's sessions handlers read. func TestSessionRoundTrip(t *testing.T) { - r := testRegistry(100) - started := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC) - r.Register(Record{ - ID: "run1", PID: 100, Group: "web", Name: "dev", Cmd: "npm run dev", - Cwd: "/home/me/code/shop", StartedAt: started, Session: agentSession(), + reg := testRegistry(500) + startedAt := time.Date(2026, 4, 17, 14, 30, 0, 0, time.UTC) + reg.Register(Record{ + ID: "run-500", PID: 500, Group: "storefront", Name: "vite", + Cmd: "npm run dev", Cwd: "/home/dev/code/storefront", + StartedAt: startedAt, Session: fixtureSession(), }) - rec, ok := r.Lookup(100) - if !ok || rec.Session.ID != "claude-code:abc" { - t.Fatalf("Lookup = %+v, %v", rec, ok) + rec, ok := reg.Lookup(500) + if !ok { + t.Fatal("the run was not registered") + } + if rec.Session.ID != fixtureSession().ID { + t.Fatalf("Lookup session = %+v, want %+v", rec.Session, fixtureSession()) } - got, ok := r.Session(state.Port{PID: 100}) + byPID, ok := reg.Session(state.Port{PID: 500}) if !ok { t.Fatal("Session did not attribute the run's own pid") } - if got != agentSession() { - t.Errorf("Session = %+v, want %+v", got, agentSession()) + if byPID != fixtureSession() { + t.Errorf("Session = %+v, want %+v", byPID, fixtureSession()) } - live := r.SessionRuns() + live := reg.SessionRuns() if len(live) != 1 { t.Fatalf("SessionRuns = %+v, want one entry", live) } want := sessions.Live{ - RunID: "run1", PID: 100, Group: "web", Name: "dev", Cmd: "npm run dev", - Cwd: "/home/me/code/shop", StartedAt: started, Session: agentSession(), + RunID: "run-500", PID: 500, Group: "storefront", Name: "vite", + Cmd: "npm run dev", Cwd: "/home/dev/code/storefront", + StartedAt: startedAt, Session: fixtureSession(), } if live[0] != want { - t.Errorf("SessionRuns[0] = %+v, want %+v", live[0], want) + t.Errorf("SessionRuns = %+v, want the run projected with its session as %+v", live, want) } } -// A child of the run inherits its session, through the same PPID walk that -// decides the run itself: `npm run dev` -> vite -> esbuild is one session. +// A grandchild of the run carries the same session as the run itself: the PPID +// walk that decides attribution is the one that answers this, so `npm run dev` +// -> vite -> esbuild stays a single session. func TestSessionFollowsThePPIDWalk(t *testing.T) { - r := testRegistry(100) - r.Parents = func() map[int]int { return map[int]int{300: 200, 200: 100, 100: 1} } - r.Register(Record{ID: "run1", PID: 100, Group: "web", Name: "dev", Session: agentSession()}) + reg := testRegistry(700) + reg.Parents = func() map[int]int { return map[int]int{900: 800, 800: 700, 700: 1} } + reg.Register(Record{ID: "run-700", PID: 700, Group: "storefront", Name: "vite", Session: fixtureSession()}) - if got, ok := r.Session(state.Port{PID: 300, PPID: 200}); !ok || got.ID != "claude-code:abc" { - t.Errorf("a grandchild's port got session %+v, %v", got, ok) + got, ok := reg.Session(state.Port{PID: 900, PPID: 800}) + if !ok { + t.Fatal("a grandchild's port was attributed to no run") + } + if got.ID != fixtureSession().ID { + t.Errorf("a grandchild's port got session %+v, want %+v", got, fixtureSession()) } } // A run nobody attributed to a session has none, and neither do its ports: // a plain shell must not invent a session for every dev server on the machine. func TestRunsWithoutASessionReportNone(t *testing.T) { - r := testRegistry(100) - r.Register(Record{ID: "run1", PID: 100, Group: "web", Name: "dev"}) + reg := testRegistry(700) + reg.Register(Record{ID: "run-700", PID: 700, Group: "storefront", Name: "vite"}) - if _, ok := r.Session(state.Port{PID: 100}); ok { - t.Error("a run with no session reported one") + if got, ok := reg.Session(state.Port{PID: 700}); ok { + t.Errorf("a run with no session reported %+v", got) } - if live := r.SessionRuns(); len(live) != 0 { + live := reg.SessionRuns() + if len(live) != 0 { t.Errorf("SessionRuns = %+v, want empty", live) } - if r.SessionRuns() == nil { + if live == nil { t.Error("SessionRuns returned nil rather than an empty slice") } } // A dead run leaves the session list with it, so "active" means what it says. func TestSessionRunsDropsDeadRuns(t *testing.T) { - r := testRegistry() - r.Register(Record{ID: "run1", PID: 100, Session: agentSession()}) - if live := r.SessionRuns(); len(live) != 0 { + reg := testRegistry() + reg.Register(Record{ID: "run-701", PID: 701, Session: fixtureSession()}) + if live := reg.SessionRuns(); len(live) != 0 { t.Errorf("SessionRuns kept a dead run: %+v", live) } } From dd161a327aa07b9078b68561abca11fe9e2a6d04 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 17:40:18 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(engine):=20=E8=AE=B0=E5=BD=95=20daemon?= =?UTF-8?q?=20=E9=87=8D=E5=86=99=E5=90=8E=E7=9A=84=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 26dbcae:总量 5,530 → 0 行 / 0 段 / 0 文件。审计脚本以 0 退出, 「上游声明可以移除」的判据已经成立;声明本身的移除单独提交。 Co-Authored-By: Claude Code --- engine/UPSTREAM.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/engine/UPSTREAM.md b/engine/UPSTREAM.md index 7e69a5aa..71250055 100644 --- a/engine/UPSTREAM.md +++ b/engine/UPSTREAM.md @@ -31,15 +31,15 @@ **9,621**(对照 `f4c50506`)、**9,659**(对照 `be48afbe`),生产 3,852、 测试 5,758。 -2026-11-12 的 ports、killer、groups、cmd、scanner 与 store 重写在此基础上继续 -下降:对照 `26dbcae` 为 **5,530** 行 / 328 段,分布在 60 个文件(生产 2,073、 -测试 3,457)。只有 `internal/daemon` 仍有复制段。 +2026-11-12 按包重写(ports、killer、groups、cmd、scanner、store、daemon)完成后, +对照 `26dbcae` 的审计输出为空:**0 行 / 0 段 / 0 个文件**,生产与测试都不再有 +复制段。 -按包(对照 `26dbcae`,除 daemon 外已重写): +按包(对照 `26dbcae`): | 包 | 生产 | 测试 | 合计 | | --- | ---: | ---: | ---: | -| `internal/daemon` | 2,073 | 3,457 | 5,530 | +| `internal/daemon` | 0 | 0 | 0 | | `internal/cmd` | 0 | 0 | 0 | | `internal/groups` | 0 | 0 | 0 | | `internal/scanner` | 0 | 0 | 0 | @@ -47,11 +47,11 @@ | `internal/killer` | 0 | 0 | 0 | | `internal/store` | 0 | 0 | 0 | -`internal/claims`、`internal/cmd`、`internal/display`、`internal/testenv`、 -`internal/groups`、`internal/killer`、`internal/ports`、`internal/scanner`、 -`internal/store` 与 `internal/store/claims*.go` 已经归零;`internal/docker` 只剩 -结构体字段与表驱动用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 -199 行。 +`internal/claims`、`internal/cmd`、`internal/daemon`、`internal/display`、 +`internal/testenv`、`internal/groups`、`internal/killer`、`internal/ports`、 +`internal/scanner`、`internal/store` 与 `internal/store/claims*.go` 已经归零; +`internal/docker` 只剩结构体字段与表驱动用例数据。`internal/store` 已整包重写: +生产代码从 1,076 行降到 199 行。 ```sh git clone https://github.com/raskrebs/sonar /tmp/sonar