261024.23 · 9ceefbd589221fc686c18d0278d9074fe465fe79
make run opens a browser only when no page of the tree is open
--restart takes a directory: after replacing the servers it serves the directory and opens it unless a page of it held a live stream to the old server. The restart asks the stopping server for its open pages through a record in the registry, and says how many reload onto each new server. make run is now one --restart call, so a Delete lock click no longer decides whether a tab opens.
reading declarations
@@ -12,17 +12,12 @@ build: # run serves this directory from build/ with --stamp, on port 8080 beside # an installed server on port 80. A server already there is replaced by # this build, serving the same trees, so its open pages go on under the-# new code; only the first run opens the browser. --restart exits with-# ESRCH, 3, when no server runs there; any other failure stops the run.+# new code; the browser opens only when no page of this directory was+# open, and www says which happened. RUNADDR := 127.31.$(shell expr $$(id -u) - 1000).1:8080 run: build- @out=$$(build/$(NAME) --restart --stamp -a $(RUNADDR) 2>&1 >/dev/null); \- case $$? in \- 0) build/$(NAME) -n --stamp -a $(RUNADDR) ;; \- 3) build/$(NAME) --stamp -a $(RUNADDR) ;; \- *) echo "$$out" >&2; exit 1 ;; \- esac+ @build/$(NAME) --restart --stamp -a $(RUNADDR) . test: go vet ./...
@@ -9,7 +9,7 @@ It is fast. A page from a running server takes one to three milliseconds, a serv ``` www [-fnph] [-a ip[:port]] [dir] www [-fnph] --share[=host[:port]] [--secret[=token]] [dir]-www --restart [-a ip[:port] | --share[=host[:port]]]+www --restart [-n] [-a ip[:port] | --share[=host[:port]]] [dir] www -l [-a ip[:port] | --share[=host[:port]]] [dir] www --kill [-a ip[:port] | --share[=host[:port]]] ```@@ -22,7 +22,7 @@ The URL is all it prints, so `url=$(www -n)` works. Running `www` again in a ser 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. 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. 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.@@ -36,9 +36,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+- `--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 URL and directory, on every address; with `-a` or `--share`, on that address only; with a directory, 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+- `--kill`: stop every server and print the trees it served, as `--list` does; 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 - `--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` - `-h`, `--help`: print the options @@ -81,7 +81,7 @@ www needs git. `make deb` builds a Debian package in `build/`, such as `www_0~git20261015223930.20f02fd_amd64.deb`, versioned by the last commit's UTC time and hash. It depends on git. For another architecture, pass `ARCH`, eg `make deb ARCH=arm64`. `make install-deb` builds the package while sudo asks for the password, then installs it with `sudo apt install`, which pulls in git. It first removes the `~/.local/bin` link from `make install`, which would otherwise shadow the package's binary. Run it again to replace an installed build, even of the same commit. `make uninstall-deb` removes the package. -To try a change without installing it, `make run` builds `build/www` and serves this directory on port 8080 with `--stamp`, beside an installed server on port 80. A later run replaces the server on 8080 with the new build, serving the same trees, and open pages go on under it, so edit and `make run` again. Only the first run opens the browser. `www --kill -a 127.31.N.1:8080` stops it.+To try a change without installing it, `make run` builds `build/www` and serves this directory on port 8080 with `--stamp`, beside an installed server on port 80. A later run replaces the server on 8080 with the new build, serving the same trees, and open pages go on under it, so edit and `make run` again. It opens the browser only when no page of this directory was open on the old server, and says which happened. A hidden tab holds no connection, so it does not count and gets a second tab. `www --kill -a 127.31.N.1:8080` stops it. `make bench` times what a reader waits for: a search across a generated tree of 4,000 files and 24 MB, and a code page's first view per language: lexing alone, the whole page, and the page with scope boxes. `BENCH` picks benchmarks by pattern and `COUNT` repeats them, eg `make bench BENCH=Search COUNT=10` for benchstat. Python, JavaScript and shell are timed on argparse.py, jquery.js and bash_completion from Debian's packages and skipped where those are not installed. Warm pages have no benchmark, since `TestPageBudget` in `make test` holds them under 5 ms.
@@ -58,26 +58,26 @@ type live struct { key []byte // keys versions, which pages show to visitors w *watcher mu sync.Mutex- subs map[chan struct{}]bool- wds map[int]int // open pages per watch descriptor+ subs map[chan struct{}]string // each open page's stream, to the page's path+ wds map[int]int // open pages per watch descriptor done chan struct{} once sync.Once } func newLive() *live {- l := &live{id: rand.Text()[:12], key: []byte(rand.Text()), w: newWatcher(pageMask), subs: map[chan struct{}]bool{},+ l := &live{id: rand.Text()[:12], key: []byte(rand.Text()), w: newWatcher(pageMask), subs: map[chan struct{}]string{}, wds: map[int]int{}, done: make(chan struct{})} go l.loop() return l } -// subscribe watches dirs for one page and returns the channel that wakes-// it. release undoes both.-func (l *live) subscribe(dirs []string) (wake chan struct{}, release func()) {+// subscribe watches dirs for the page at path and returns the channel that+// wakes it. release undoes both.+func (l *live) subscribe(path string, dirs []string) (wake chan struct{}, release func()) { wake = make(chan struct{}, 1) var wds []int l.mu.Lock()- l.subs[wake] = true+ l.subs[wake] = path for _, dir := range dirs { // A directory reached twice, through a link, has one descriptor. if wd := l.w.add(dir); wd >= 0 && !slices.Contains(wds, wd) {@@ -134,6 +134,21 @@ func (l *live) loop() { } } +// pages returns the path of each page that holds a stream, one entry per+// page, so two tabs on one path count twice.+func (l *live) pages() []string {+ if l == nil {+ return nil+ }+ l.mu.Lock()+ defer l.mu.Unlock()+ paths := make([]string, 0, len(l.subs))+ for _, p := range l.subs {+ paths = append(paths, p)+ }+ return paths+}+ // kick wakes every page, as when a tree joins or leaves. func (l *live) kick() { if l != nil {@@ -182,7 +197,7 @@ func (s *server) handleLive(w http.ResponseWriter, r *http.Request) { return } p, seen := r.URL.Query().Get("path"), r.URL.Query().Get("v")- wake, release := l.subscribe(s.watchDirs(p))+ wake, release := l.subscribe(p, s.watchDirs(p)) defer release() w.Header().Set("Content-Type", "text/event-stream") // A dropped stream comes back after a second, as on --restart. One lost
@@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "regexp"+ "slices" "strings" "syscall" "testing"@@ -273,3 +274,31 @@ func TestLiveCloseEndsStreams(t *testing.T) { } } }++// A stopping server records its open pages only when a restart has asked,+// by creating the record, so other stops leave the registry as it was.+func TestPagesRecord(t *testing.T) {+ l := newLive()+ defer l.close()+ _, drop := l.subscribe("/t/a.md", nil)+ l.subscribe("/t/b c.md", nil)+ l.subscribe("/t/a.md", nil)+ drop()+ got := l.pages()+ slices.Sort(got)+ if want := []string{"/t/a.md", "/t/b c.md"}; !slices.Equal(got, want) {+ t.Fatalf("pages %q, want %q", got, want)+ }++ r := ®istry{dir: t.TempDir()}+ record := filepath.Join(r.dir, pagesFile)+ r.writePages(got)+ if _, err := os.Stat(record); err == nil {+ t.Fatal("record written unasked")+ }+ os.WriteFile(record, []byte("stale\n"), 0o600)+ r.writePages(got)+ if b, _ := os.ReadFile(record); string(b) != "/t/a.md\n/t/b c.md\n" {+ t.Errorf("record %q", b)+ }+}
@@ -81,7 +81,7 @@ type config struct { var usageText = strings.ReplaceAll(`usage: @NAME@ [-fnph] [-a ip[:port]] [dir] @NAME@ [-fnph] --share[=host[:port]] [--secret[=token]] [dir]- @NAME@ --restart [-a ip[:port] | --share[=host[:port]]]+ @NAME@ --restart [-n] [-a ip[:port] | --share[=host[:port]]] [dir] @NAME@ -l [-a ip[:port] | --share[=host[:port]]] [dir] @NAME@ --kill [-a ip[:port] | --share[=host[:port]]] @@ -113,7 +113,8 @@ var usageText = strings.ReplaceAll(`usage: @NAME@ [-fnph] [-a ip[:port]] [dir] --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+ server on that address; with a directory, serve it+ too and open it unless a page of it was open -l, --list print each served tree's URL and directory; with a directory, only the trees at or below it --kill stop every server and print the trees it served; with@@ -237,8 +238,8 @@ func parseArgs(args []string) (config, error) { dirs = append(dirs, a) } }- if c.restart && (len(dirs) > 0 || c.fg) {- return c, &errno{syscall.EINVAL, "--restart takes no directory or -f"}+ if c.restart && c.fg {+ return c, &errno{syscall.EINVAL, "--restart takes no -f"} } if c.persist && (c.restart || c.list || c.kill) { return c, &errno{syscall.EINVAL, "--persist goes with a start, not with --restart, --list or --kill"}@@ -257,8 +258,9 @@ func parseArgs(args []string) (config, error) { } switch len(dirs) { case 0:- // --list without a directory lists every tree.- if !c.list && !c.kill {+ // --list without a directory lists every tree, and --restart+ // without one serves no tree beyond those it restarts.+ if !c.list && !c.kill && !c.restart { c.dir = "." } case 1:
main_test.go
@@ -30,7 +30,8 @@ func TestParseArgs(t *testing.T) { {[]string{"-h"}, config{dir: ".", help: true}}, {[]string{"--help"}, config{dir: ".", help: true}}, {[]string{"--version"}, config{dir: ".", version: true}},- {[]string{"--restart", "-a", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2", restart: true}},+ {[]string{"--restart", "-a", "127.31.1.2"}, config{addr: "127.31.1.2", restart: true}},+ {[]string{"--restart", "-a", "127.31.1.2", "."}, config{dir: ".", addr: "127.31.1.2", restart: true}}, {[]string{"-l"}, config{list: true}}, {[]string{"--list", "docs"}, config{dir: "docs", list: true}}, {[]string{"--share=0.0.0.0"}, config{dir: ".", sharing: true, share: "0.0.0.0"}},@@ -38,7 +39,7 @@ func TestParseArgs(t *testing.T) { {[]string{"--share", "docs"}, config{dir: "docs", sharing: true}}, {[]string{"--share=10.0.0.1:9000", "--secret", "docs"}, config{dir: "docs", sharing: true, share: "10.0.0.1:9000", secret: true}}, {[]string{"--share=10.0.0.1", "--secret=k3x9"}, config{dir: ".", sharing: true, share: "10.0.0.1", secret: true, token: "k3x9"}},- {[]string{"--restart", "--share=10.0.0.1"}, config{dir: ".", sharing: true, share: "10.0.0.1", restart: true}},+ {[]string{"--restart", "--share=10.0.0.1"}, config{sharing: true, share: "10.0.0.1", restart: true}}, {[]string{"--kill"}, config{kill: true}}, {[]string{"--kill", "--share"}, config{sharing: true, kill: true}}, }@@ -60,7 +61,6 @@ func TestParseArgs(t *testing.T) { {"--addr"}, {"--addr="}, {"one", "two"},- {"--restart", "docs"}, {"--restart", "-f"}, {"-l", "--restart"}, {"--share="},
@@ -182,6 +182,26 @@ func readLinks(dir string) (map[string]string, error) { // is how to tell whether its tree is served and which process to stop. const serverFile = "server" +// pagesFile, in a registry, lists the path of each page that held a live+// stream when its server stopped, one per line. --restart creates it empty+// before it stops the server and reads it after, to say which pages+// reload onto the new server and whether a tree needs a browser opened. A+// server stopped any other way finds no file and writes none.+const pagesFile = "pages"++// writePages records the pages open on a stopping server, when a restart+// has asked for them.+func (r *registry) writePages(paths []string) {+ f, err := os.OpenFile(filepath.Join(r.dir, pagesFile), os.O_WRONLY|os.O_TRUNC, 0)+ if err != nil {+ return+ }+ defer f.Close()+ for _, p := range paths {+ f.WriteString(p + "\n")+ }+}+ // markServer records this process as the server of the registry. func (r *registry) markServer() error { return os.WriteFile(filepath.Join(r.dir, serverFile),
@@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "net/netip"+ neturl "net/url" "os" "os/exec" "path/filepath"@@ -21,7 +22,9 @@ import ( // 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.+// 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 {@@ -32,16 +35,23 @@ func restart(c config) int { eps = []netip.AddrPort{c.ep} } code := 0+ var open []string // pages that were open on c.ep's old server for _, ep := range eps { pid, trees, err := servedTrees(ep) if errors.Is(err, syscall.ESRCH) { if kept := persistedTrees(ep); len(kept) > 0 { err = startTrees(ep, kept, c.stamp)- } else if !named {+ } else if !named || c.dir != "" { continue } } else if err == nil {- err = restartServer(ep, pid, trees, c.stamp)+ var pages []string+ if pages, err = restartServer(ep, pid, trees, c.stamp); 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())@@ -49,22 +59,126 @@ func restart(c config) int { 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...)+ 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, "-n")+ }+ if c.stamp {+ args = append(args, "--stamp")+ }+ 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. 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, stamp bool) error {+// 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, stamp bool) ([]string, error) { if ep.Port() < 1024 && !lowPortOK() {- return &errno{syscall.EACCES, "this binary cannot bind port " + strconv.Itoa(int(ep.Port())) ++ 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 {- return err+ 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 startTrees(ep, trees, stamp)+ return pages, startTrees(ep, trees, stamp) } // startTrees serves each tree on ep through this binary, as a start from
@@ -630,6 +630,7 @@ func (h *hub) halt(why string) { h.webStop() h.web.srv.live.close() }+ h.reg.writePages(h.srv.live.pages()) h.reg.unmarkServer() // Live reload streams never end on their own, and Shutdown waits for them. h.srv.live.close()