@ 261101.7
current
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"net/netip"
	neturl "net/url"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"syscall"
	"time"
)

// restart replaces the server on c.ep, when -a or --share names it, or
// else every server, with one running the current binary and serving the
// same trees, as --list and --kill pick their servers. An address with no
// server but with persistent locks in its registry gets a server again,
// which is how trees come back after a reboot. Without a named address or
// --force, it leaves the addresses another binary last served, so the
// package's unit and a build from make install never take each other's
// servers. With --debug, the new servers show what serves debugging, and with --no-bug they hide the bug button. With a
// directory, it then serves that directory on c.ep, as a start does, and
// opens it unless a page of it was open on the old server.
func restart(c config) int {
	eps, err := endpoints()
	if err != nil {
		return failNow(errnoOf(err), err.Error())
	}
	named := c.addr != "" || c.sharing
	if named {
		eps = []netip.AddrPort{c.ep}
	}
	code := 0
	var open []string // pages that were open on c.ep's old server
	for _, ep := range eps {
		other := ""
		if !named && !c.force {
			other = otherBinary(ep)
		}
		pid, trees, err := servedTrees(ep)
		if errors.Is(err, syscall.ESRCH) {
			if kept := persistedTrees(ep); len(kept) > 0 && other != "" {
				say("the persistent trees on %s wait for %s, so they stay; -f serves them with this binary", ep, other)
				continue
			} else if len(kept) > 0 {
				err = startTrees(ep, kept, c.debug, c.noBug)
			} else if !named || c.dir != "" {
				continue
			}
		} else if err == nil && other != "" {
			say("the server on %s runs %s, so it stays; -f moves it to this binary", ep, other)
			continue
		} else if err == nil {
			var pages []string
			if pages, err = restartServer(ep, pid, trees, c.debug, c.noBug); err == nil {
				say("the server on %s is replaced; %s", ep, reloading(len(pages)))
			}
			if ep == c.ep {
				open = pages
			}
		}
		if err != nil && code == 0 {
			code = failNow(errnoOf(err), err.Error())
		} else if err != nil {
			failNow(errnoOf(err), err.Error())
		}
	}
	if c.dir != "" && code == 0 {
		code = startDir(c, open)
	}
	return code
}

// reloading says how many open pages a new server takes over.
func reloading(n int) string {
	switch n {
	case 0:
		return "no page was open"
	case 1:
		return "1 open page reloads onto it"
	}
	return strconv.Itoa(n) + " open pages reload onto it"
}

// say tells the user and the log what a restart did.
func say(format string, args ...any) {
	logInfo(format, args...)
	if !stderrIsJournal() {
		fmt.Fprintf(os.Stderr, format+"\n", args...)
	}
}

// startDir serves c.dir on c.ep once the servers are replaced. A tree the
// old server served has had its URL printed; it opens in the browser only
// when none of open, the old server's pages, lies in it, since an open
// page reloads onto the new server. A hidden tab holds no stream, so it
// does not count. Any other directory starts as from the command line.
func startDir(c config, open []string) int {
	dir, err := filepath.Abs(c.dir)
	if err != nil {
		return failNow(errnoOf(err), "bad directory: "+err.Error())
	}
	if _, trees, err := servedTrees(c.ep); err == nil {
		for _, t := range trees {
			if t.dir != dir {
				continue
			}
			if c.noopen {
				return 0
			}
			n := 0
			if u, err := neturl.Parse(t.url); err == nil {
				for _, p := range open {
					if strings.HasPrefix(p, u.Path) {
						n++
					}
				}
			}
			if n > 0 {
				say("%s has %s, so no browser opens", dir, pagesOf(n))
				return 0
			}
			say("no page of %s was open, so one opens", dir)
			openBrowser(t.url)
			return 0
		}
	}
	exe, err := os.Executable()
	if err != nil {
		return failNow(errnoOf(err), "cannot find own executable: "+err.Error())
	}
	args := []string{"-a", c.ep.String()}
	if shared(c.ep) {
		args = []string{"--share=" + c.ep.String()}
	}
	if c.noopen {
		args = append(args, "--no-open")
	}
	if c.debug {
		args = append(args, "--debug")
	}
	if c.noBug {
		args = append(args, "--no-bug")
	}
	cmd := exec.Command(exe, append(args, "--", dir)...)
	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
	if err := cmd.Run(); err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) {
			// The child has printed its own error.
			return ee.ExitCode()
		}
		return failNow(errnoOf(err), "cannot serve "+dir+": "+err.Error())
	}
	return 0
}

// pagesOf counts open pages in a sentence.
func pagesOf(n int) string {
	if n == 1 {
		return "1 open page"
	}
	return strconv.Itoa(n) + " open pages"
}

// restartServer reads the trees from the registry, sends the server
// SIGTERM, waits for its locks to go and starts each tree again. It
// returns the pages that held a stream to the old server, from the record
// the server leaves as it stops. A binary that could not bind the
// server's port leaves the server alone, since stopping it would drop its
// trees.
func restartServer(ep netip.AddrPort, pid int, trees []servedTree, debug, noBug bool) ([]string, error) {
	if ep.Port() < 1024 && !lowPortOK() {
		return nil, &errno{syscall.EACCES, "this binary cannot bind port " + strconv.Itoa(int(ep.Port())) +
			", so the server on " + ep.String() + " keeps its old one. Run the installed package's " + progName + ", which holds cap_net_bind_service"}
	}
	var record string
	if dir, err := registryDir(ep); err == nil {
		record = filepath.Join(dir, pagesFile)
		os.WriteFile(record, nil, 0o600)
	}
	if err := stopServer(ep, pid, trees); err != nil {
		os.Remove(record)
		return nil, err
	}
	var pages []string
	if b, err := os.ReadFile(record); record != "" && err == nil {
		// A path may hold spaces, so lines divide them.
		pages = strings.FieldsFunc(string(b), func(r rune) bool { return r == '\n' })
		os.Remove(record)
	}
	return pages, startTrees(ep, trees, debug, noBug)
}

// startTrees serves each tree on ep through this binary, as a start from
// the command line would, persistent ones with --persist. The first start
// runs the server, so --debug and --no-bug reach it.
func startTrees(ep netip.AddrPort, trees []servedTree, debug, noBug bool) error {
	if ep.Port() < 1024 && !lowPortOK() {
		return &errno{syscall.EACCES, "this binary cannot bind port " + strconv.Itoa(int(ep.Port())) +
			", so it cannot serve " + ep.String() + ". Run the installed package's " + progName + ", which holds cap_net_bind_service"}
	}
	exe, err := os.Executable()
	if err != nil {
		return &errno{errnoOf(err), "cannot find own executable: " + err.Error()}
	}
	var failed error
	for _, t := range trees {
		args := []string{"--no-open", "-a", ep.String()}
		if shared(ep) {
			args = []string{"--no-open", "--share=" + ep.String()}
		}
		// The token goes in the environment, since ps shows the command
		// line.
		var env []string
		if t.token != "" {
			args = append(args, "--secret")
			env = append(os.Environ(), secretEnv+"="+t.token)
		}
		if t.persist {
			args = append(args, "--persist")
		}
		if debug {
			args = append(args, "--debug")
		}
		if noBug {
			args = append(args, "--no-bug")
		}
		cmd := exec.Command(exe, append(args, "--", t.dir)...)
		cmd.Env = env
		cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
		if err := cmd.Run(); err != nil && failed == nil {
			code := syscall.EIO
			var ee *exec.ExitError
			if errors.As(err, &ee) {
				code = syscall.Errno(ee.ExitCode())
			}
			// The child has printed its own error.
			failed = &errno{code, "cannot serve " + t.dir + " again on " + ep.String()}
		}
	}
	return failed
}

// kill stops the server on c.ep when -a or --share names it, else every
// server, and prints the trees each one served as --list does.
func kill(c config) int {
	eps, err := endpoints()
	if err != nil {
		return failNow(errnoOf(err), err.Error())
	}
	named := c.addr != "" || c.sharing
	if named {
		eps = []netip.AddrPort{c.ep}
	}
	// The stopped trees print once at the end, since keyval numbers them
	// across every server.
	var stopped []servedTree
	code := 0
	for _, ep := range eps {
		pid, trees, err := servedTrees(ep)
		if errors.Is(err, syscall.ESRCH) && !named {
			continue
		}
		if err == nil {
			err = stopServer(ep, pid, trees)
		}
		if err != nil {
			code = failNow(errnoOf(err), err.Error())
			continue
		}
		stopped = append(stopped, trees...)
	}
	writeTrees(os.Stdout, stopped)
	return code
}

// stopServer sends the server on ep SIGTERM and waits until it has removed
// the locks of trees.
func stopServer(ep netip.AddrPort, pid int, trees []servedTree) error {
	logInfo("sends SIGTERM to the server on %s, pid %d", ep, pid)
	if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
		return &errno{errnoOf(err), "cannot stop the server on " + ep.String() + ": " + err.Error()}
	}
	dirs := make([]string, len(trees))
	for i, t := range trees {
		dirs[i] = t.dir
	}
	if !waitGone(dirs, ep) {
		return &errno{syscall.ETIMEDOUT, "the server on " + ep.String() + " kept its locks"}
	}
	return nil
}

// servedTrees returns the server's PID and the trees it serves, sorted by
// directory.
func servedTrees(ep netip.AddrPort) (int, []servedTree, error) {
	trees, err := liveTrees(ep)
	if err != nil {
		return 0, nil, err
	}
	if len(trees) == 0 {
		return 0, nil, &errno{syscall.ESRCH, "no server runs on " + ep.String()}
	}
	slices.SortFunc(trees, func(a, b servedTree) int { return strings.Compare(a.dir, b.dir) })
	return trees[0].pid, trees, nil
}

// waitGone waits for the server to withdraw every tree and drop its
// record; persistent locks stay. It closes its listener as it does, so a
// new server can bind before the old one exits.
func waitGone(dirs []string, ep netip.AddrPort) bool {
	for end := time.Now().Add(joinWait); time.Now().Before(end); time.Sleep(20 * time.Millisecond) {
		if _, up := serverOf(ep); !up && !slices.ContainsFunc(dirs, func(dir string) bool {
			_, err := os.Lstat(filepath.Join(dir, lockName(ep)))
			return !errors.Is(err, fs.ErrNotExist)
		}) {
			return true
		}
	}
	return false
}

// persistedTrees returns the trees whose persistent locks the registry of
// ep still links to, for a server that is not running.
func persistedTrees(ep netip.AddrPort) []servedTree {
	dir, err := registryDir(ep)
	if err != nil {
		return nil
	}
	links, _ := readLinks(dir)
	var trees []servedTree
	for _, target := range links {
		lockPath, token := splitTarget(target)
		if !isPersist(lockPath) {
			continue
		}
		if _, err := os.Stat(lockPath); err != nil {
			continue
		}
		trees = append(trees, servedTree{ep: ep, dir: filepath.Dir(lockPath), token: token, lockPath: lockPath, persist: true})
	}
	slices.SortFunc(trees, func(a, b servedTree) int { return strings.Compare(a.dir, b.dir) })
	return trees
}