~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
1053 lines · 26.5 KB Raw History
//go:build linux || darwin

package devsup

import (
	"bufio"
	"cmp"
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"os"
	"os/signal"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"sync"
	"syscall"
	"time"

	"golang.org/x/sys/unix"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/hostos"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/hostsvc"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/fsx"
)

type unit struct {
	spec        hostsvc.UnitSpec
	st          hostsvc.UnitStatus
	pgid        int
	leaderTicks uint64
	order       int
	stopping    bool
	timedOut    bool
	exited      chan struct{}
	restarts    []time.Time
	restartT    *time.Timer
	store       unitStore
}

type unitStore interface {
	socket() string
	launchFiles() ([]*os.File, []string)
	release()
}

type watcher struct {
	prefix string
	ch     chan hostsvc.UnitEvent
}

type spawnReq struct {
	argv  []string
	attr  *os.ProcAttr
	reply chan spawnResult
}

type spawnResult struct {
	pid int
	err error
}

type server struct {
	o        Options
	log      *slog.Logger
	id       string
	boot     string
	hello    hostsvc.ProcHello
	mu       sync.Mutex
	units    map[string]*unit
	seq      int
	watchers map[*watcher]struct{}
	spawn    chan spawnReq
	done     chan struct{}
	life     context.Context
	end      context.CancelFunc
	stopOnce sync.Once
	conns    map[net.Conn]struct{}
	wg       sync.WaitGroup
}

var errStopping = errors.New("devsup: devsup is stopping")

func newID() string {
	b := make([]byte, 16)
	rand.Read(b)
	return hex.EncodeToString(b)
}

func Run(ctx context.Context, o Options) error {
	if o.State == "" || o.Socket == "" {
		return errors.New("devsup: --state and --socket are required")
	}
	log := o.Log
	if log == nil {
		log = slog.New(slog.NewJSONHandler(os.Stderr, nil))
	}
	log = log.With("component", "devsup")
	if err := os.MkdirAll(filepath.Join(o.State, "units"), 0o700); err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(o.Socket), 0o700); err != nil {
		return err
	}
	lockf, err := os.OpenFile(filepath.Join(o.State, "devsup.lock"), os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		return err
	}
	defer lockf.Close()
	if err := hostos.Lock(lockf, hostos.LockExclusive|hostos.LockNonBlocking); err != nil {
		return fmt.Errorf("devsup: another devsup holds %s: %w", lockf.Name(), err)
	}
	if err := becomeSubreaper(); err != nil {
		return fmt.Errorf("devsup: becoming a child subreaper: %w", err)
	}
	boot, err := hostos.BootID()
	if err != nil {
		return err
	}
	s := &server{o: o, log: log, id: newID(), boot: boot, units: map[string]*unit{},
		watchers: map[*watcher]struct{}{}, spawn: make(chan spawnReq), done: make(chan struct{}), conns: map[net.Conn]struct{}{}}
	s.life, s.end = context.WithCancel(context.WithoutCancel(ctx))
	defer s.end()
	ticks, _ := hostos.ProcStart(os.Getpid())
	s.hello = hostsvc.ProcHello{Instance: s.id, PID: os.Getpid(), StartTicks: ticks, BootID: boot,
		Started: time.Now().UTC(), State: o.State, Socket: o.Socket}
	s.sweep()
	abort := func(err error) error {
		s.stopOnce.Do(s.finish)
		s.wg.Wait()
		return err
	}
	s.wg.Go(s.spawner)
	sigchld := make(chan os.Signal, 16)
	signal.Notify(sigchld, syscall.SIGCHLD)
	s.wg.Go(func() { s.reaper(sigchld) })
	os.Remove(o.Socket)
	l, err := net.Listen("unix", o.Socket)
	if err != nil {
		return abort(fmt.Errorf("devsup: listening on %s: %w", o.Socket, err))
	}
	os.Chmod(o.Socket, 0o600)
	if err := fsx.WriteJSON(filepath.Join(o.State, "devsup.json"), s.hello, 0o600); err != nil {
		l.Close()
		return abort(err)
	}
	log.Info("devsup listening", "instance", s.id, "pid", os.Getpid(), "socket", o.Socket, "state", o.State,
		"dieWith", o.DieWith, "label", o.Instance)
	s.wg.Go(s.orphanKiller)
	term := make(chan os.Signal, 2)
	signal.Notify(term, syscall.SIGTERM, syscall.SIGINT)
	signal.Ignore(syscall.SIGHUP, syscall.SIGPIPE)
	if o.DieWith > 0 {
		s.wg.Go(func() { s.dieWith(o.DieWith) })
	}
	s.wg.Go(func() {
		select {
		case sig := <-term:
			log.Info("devsup stopping on a signal", "signal", sig.String())
			s.shutdown()
		case <-ctx.Done():
			s.shutdown()
		case <-s.done:
		}
	})
	s.wg.Go(func() { s.accept(l) })
	<-s.done
	l.Close()
	s.closeConns()
	s.wg.Wait()
	os.Remove(o.Socket)
	os.Remove(filepath.Join(o.State, "devsup.json"))
	log.Info("devsup stopped", "instance", s.id)
	return nil
}

func (s *server) shutdown() {
	s.stopOnce.Do(func() {
		s.wg.Go(func() {
			s.stopAll()
			s.finish()
		})
	})
}

func (s *server) finish() {
	close(s.done)
	s.end()
}

func (s *server) stopAll() {
	s.mu.Lock()
	var names []*unit
	for _, u := range s.units {
		names = append(names, u)
	}
	s.mu.Unlock()
	sort.Slice(names, func(i, j int) bool { return names[i].order > names[j].order })
	for _, u := range names {
		if err := s.stop(u.spec.Name); err != nil {
			s.log.Error("stopping a unit at shutdown", "unit", u.spec.Name, "err", err.Error())
		}
		s.mu.Lock()
		if cur, ok := s.units[u.spec.Name]; ok && cur == u && u.st.State != hostsvc.Active {
			s.collect(u)
		}
		s.mu.Unlock()
	}
	for pid := range attributedTo(s.id) {
		syscall.Kill(pid, syscall.SIGKILL)
	}
}

func (s *server) dieWith(pid int) {
	if err := hostos.AwaitExit(s.life, pid, 0); err != nil {
		return
	}
	s.log.Info("the watched pid exited", "pid", pid)
	s.shutdown()
}

func (s *server) spawner() {
	runtime.LockOSThread()
	for {
		var req spawnReq
		select {
		case <-s.done:
			return
		case req = <-s.spawn:
		}
		p, err := os.StartProcess(req.argv[0], req.argv, req.attr)
		if err != nil {
			req.reply <- spawnResult{err: err}
			continue
		}
		pid := p.Pid
		p.Release()
		req.reply <- spawnResult{pid: pid}
	}
}

func (s *server) reaper(sigchld <-chan os.Signal) {
	for {
		for {
			var ws unix.WaitStatus
			pid, err := unix.Wait4(-1, &ws, unix.WNOHANG, nil)
			if err == unix.EINTR {
				continue
			}
			if pid <= 0 || err != nil {
				break
			}
			s.exited(pid, ws)
		}
		select {
		case <-s.done:
			return
		case <-sigchld:
		}
	}
}

func (s *server) exited(pid int, ws unix.WaitStatus) {
	s.mu.Lock()
	defer s.mu.Unlock()
	var u *unit
	for _, cand := range s.units {
		if cand.st.MainPID == pid && cand.exited != nil {
			u = cand
			break
		}
	}
	if u == nil {
		return
	}
	code, status := int32(CodeExited), int32(0)
	switch {
	case ws.Exited():
		status = int32(ws.ExitStatus())
	case ws.Signaled():
		code = CodeKilled
		if ws.CoreDump() {
			code = CodeDumped
		}
		status = int32(ws.Signal())
	}
	result := "success"
	switch {
	case code == CodeExited && status != 0:
		result = "exit-code"
	case code != CodeExited && u.stopping && u.timedOut:
		result = "timeout"
	case code != CodeExited && u.stopping && syscall.Signal(status) == u.killSignal() && CleanSignal(u.killSignal()):
		result = "success"
	case code == CodeDumped:
		result = "core-dump"
	case code != CodeExited:
		result = "signal"
	}
	u.st.ExecMainCode, u.st.ExecMainStatus, u.st.Result = code, status, result
	s.log.Info("unit exited", "unit", u.spec.Name, "pid", pid, "result", result, "code", code, "status", status,
		"stopping", u.stopping, "ranFor", time.Since(u.st.ActiveSince).Round(time.Millisecond).String())
	u.st.MainPID = 0
	close(u.exited)
	u.exited = nil
	pgid, sig, grace := u.pgid, u.killSignal(), u.stopTimeout()
	inv := u.st.InvocationID
	name := u.spec.Name
	s.wg.Go(func() { s.killRemainder(name, inv, pgid, sig, grace) })
	if !u.stopping && s.shouldRestart(u, result) {
		u.st.State, u.st.SubState = hostsvc.Activating, "auto-restart"
		s.emit(u)
		u.restartT = time.AfterFunc(RestartDelay, func() { s.restart(name, u) })
		return
	}
	if result == "success" {
		u.st.State, u.st.SubState = hostsvc.Inactive, "dead"
		s.emit(u)
		s.collect(u)
		return
	}
	u.st.State, u.st.SubState = hostsvc.Failed, "failed"
	s.persist(u)
	s.emit(u)
	s.log.Info("unit failed", "unit", u.spec.Name, "result", result, "code", code, "status", status)
}

func (s *server) shouldRestart(u *unit, result string) bool {
	switch u.spec.Restart {
	case hostsvc.RestartAlways:
	case hostsvc.RestartOnFailure:
		if result == "success" {
			return false
		}
	default:
		return false
	}
	now := time.Now()
	var keep []time.Time
	for _, t := range u.restarts {
		if now.Sub(t) < StartLimitInterval {
			keep = append(keep, t)
		}
	}
	u.restarts = keep
	if len(keep) >= StartLimitBurst {
		u.st.Result = "start-limit-hit"
		return false
	}
	u.restarts = append(u.restarts, now)
	return true
}

func (s *server) restart(name string, u *unit) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if cur, ok := s.units[name]; !ok || cur != u || u.stopping || u.st.SubState != "auto-restart" {
		return
	}
	if err := s.launch(u); err != nil {
		s.log.Error("restarting a unit", "unit", name, "err", err.Error())
		return
	}
	s.log.Info("unit restarted", "unit", name, "pid", u.st.MainPID, "invocation", u.st.InvocationID)
}

func (s *server) killRemainder(name, inv string, pgid int, sig syscall.Signal, grace time.Duration) {
	set := s.members(name, inv, pgid)
	if len(set) == 0 {
		return
	}
	for _, pid := range set {
		syscall.Kill(pid, sig)
	}
	if s.awaitGone(name, inv, pgid, time.Now().Add(grace)) {
		return
	}
	for _, pid := range s.members(name, inv, pgid) {
		syscall.Kill(pid, syscall.SIGKILL)
	}
}

func (s *server) awaitGone(name, inv string, pgid int, deadline time.Time) bool {
	ctx, cancel := context.WithDeadline(s.life, deadline)
	defer cancel()
	for {
		set := s.members(name, inv, pgid)
		if len(set) == 0 {
			return true
		}
		for _, pid := range set {
			err := hostos.AwaitExit(ctx, pid, 0)
			if ctx.Err() != nil {
				return false
			}
			if err != nil {
				s.log.Warn("waiting for a unit process to exit; the stop waits out its grace", "unit", name, "pid", pid, "err", err.Error())
				<-ctx.Done()
				return false
			}
		}
	}
}

func (s *server) members(name, inv string, pgid int) []int {
	seen := map[int]bool{}
	var out []int
	add := func(pid int) {
		if pid > 0 && !seen[pid] && pid != os.Getpid() {
			seen[pid] = true
			out = append(out, pid)
		}
	}
	for _, p := range procTable() {
		if pgid > 0 && p.pgid == pgid {
			add(p.pid)
			for _, c := range childrenOf(p.pid) {
				if env := procEnv(c, EnvDevsup, EnvUnit); env[EnvDevsup] == s.id && (env[EnvUnit] == "" || env[EnvUnit] == name) {
					add(c)
				}
			}
		}
	}
	for pid, a := range attributedTo(s.id) {
		if a.unit == name && (inv == "" || a.invocation == inv) {
			add(pid)
		}
	}
	return out
}

func (s *server) orphanKiller() {
	if OrphanScanInterval <= 0 {
		return
	}
	t := time.NewTicker(OrphanScanInterval)
	defer t.Stop()
	for {
		select {
		case <-s.done:
			return
		case <-t.C:
		}
		attributed := attributedTo(s.id)
		s.mu.Lock()
		for pid, a := range attributed {
			u, ok := s.units[a.unit]
			if ok && u.exited != nil && u.st.InvocationID == a.invocation {
				continue
			}
			if ok && u.stopping {
				continue
			}
			syscall.Kill(pid, syscall.SIGKILL)
			s.log.Info("killed a process of an inactive unit", "pid", pid, "unit", a.unit, "invocation", a.invocation)
		}
		s.mu.Unlock()
	}
}

func (u *unit) killSignal() syscall.Signal {
	if u.spec.KillSignal != 0 {
		return u.spec.KillSignal
	}
	return syscall.SIGTERM
}

func (u *unit) stopTimeout() time.Duration {
	if u.spec.StopTimeout > 0 {
		return u.spec.StopTimeout
	}
	return DefaultStopTimeout
}

func (s *server) recordPath(name string) string {
	return filepath.Join(s.o.State, "units", name+".json")
}

func (s *server) persist(u *unit) {
	rec := Record{Spec: u.spec, Status: u.st, PGID: u.pgid, LeaderTicks: u.leaderTicks, BootID: s.boot,
		Devsup: s.id, Order: u.order}
	if err := fsx.WriteJSON(s.recordPath(u.spec.Name), rec, 0o600); err != nil {
		s.log.Error("writing a unit record", "unit", u.spec.Name, "err", err.Error())
	}
}

func (s *server) collect(u *unit) {
	if cur, ok := s.units[u.spec.Name]; ok && cur == u {
		delete(s.units, u.spec.Name)
	}
	if u.store != nil {
		u.store.release()
		u.store = nil
	}
	os.Remove(s.recordPath(u.spec.Name))
}

func (s *server) emit(u *unit) {
	ev := hostsvc.UnitEvent{Name: u.spec.Name, Status: u.st}
	for w := range s.watchers {
		if strings.HasPrefix(u.spec.Name, w.prefix) {
			select {
			case w.ch <- ev:
			default:
			}
		}
	}
}

func (s *server) sweep() {
	entries, _ := filepath.Glob(filepath.Join(s.o.State, "units", "*.json"))
	if len(entries) == 0 {
		return
	}
	var table []ProcEntry
	for _, p := range procTable() {
		table = append(table, ProcEntry{PID: p.pid, PGID: p.pgid, Ticks: p.ticks})
	}
	for _, path := range entries {
		b, err := os.ReadFile(path)
		var rec Record
		if err == nil {
			err = json.Unmarshal(b, &rec)
		}
		if err != nil {
			s.log.Error("an unreadable unit record is dropped", "path", path, "err", fmt.Sprint(err))
			os.Remove(path)
			continue
		}
		verdict := SweepNothing
		if rec.BootID == s.boot {
			verdict = SweepDecision(rec, table)
			if verdict == SweepGroup {
				syscall.Kill(-rec.PGID, syscall.SIGKILL)
			}
			if rec.Devsup != "" {
				for pid, a := range attributedTo(rec.Devsup) {
					if a.unit == rec.Spec.Name {
						syscall.Kill(pid, syscall.SIGKILL)
					}
				}
			}
		}
		s.log.Info("swept a unit of a previous devsup", "unit", rec.Spec.Name, "pgid", rec.PGID,
			"leaderTicks", rec.LeaderTicks, "previous", rec.Devsup, "verdict", verdict.String())
		os.Remove(path)
	}
}

func (s *server) accept(l net.Listener) {
	for {
		c, err := l.Accept()
		if err != nil {
			select {
			case <-s.done:
				return
			default:
			}
			if errors.Is(err, net.ErrClosed) {
				return
			}
			continue
		}
		if !s.track(c) {
			c.Close()
			return
		}
		s.wg.Go(func() { s.serve(c) })
	}
}

func (s *server) track(c net.Conn) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	select {
	case <-s.done:
		return false
	default:
	}
	s.conns[c] = struct{}{}
	return true
}

func (s *server) closeConns() {
	s.mu.Lock()
	defer s.mu.Unlock()
	for c := range s.conns {
		c.Close()
	}
	for _, u := range s.units {
		if u.store != nil {
			u.store.release()
			u.store = nil
		}
	}
}

func (s *server) serve(c net.Conn) {
	defer func() {
		s.mu.Lock()
		delete(s.conns, c)
		s.mu.Unlock()
		c.Close()
	}()
	line, err := bufio.NewReader(c).ReadBytes('\n')
	if err != nil {
		return
	}
	var req hostsvc.ProcRequest
	if err := json.Unmarshal(line, &req); err != nil {
		json.NewEncoder(c).Encode(hostsvc.ProcError(fmt.Errorf("devsup: bad request: %w", err)))
		return
	}
	if req.Op == hostsvc.ProcOpWatch {
		s.watch(c, req.Prefix)
		return
	}
	resp := s.handle(req)
	json.NewEncoder(c).Encode(resp)
	if req.Op == hostsvc.ProcOpShutdown {
		s.shutdown()
	}
}

func (s *server) watch(c net.Conn, prefix string) {
	w := &watcher{prefix: prefix, ch: make(chan hostsvc.UnitEvent, 256)}
	s.mu.Lock()
	s.watchers[w] = struct{}{}
	s.mu.Unlock()
	defer func() {
		s.mu.Lock()
		delete(s.watchers, w)
		s.mu.Unlock()
	}()
	enc := json.NewEncoder(c)
	if err := enc.Encode(hostsvc.ProcResponse{}); err != nil {
		return
	}
	gone := make(chan struct{})
	go func() {
		io.Copy(io.Discard, c)
		close(gone)
	}()
	defer func() {
		c.Close()
		<-gone
	}()
	for {
		select {
		case ev := <-w.ch:
			if err := enc.Encode(hostsvc.ProcResponse{Event: &ev}); err != nil {
				return
			}
		case <-gone:
			return
		case <-s.done:
			return
		}
	}
}

func (s *server) handle(req hostsvc.ProcRequest) hostsvc.ProcResponse {
	fail := func(err error) hostsvc.ProcResponse { return hostsvc.ProcError(err) }
	switch req.Op {
	case hostsvc.ProcOpHello, hostsvc.ProcOpShutdown:
		h := s.hello
		return hostsvc.ProcResponse{Hello: &h}
	case hostsvc.ProcOpStart:
		if req.Spec == nil {
			return fail(errors.New("devsup: start without a spec"))
		}
		st, err := s.start(*req.Spec)
		if err != nil {
			return hostsvc.ProcResponse{Err: err.Error(), Status: &st}
		}
		return hostsvc.ProcResponse{Status: &st}
	case hostsvc.ProcOpInspect:
		st := s.inspect(req.Name)
		return hostsvc.ProcResponse{Status: &st}
	case hostsvc.ProcOpStop:
		if err := s.stop(req.Name); err != nil {
			return fail(err)
		}
		return hostsvc.ProcResponse{}
	case hostsvc.ProcOpStopSlice:
		if err := s.stopSlice(req.Slice); err != nil {
			return fail(err)
		}
		return hostsvc.ProcResponse{}
	case hostsvc.ProcOpResetFailed:
		s.resetFailed(req.Name)
		return hostsvc.ProcResponse{}
	case hostsvc.ProcOpList:
		return hostsvc.ProcResponse{Statuses: s.list(req.Prefix)}
	case hostsvc.ProcOpIdentify:
		id, err := s.identify(req.Name, req.PID)
		if err != nil {
			return fail(err)
		}
		return hostsvc.ProcResponse{Identity: &id}
	case hostsvc.ProcOpCheck:
		if req.Identity == nil {
			return fail(errors.New("devsup: check without an identity"))
		}
		obs, err := s.check(*req.Identity)
		if err != nil {
			r := fail(err)
			r.Observation = &obs
			return r
		}
		return hostsvc.ProcResponse{Observation: &obs}
	}
	return fail(fmt.Errorf("devsup: unknown op %q", req.Op))
}

func (s *server) start(spec hostsvc.UnitSpec) (hostsvc.UnitStatus, error) {
	if err := hostsvc.ValidateSpec(spec); err != nil {
		return hostsvc.UnitStatus{}, err
	}
	if err := hostsvc.RefuseUnsupported(hostsvc.ProcCapabilities(), spec); err != nil {
		return hostsvc.UnitStatus{}, err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	select {
	case <-s.done:
		return hostsvc.UnitStatus{}, errStopping
	default:
	}
	if u, ok := s.units[spec.Name]; ok {
		st := u.st
		st.Adopted = true
		return st, nil
	}
	for _, w := range spec.Wants {
		if err := hostsvc.ValidateName(w); err != nil {
			return hostsvc.UnitStatus{}, err
		}
		if _, ok := s.units[w]; !ok {
			return hostsvc.UnitStatus{}, fmt.Errorf("devsup: %s wants %s, which this devsup does not hold", spec.Name, w)
		}
	}
	for _, a := range spec.After {
		if err := hostsvc.ValidateName(a); err != nil {
			return hostsvc.UnitStatus{}, err
		}
	}
	s.seq++
	u := &unit{spec: spec, order: s.seq}
	u.st = hostsvc.UnitStatus{Name: spec.Name, Backend: hostsvc.BackendProc, Description: spec.Description}
	s.units[spec.Name] = u
	if err := s.openFDStore(u); err != nil {
		delete(s.units, spec.Name)
		return u.st, err
	}
	if err := s.launch(u); err != nil {
		return u.st, err
	}
	return u.st, nil
}

func (s *server) openOutput(path string) (*os.File, error) {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return nil, err
	}
	return os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
}

func (s *server) unitEnv(u *unit, inv string) []string {
	var env []string
	for _, k := range []string{"PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "XDG_RUNTIME_DIR"} {
		if v, ok := os.LookupEnv(k); ok {
			env = append(env, k+"="+v)
		}
	}
	if s.o.Instance != "" {
		env = append(env, EnvInstance+"="+s.o.Instance)
	}
	env = append(env, u.spec.Env...)
	env = append(env, hostsvc.ReadEnvFiles(u.spec.EnvFiles)...)
	return append(env, EnvUnit+"="+u.spec.Name, EnvDevsup+"="+s.id, EnvInvocation+"="+inv)
}

func (s *server) launch(u *unit) error {
	logPath := filepath.Join(s.o.State, "logs", u.spec.Name+".log")
	outPath, errPath := u.spec.Output.StdoutFile, u.spec.Output.StderrFile
	outPath = cmp.Or(outPath, logPath)
	errPath = cmp.Or(errPath, logPath)
	out, err := s.openOutput(outPath)
	if err != nil {
		return err
	}
	defer out.Close()
	errf, err := s.openOutput(errPath)
	if err != nil {
		return err
	}
	defer errf.Close()
	null, err := os.Open(os.DevNull)
	if err != nil {
		return err
	}
	defer null.Close()
	dir := u.spec.WorkDir
	dir = cmp.Or(dir, "/")
	inv := newID()
	env := s.unitEnv(u, inv)
	files := []*os.File{null, out, errf}
	if u.store != nil {
		env = append(env, EnvNotifySocket+"="+u.store.socket())
		stored, listenEnv := u.store.launchFiles()
		files = append(files, stored...)
		env = append(env, listenEnv...)
	}
	attr := &os.ProcAttr{Dir: dir, Env: env, Files: files, Sys: hostos.UnitProcAttr()}
	reply := make(chan spawnResult, 1)
	var r spawnResult
	select {
	case s.spawn <- spawnReq{argv: u.spec.Exec, attr: attr, reply: reply}:
		r = <-reply
	case <-s.done:
		r = spawnResult{err: errStopping}
	}
	u.stopping, u.timedOut = false, false
	u.st.InvocationID = inv
	u.st.ActiveSince = time.Now().UTC()
	if r.err != nil {
		u.st.State, u.st.SubState, u.st.Result = hostsvc.Failed, "failed", "exit-code"
		u.st.ExecMainCode, u.st.ExecMainStatus, u.st.MainPID = CodeExited, ExitStatusExec, 0
		s.persist(u)
		s.emit(u)
		return fmt.Errorf("devsup: the start job of %s ended failed: exec %s: %w", u.spec.Name, u.spec.Exec[0], r.err)
	}
	u.pgid = r.pid
	u.leaderTicks, _ = hostos.ProcStart(r.pid)
	u.exited = make(chan struct{})
	u.st.MainPID = r.pid
	u.st.State, u.st.SubState, u.st.Result = hostsvc.Active, "running", "success"
	u.st.ExecMainCode, u.st.ExecMainStatus = 0, 0
	s.persist(u)
	s.emit(u)
	s.log.Info("unit started", "unit", u.spec.Name, "pid", r.pid, "invocation", inv, "exec", u.spec.Exec)
	return nil
}

func (s *server) inspect(name string) hostsvc.UnitStatus {
	s.mu.Lock()
	defer s.mu.Unlock()
	if u, ok := s.units[name]; ok {
		return u.st
	}
	return hostsvc.UnitStatus{Name: name, State: hostsvc.NotFound, Backend: hostsvc.BackendProc}
}

func (s *server) stop(name string) error {
	s.mu.Lock()
	u, ok := s.units[name]
	if !ok {
		s.mu.Unlock()
		return nil
	}
	if u.restartT != nil {
		u.restartT.Stop()
	}
	if u.exited == nil {
		u.stopping = true
		if u.st.SubState == "auto-restart" {
			u.st.State, u.st.SubState = hostsvc.Failed, "failed"
			s.persist(u)
			s.emit(u)
		}
		s.mu.Unlock()
		return nil
	}
	u.stopping = true
	u.st.State, u.st.SubState = hostsvc.Deactivating, "stop-sigterm"
	s.emit(u)
	exited, pgid, inv, sig, grace := u.exited, u.pgid, u.st.InvocationID, u.killSignal(), u.stopTimeout()
	s.mu.Unlock()
	set := s.members(name, inv, pgid)
	for _, pid := range set {
		syscall.Kill(pid, sig)
	}
	deadline := time.Now().Add(grace)
	select {
	case <-exited:
	case <-s.done:
	case <-time.After(grace):
		s.mu.Lock()
		u.timedOut = true
		s.mu.Unlock()
		for _, pid := range s.members(name, inv, pgid) {
			syscall.Kill(pid, syscall.SIGKILL)
		}
		select {
		case <-exited:
		case <-s.done:
		}
	}
	s.awaitGone(name, inv, pgid, deadline)
	for _, pid := range s.members(name, inv, pgid) {
		syscall.Kill(pid, syscall.SIGKILL)
	}
	s.log.Info("unit stopped", "unit", name, "signal", sig.String(), "took", time.Since(deadline.Add(-grace)).Round(time.Millisecond).String())
	return nil
}

func (s *server) stopSlice(slice string) error {
	if err := hostsvc.ValidateSlice(slice); err != nil {
		return err
	}
	s.mu.Lock()
	var hit []*unit
	for _, u := range s.units {
		if hostsvc.SliceContains(slice, u.spec.Slice) {
			hit = append(hit, u)
		}
	}
	s.mu.Unlock()
	sort.Slice(hit, func(i, j int) bool { return hit[i].order > hit[j].order })
	var errs []error
	for _, u := range hit {
		if err := s.stop(u.spec.Name); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

func (s *server) resetFailed(name string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.units[name]
	if !ok || u.st.State != hostsvc.Failed {
		return
	}
	s.collect(u)
}

func (s *server) list(prefix string) []hostsvc.UnitStatus {
	s.mu.Lock()
	defer s.mu.Unlock()
	var out []hostsvc.UnitStatus
	for name, u := range s.units {
		if strings.HasPrefix(name, prefix) {
			out = append(out, u.st)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

func (s *server) belongs(name, inv string, pgid, pid int) bool {
	if p, ok := readProcInfo(pid); ok && pgid > 0 && p.pgid == pgid {
		return true
	}
	env := procEnv(pid, EnvDevsup, EnvUnit, EnvInvocation)
	return env[EnvDevsup] == s.id && env[EnvUnit] == name && env[EnvInvocation] == inv
}

func (s *server) identify(name string, pid int) (hostsvc.Identity, error) {
	s.mu.Lock()
	u, ok := s.units[name]
	var st hostsvc.UnitStatus
	var pgid int
	if ok {
		st, pgid = u.st, u.pgid
	}
	s.mu.Unlock()
	if !ok || st.State != hostsvc.Active {
		return hostsvc.Identity{}, fmt.Errorf("hostsvc: %s is %s, not active, and has no identity to record", name, s.inspect(name).State)
	}
	pid = cmp.Or(pid, st.MainPID)
	ticks, err := pin(pid)
	if err != nil {
		return hostsvc.Identity{}, fmt.Errorf("hostsvc: pinning pid %d of %s: %w", pid, name, err)
	}
	if !s.belongs(name, st.InvocationID, pgid, pid) {
		return hostsvc.Identity{}, fmt.Errorf("hostsvc: pid %d is neither in the process group of %s nor attributed to its invocation %s",
			pid, name, st.InvocationID)
	}
	return hostsvc.Identity{Unit: name, InvocationID: st.InvocationID, PID: pid, StartTicks: ticks, BootID: s.boot,
		Backend: hostsvc.BackendProc, Manager: s.id}, nil
}

func (s *server) check(want hostsvc.Identity) (hostsvc.Observation, error) {
	if want.BootID != s.boot {
		return hostsvc.Observation{Verdict: hostsvc.VerdictLost, Reason: hostsvc.LostHostRebooted,
			Detail: fmt.Sprintf("boot id %s, recorded %s", s.boot, want.BootID)}, nil
	}
	if obs, foreign := hostsvc.ForeignIdentity(hostsvc.ProcCapabilities(), want); foreign {
		return obs, nil
	}
	st := s.inspect(want.Unit)
	lost := func(r hostsvc.LostReason, format string, args ...any) (hostsvc.Observation, error) {
		return hostsvc.Observation{Verdict: hostsvc.VerdictLost, Reason: r, Detail: fmt.Sprintf(format, args...), Status: st}, nil
	}
	if want.Manager != "" && want.Manager != s.id {
		return lost(hostsvc.LostUnitGone, "devsup restarted (%s, recorded %s): every unit it held is gone", s.id, want.Manager)
	}
	switch st.State {
	case hostsvc.NotFound:
		return lost(hostsvc.LostUnitGone, "%s is not loaded", want.Unit)
	case hostsvc.Failed:
		return lost(hostsvc.LostUnitFailed, "%s failed: result %s, exec main code %d status %d",
			want.Unit, st.Result, st.ExecMainCode, st.ExecMainStatus)
	case hostsvc.Inactive:
		return lost(hostsvc.LostUnitExited, "%s is inactive: result %s", want.Unit, st.Result)
	}
	if st.InvocationID != want.InvocationID {
		return s.mismatch(st, "%s runs invocation %s, recorded %s", want.Unit, st.InvocationID, want.InvocationID)
	}
	ticks, err := pin(want.PID)
	if errors.Is(err, errGone) {
		return s.mismatch(st, "recorded pid %d of %s: %v", want.PID, want.Unit, err)
	}
	if err != nil {
		return hostsvc.Observation{Detail: err.Error(), Status: st}, err
	}
	if ticks != want.StartTicks {
		return s.mismatch(st, "pid %d started at tick %d, recorded %d: the pid was reused", want.PID, ticks, want.StartTicks)
	}
	s.mu.Lock()
	pgid := 0
	if u, ok := s.units[want.Unit]; ok {
		pgid = u.pgid
	}
	s.mu.Unlock()
	if !s.belongs(want.Unit, st.InvocationID, pgid, want.PID) {
		return s.mismatch(st, "pid %d is not in %s invocation %s", want.PID, want.Unit, st.InvocationID)
	}
	if st.State != hostsvc.Active {
		return hostsvc.Observation{Verdict: hostsvc.VerdictUnknown, Detail: fmt.Sprintf("%s is %s", want.Unit, st.State), Status: st}, nil
	}
	return hostsvc.Observation{Verdict: hostsvc.VerdictRunning, Status: st}, nil
}

func (s *server) mismatch(st hostsvc.UnitStatus, format string, args ...any) (hostsvc.Observation, error) {
	obs := hostsvc.Observation{Verdict: hostsvc.VerdictLost, Reason: hostsvc.LostIdentityMismatch,
		Detail: fmt.Sprintf(format, args...), Status: st}
	if err := s.stop(st.Name); err != nil {
		return obs, fmt.Errorf("hostsvc: %s does not match its record and stopping it failed: %w", st.Name, err)
	}
	obs.Stopped = true
	return obs, nil
}