261026.11 · 23fe4654f12b86897a15d2e2dcc48f5d4c38df31
--restart leaves another binary's servers alone
The package's units and a build from make install both run --restart and share one registry, so each swapped the other's servers onto itself. The package unit could put a dev server back on a package binary days older, and pages showed old code without saying so.
Each server now records its binary in its registry. --restart with no address moves only the servers and persistent trees its own binary started, and names each one it leaves. -f takes them all. A named address is taken whichever binary runs it, so make run still works. make install-deb passes -f, since it removes the make install link. --list and --kill print each tree's binary.
reading declarations
@@ -41,8 +41,11 @@ clean: rm -rf build # install and install-deb end by moving running servers to the new binary,-# so no server keeps old code. A server on port 80 stays put under the-# binary from make install, which cannot bind it. install also ships the+# so no server keeps old code. install moves only the servers its own+# binary started, so the package keeps its servers. install-deb removes the+# make install link, so it takes every server with --force. A server on+# port 80 stays put under the binary from make install, which cannot bind+# it. install also ships the # user unit, disabled, which serves .LOCK trees again at login; it never # uses sudo, so the system template comes only with the package. USERUNIT := $(HOME)/.config/systemd/user/$(NAME).service@@ -104,7 +107,7 @@ install-deb: uninstall sudo -v; auth=$$?; \ wait $$build && [ $$auth -eq 0 ] sudo apt install --reinstall ./$(DEB)- -/usr/bin/$(NAME) --restart+ -/usr/bin/$(NAME) --restart --force uninstall-deb: sudo apt remove $(NAME)
@@ -9,7 +9,7 @@ It is fast. A page from a running server takes one to three milliseconds, a serv ``` www [-p] [--foreground] [--no-open] [-a ADDR] [DIR] www [-p] [--foreground] [--no-open] --share[=HOST] [--secret] [DIR]-www --restart [--no-open] [-a ADDR | --share[=HOST]] [DIR]+www --restart [-f] [--no-open] [-a ADDR | --share[=HOST]] [DIR] www -l [-a ADDR | --share[=HOST]] [DIR] www --kill [-a ADDR | --share[=HOST]] www --commit=REV [DIR]@@ -25,7 +25,7 @@ The URL is all it prints, so `url=$(www --no-open)` works. Running `www` again i Trees on one address share one server. The first `www` starts it in the background, and later ones hand their tree to it and return. When something other than your www on that address holds the port, `www` fails at once with `E98` and names the listeners there. The server exits when its last lock is gone. SIGTERM stops it at once and removes every lock but a persistent `.LOCK`, as does the Kill server button on the index at `/`.-A running server keeps the binary it started with. `www --restart` moves every server's trees to the current binary, and `make install` and `make install-deb` run it for you; open tabs reload onto the new binary, and it says how many did. A binary without the capability for port 80, such as the one from `make install`, leaves a port-80 server on its old binary and says so.+A running server keeps the binary it started with. `www --restart` moves every server's trees to the current binary, and `make install` and `make install-deb` run it for you; open tabs reload onto the new binary, and it says how many did. It moves only the servers its own binary started, so the package and a build from `make install` leave each other's servers alone; it names each server it leaves, and `-f` takes them too. `make install-deb` passes `-f`, since it removes the `make install` link. A binary without the capability for port 80, such as the one from `make install`, leaves a port-80 server on its old binary and says so. The index at `/` lists the server's locks: each tree with the time its lock was written and a Delete lock button, and the lock's path on hover. Trees on other addresses follow as plain links, since only their own server may stop them. The header shows when the server started, and a warning appears when the binary on disk has changed since. To reach the server by name, add a line such as `127.31.1.1 www` to `/etc/hosts`. Type the name with its slash, eg `www/`, or the browser searches for it.@@ -39,8 +39,9 @@ To reach the server by name, add a line such as `127.31.1.1 www` to `/etc/hosts` - `--stamp`: show the binary's build time, the seconds since, its commit and the page's render time, small and faint, above each page's header. `make run` sets it; `--restart` passes it to the new servers when given, and leaves it off otherwise - `--cache=SIZE`: memory a new server may spend on cached git answers, eg `64M`; `0` caches nothing. The default is `32M`, and `--restart` goes back to it - `--disk=SIZE`: disk a new server may fill with online repos, eg `4G`; it drops the repos read longest ago. The default is `2G`, and `--restart` goes back to it-- `--restart`: replace every server with one running this binary, serving the same trees, and print each tree's URL; an address with persistent trees and no server gets one; with `-a` or `--share`, only the server on that address; with a directory, it also serves that directory there and opens it in the browser unless a page of it was open, and says which-- `-l`, `--list`: print each served tree's directory and URL as keyval, on every address; with `-a` or `--share`, on that address only; with a directory, only the trees at or below it. A directory that holds no served tree prints nothing; one that does not exist fails with `E2`+- `--restart`: replace every server this binary started with one running it now, serving the same trees, and print each tree's URL; an address with persistent trees and no server gets one, when this binary served it last; with `-a` or `--share`, only the server on that address, whichever binary runs it; with a directory, it also serves that directory there and opens it in the browser unless a page of it was open, and says which+- `-f`, `--force`: with `--restart`, take the servers and persistent trees of other binaries too+- `-l`, `--list`: print each served tree's directory, URL and binary as keyval, on every address; with `-a` or `--share`, on that address only; with a directory, only the trees at or below it. A directory that holds no served tree prints nothing; one that does not exist fails with `E2` - `--kill`: stop every server and print the trees it served, as `--list` does; with `-a` or `--share`, only the server on that address - `--commit=REV`: print as keyval the commit REV names in the repo of DIR, or of the current directory: its full hash, its stardate and its subject. REV is a stardate, eg `261024.4`, a hash or any name git takes, so `git show $(www --commit=261024.4 | sed -n 's/^hash //p')` shows a commit seen on a page. A day and x, eg `261026.x`, works while one commit alone holds it. A commit or repo that is not there fails with `E2` - `-V`, `--version`: print when the binary was built and the commit it came from, as the stamp says it, eg `www built 2026-10-21 12:49, from 1aa282c+modified`@@ -48,13 +49,15 @@ To reach the server by name, add a line such as `127.31.1.1 www` to `/etc/hosts` Options follow GNU style. Short ones combine, as in `-pa 127.31.1.2`, and options may follow the directory. Optional values go joined, as in `--share=192.168.1.5`; with a space the address would be read as the directory, and www says so. `-f` and `-n` mean force and dry run in every tool of the author's, so `--foreground` and `--no-open` have no short form. The help text is written in docopt notation, except that docopt has no way to write the optional `=HOST`. -`--list` and `--kill` print [keyval](https://unixfile.github.io/keyval/): two lines per tree, sorted, with every index the same width.+`--list` and `--kill` print [keyval](https://unixfile.github.io/keyval/): three lines per tree, sorted, with every index the same width. The binary line is missing for a server that started before www recorded it. ```+tree.0.binary /usr/bin/www tree.0.dir /home/t/keep/notes tree.0.url http://127.31.1.1/keep/notes/+tree.1.binary /home/t/scratch/github.com/unixfile/www/build/www tree.1.dir /home/t/scratch/github.com/unixfile/www-tree.1.url http://127.31.1.1/scratch/github.com/unixfile/www/+tree.1.url http://127.31.1.1:8080/scratch/github.com/unixfile/www/ ``` Two variables set the default addresses. `--help` shows each value and where it comes from, and an error from a bad value names its variable.
@@ -23,7 +23,8 @@ type servedTree struct { token string // the secret path's token, or "" since time.Time // when the lock was written lockPath string- persist bool // the lock outlives its server+ persist bool // the lock outlives its server+ binary string // the server's binary, or "" when unrecorded } // readTree reads the lock that a registry link targets and reports whether@@ -35,7 +36,7 @@ func readTree(ep netip.AddrPort, target string) (servedTree, bool) { return servedTree{}, false } t := servedTree{ep: ep, pid: l.pid, dir: filepath.Dir(lockPath), url: l.url, token: token,- lockPath: lockPath, persist: isPersist(lockPath)}+ lockPath: lockPath, persist: isPersist(lockPath), binary: binaryOf(ep)} if info, err := os.Stat(lockPath); err == nil { t.since = info.ModTime() }@@ -128,12 +129,16 @@ func list(c config) int { return 0 } -// writeTrees prints trees as keyval: a tree.N.dir and a tree.N.url line-// for each, every index the same width, so the lines sort in key order.-// No trees print nothing, which is an empty keyval file.+// writeTrees prints trees as keyval: a tree.N.binary line when the binary+// is known, then a tree.N.dir and a tree.N.url line for each, every index+// the same width, so the lines sort in key order. No trees print nothing,+// which is an empty keyval file. func writeTrees(w io.Writer, trees []servedTree) { width := len(strconv.Itoa(max(len(trees)-1, 0))) for i, t := range trees {+ if t.binary != "" {+ fmt.Fprintf(w, "tree.%0*d.binary %s\n", width, i, kvValue(t.binary))+ } fmt.Fprintf(w, "tree.%0*d.dir %s\n", width, i, kvValue(t.dir)) fmt.Fprintf(w, "tree.%0*d.url %s\n", width, i, kvValue(t.url)) }
@@ -21,11 +21,12 @@ func TestWriteTrees(t *testing.T) { trees[3].dir = `/a\b` trees[4].dir = "/tab\there" trees[5].dir = "/ends in space "+ trees[7].binary = "/bin/sh" b.Reset() writeTrees(&b, trees) lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n")- if len(lines) != 22 {- t.Fatalf("%d lines, want 22", len(lines))+ if len(lines) != 23 {+ t.Fatalf("%d lines, want 23", len(lines)) } for i := 1; i < len(lines); i++ { if lines[i-1] >= lines[i] {@@ -38,6 +39,7 @@ func TestWriteTrees(t *testing.T) { `tree.03.dir /a\\b`, `tree.04.dir /tab\there`, `tree.05.dir /ends in space \0`,+ "tree.07.binary /bin/sh", "tree.10.url http://u/", } { if !strings.Contains(b.String(), want+"\n") {
@@ -75,6 +75,7 @@ type config struct { help bool version bool restart bool+ force bool // --restart takes servers of other binaries too list bool kill bool commit string // the commit --commit looks up@@ -85,7 +86,7 @@ type config struct { var usageText = strings.NewReplacer("@NAME@", progName, "@ENV@", envPrefix).Replace(`Usage: @NAME@ [-p] [--foreground] [--no-open] [-a ADDR] [DIR] @NAME@ [-p] [--foreground] [--no-open] --share[=HOST] [--secret] [DIR]- @NAME@ --restart [--no-open] [-a ADDR | --share[=HOST]] [DIR]+ @NAME@ --restart [-f] [--no-open] [-a ADDR | --share[=HOST]] [DIR] @NAME@ -l [-a ADDR | --share[=HOST]] [DIR] @NAME@ --kill [-a ADDR | --share[=HOST]] @NAME@ --commit=REV [DIR]@@ -118,13 +119,17 @@ Options: eg 64M; 0 caches nothing [default: 32M] --disk=SIZE disk a new server may fill with online repos, eg 4G; it drops the repos read longest ago [default: 2G]- --restart replace every server with one running this binary,- serving the same trees, and start one for persistent- trees that have none; with -a or --share, only the- server on that address; with DIR, serve it too and+ --restart replace every server this binary started with one+ running it now, serving the same trees, and start+ one for its persistent trees that have none; with+ -a or --share, only the server on that address,+ whichever binary runs it; with DIR, serve it too and open it unless a page of it was open- -l, --list print each served tree's directory and URL as- keyval; with DIR, only the trees at or below it+ -f, --force with --restart, take the servers and persistent+ trees of other binaries too, eg the package's+ -l, --list print each served tree's directory, URL and the+ binary that serves it as keyval; with DIR, only the+ trees at or below it --kill stop every server and print the trees it served as --list does; with -a or --share, only the server on that address@@ -147,6 +152,7 @@ func (c *config) switches() map[string]*bool { "help": &c.help, "version": &c.version, "restart": &c.restart,+ "force": &c.force, "list": &c.list, "kill": &c.kill, }@@ -154,7 +160,7 @@ func (c *config) switches() map[string]*bool { // -f and -n are reserved for --force and --dry-run in every tool of the // user's, so --foreground and --no-open have no short form.-var shorts = map[byte]string{'p': "persist", 'h': "help", 'V': "version", 'l': "list"}+var shorts = map[byte]string{'p': "persist", 'h': "help", 'V': "version", 'l': "list", 'f': "force"} // parseArgs reads options the GNU way. Short ones combine, as in -pl, and // take a value joined or apart: -aIP or -a IP. Long ones take --addr IP or@@ -268,6 +274,9 @@ func parseArgs(args []string) (config, error) { c.sharing || c.secret || c.addr != "" || c.cache != "" || c.disk != "") { return c, &errno{syscall.EINVAL, "--commit takes a directory and no other option"} }+ if c.force && !c.restart {+ return c, &errno{syscall.EINVAL, "--force goes with --restart"}+ } if c.restart && c.fg { return c, &errno{syscall.EINVAL, "--restart takes no --foreground"} }
@@ -202,19 +202,64 @@ func (r *registry) writePages(paths []string) { } } -// markServer records this process as the server of the registry.+// binaryFile, in a registry, names the binary of the last server on its+// endpoint. It outlives the server, unlike serverFile, so --restart can+// tell which binary persistent trees wait for, and leaves the servers of+// another install alone.+const binaryFile = "binary"++// markServer records this process as the server of the registry, and its+// binary. func (r *registry) markServer() error {+ if exe, err := os.Executable(); err == nil {+ if err := os.WriteFile(filepath.Join(r.dir, binaryFile), []byte(exe+"\n"), 0o600); err != nil {+ return err+ }+ } return os.WriteFile(filepath.Join(r.dir, serverFile), []byte(fmt.Sprintf("pid: %d\nboot_id: %s\n", os.Getpid(), bootID())), 0o600) } +// binaryOf reads which binary last served ep, or "" for a registry with no+// record, eg one older than the record.+func binaryOf(ep netip.AddrPort) string {+ dir, err := registryDir(ep)+ if err != nil {+ return ""+ }+ b, err := os.ReadFile(filepath.Join(dir, binaryFile))+ if err != nil {+ return ""+ }+ return strings.TrimSuffix(string(b), "\n")+}++// otherBinary names the binary that last served ep when that is not this+// binary and still exists. A registry with no record, or whose binary is+// gone, belongs to whichever binary restarts it.+func otherBinary(ep netip.AddrPort) string {+ b := binaryOf(ep)+ exe, err := os.Executable()+ if b == "" || err != nil || b == exe {+ return ""+ }+ if _, err := os.Stat(b); err != nil {+ return ""+ }+ return b+}+ // unmarkServer removes the record, unless another server has written its-// own.+// own. The binary record stays while persistent trees wait for a server,+// and goes with the last link. func (r *registry) unmarkServer() { p := filepath.Join(r.dir, serverFile) if b, err := os.ReadFile(p); err == nil && parseLock(b).pid == os.Getpid() { os.Remove(p) }+ if links, err := r.links(); err == nil && len(links) == 0 {+ os.Remove(filepath.Join(r.dir, binaryFile))+ } } // serverOf reads the server record of ep, and reports whether that server
@@ -20,10 +20,12 @@ import ( // 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. With --stamp, the new-// servers show the build stamp. 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.+// 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 --stamp, the new servers show the build stamp. 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 {@@ -36,13 +38,23 @@ func restart(c config) int { 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 {+ 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.stamp) } 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.stamp); err == nil {
@@ -0,0 +1,84 @@+package main++import (+ "net/http"+ "os"+ "path/filepath"+ "syscall"+ "testing"+ "time"+)++// A server records its binary, which outlives it. --restart leaves the+// server and the persistent trees of another binary that still exists,+// and takes those of a binary that is gone.+func TestRestartLeavesOtherBinaries(t *testing.T) {+ ep := testEndpoint(t)+ dir := t.TempDir()+ os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)+ base := "http://" + ep.String()+ reg := filepath.Join(os.Getenv("XDG_STATE_HOME"), progName, endpointName(ep))+ record := filepath.Join(reg, binaryFile)+ exe, _ := os.Executable()++ _, served := start(t, config{dir: dir, ep: ep, persist: true})+ if got := binaryOf(ep); got != exe {+ t.Fatalf("binary record %q, want %q", got, exe)+ }+ if trees, _ := liveTrees(ep); len(trees) != 1 || trees[0].binary != exe {+ t.Errorf("listed trees: %+v", trees)+ }+ if got := otherBinary(ep); got != "" {+ t.Errorf("own binary reads as other: %q", got)+ }++ // /bin/sh stands in for the package's binary.+ os.WriteFile(record, []byte("/bin/sh\n"), 0o600)+ if got := otherBinary(ep); got != "/bin/sh" {+ t.Fatalf("other binary %q, want /bin/sh", got)+ }+ if code := restart(config{restart: true}); code != 0 {+ t.Errorf("restart exit %d", code)+ }+ select {+ case <-served:+ t.Fatal("restart stopped the server of another binary")+ case <-time.After(200 * time.Millisecond):+ }+ if status(t, base+dir+"/a.txt?raw") != http.StatusOK {+ t.Error("the other binary's tree went")+ }++ syscall.Kill(os.Getpid(), syscall.SIGHUP)+ select {+ case <-served:+ case <-time.After(5 * time.Second):+ t.Fatal("server did not stop")+ }+ os.WriteFile(record, []byte("/bin/sh\n"), 0o600)+ if code := restart(config{restart: true}); code != 0 {+ t.Errorf("restart exit %d", code)+ }+ if _, ok := readLive(ep, filepath.Join(dir, persistName(ep))); ok {+ t.Error("restart served the persistent trees of another binary")+ }++ os.WriteFile(record, []byte(filepath.Join(t.TempDir(), "gone")+"\n"), 0o600)+ if got := otherBinary(ep); got != "" {+ t.Errorf("a gone binary reads as other: %q", got)+ }+ os.Remove(record)+ if got := otherBinary(ep); got != "" {+ t.Errorf("no record reads as other: %q", got)+ }+}++func TestForceGoesWithRestart(t *testing.T) {+ if _, err := parseArgs([]string{"-f", "."}); err == nil {+ t.Error("-f without --restart parsed")+ }+ c, err := parseArgs([]string{"--restart", "-f"})+ if err != nil || !c.force {+ t.Errorf("--restart -f: %+v, %v", c, err)+ }+}