261017.12 · 7d3aa432cb8e6ec51aa21d24e3344d38f6d68c48
Git answers are cached within a memory budget
Most pages ran two git subprocesses, about 3 ms of a 3.3 ms doc page. The server now keeps which repo each directory lies in and the histories it shows, and reads HEAD from the repo's files. A history is kept under the commit HEAD names, so a new commit misses the cache. A doc page renders in about 0.6 ms on a repeat view.
--cache sets the memory for it, default 32M; 0 caches nothing.
reading declarations
@@ -31,6 +31,7 @@ To reach the server by name, add a line such as `127.31.1.1 mdserve` to `/etc/ho - `-f`, `--foreground`: when no server runs on the address yet, serve in the foreground until Ctrl-C - `-n`, `--no-open`: print the URL without opening a browser - `--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` leaves it off+- `--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 - `--restart`: replace every server with one running this binary, serving the same trees, and print each tree's URL; with `-a` or `--share`, only the server on that address - `-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@@ -75,6 +76,8 @@ URLs start with the served directory's path relative to home, eg `http://127.31. Below the prefix, URLs mirror the file tree, so relative links in the docs work unchanged. An absolute link such as `/x.md` points outside the prefix and gives 404. A `.md` file renders as a page, a directory as a listing of its names, a text file as a highlighted code page with linkable lines, and anything else raw. Add `?raw` to any URL for the file itself. A tree inside another served tree answers for its own paths. A code page anchors lines as GitHub does, so `main.go#L42` lands on line 42 and highlights it. Bare URLs in a doc become links. +A server keeps git's answers so most pages run no git at all: which repo each directory lies in, and the histories pages show. It reads HEAD from the repo's files, and a history is kept under the commit HEAD names, so a new commit misses the cache and nothing shown goes stale. A `.git` that appears or goes makes the server ask git again. `--cache` sets the memory for it.+ Open pages reload when what they show changes. A doc or code page reloads when its file is written, a listing when a name in it changes, any page in a repo when its HEAD moves, and the index when a tree joins or leaves. A page whose file or tree is gone waits two seconds for it to return, then reloads to say so. A page whose server stops reloads after three seconds without it, so the browser says the server is gone; a restart takes about 60 ms, and its new server reloads the page first. Each page holds a server-sent events stream while its tab is visible; a hidden tab lets go, since a browser opens at most six connections to one host, and checks again when shown. The server watches the directory of each open page and its repo's git directory through inotify, since editors save by renaming a new file over the old one, and polls every second where inotify fails. Docs follow CommonMark with GitHub's extensions, rendered by goldmark: tables, strikethrough, task lists, bare URLs as links, footnotes and definition lists. Lines join into a paragraph, and a blank line starts the next. A line that starts with a dash begins a list even right after text. Straight quotes and dashes turn typographic. Code in a fenced block with a known language is highlighted by chroma, as are code pages, whose language follows the file name. Go is lexed by Go's own scanner from the standard library instead, about 150 times faster, with the same colours. Every gap between blocks is the same, whether the block is a paragraph, list, table or code. Headings sit close to their text.
@@ -0,0 +1,98 @@+package main++import (+ linked "container/list"+ "strconv"+ "strings"+ "sync"+ "syscall"+)++// defaultCache is the memory a server may spend on cached answers, eg+// which repo a directory lies in and the commits a page's history lists.+const defaultCache = 32 << 20++// cache keeps values up to a byte budget and drops the least recently+// used first. A budget of 0 keeps nothing.+type cache struct {+ mu sync.Mutex+ max int64+ used int64+ order *linked.List // front is the most recently used+ items map[string]*linked.Element+}++type cacheItem struct {+ key string+ val any+ size int64+}++func newCache(max int64) *cache {+ return &cache{max: max, order: linked.New(), items: map[string]*linked.Element{}}+}++// gitCache holds git's answers for every tree on the server; serve sets+// its budget from --cache.+var gitCache = newCache(defaultCache)++func (c *cache) get(key string) (any, bool) {+ c.mu.Lock()+ defer c.mu.Unlock()+ e, ok := c.items[key]+ if !ok {+ return nil, false+ }+ c.order.MoveToFront(e)+ return e.Value.(*cacheItem).val, true+}++// put stores val, whose size counts its key too. A value larger than the+// whole budget is not kept.+func (c *cache) put(key string, val any, size int64) {+ size += int64(len(key))+ c.mu.Lock()+ defer c.mu.Unlock()+ if e, ok := c.items[key]; ok {+ c.remove(e)+ }+ if size > c.max {+ return+ }+ c.items[key] = c.order.PushFront(&cacheItem{key, val, size})+ c.used += size+ for c.used > c.max {+ c.remove(c.order.Back())+ }+}++func (c *cache) drop(key string) {+ c.mu.Lock()+ defer c.mu.Unlock()+ if e, ok := c.items[key]; ok {+ c.remove(e)+ }+}++func (c *cache) remove(e *linked.Element) {+ it := c.order.Remove(e).(*cacheItem)+ delete(c.items, it.key)+ c.used -= it.size+}++// parseSize reads a byte count such as 32M, 512K, 1G or 0, in powers of+// 1024.+func parseSize(s string) (int64, error) {+ bad := &errno{syscall.EINVAL, "bad size " + s + ": want a number with K, M or G, eg 32M"}+ num, mult := s, int64(1)+ for suffix, m := range map[string]int64{"K": 1 << 10, "M": 1 << 20, "G": 1 << 30} {+ if strings.HasSuffix(strings.ToUpper(s), suffix) {+ num, mult = s[:len(s)-1], m+ }+ }+ n, err := strconv.ParseInt(num, 10, 64)+ if err != nil || n < 0 || n > (1<<40)/mult {+ return 0, bad+ }+ return n * mult, nil+}
@@ -0,0 +1,72 @@+package main++import (+ "context"+ "os"+ "path/filepath"+ "testing"+)++// The cache drops the least recently used value past its budget, and a+// budget of 0 keeps nothing.+func TestCacheBudget(t *testing.T) {+ c := newCache(25) // two of the 10-byte entries, keys included+ c.put("a", 1, 9)+ c.put("b", 2, 9)+ c.get("a")+ c.put("c", 3, 9)+ if _, ok := c.get("b"); ok {+ t.Error("b, the least recently used, was kept")+ }+ for _, k := range []string{"a", "c"} {+ if _, ok := c.get(k); !ok {+ t.Errorf("%s was dropped", k)+ }+ }+ none := newCache(0)+ none.put("a", 1, 0)+ if _, ok := none.get("a"); ok {+ t.Error("a budget of 0 kept a value")+ }+}++func TestParseSize(t *testing.T) {+ for s, want := range map[string]int64{"0": 0, "512": 512, "64K": 64 << 10, "32M": 32 << 20, "1g": 1 << 30} {+ if got, err := parseSize(s); err != nil || got != want {+ t.Errorf("%s: %d %v, want %d", s, got, err, want)+ }+ }+ for _, s := range []string{"", "M", "lots", "-1", "1T"} {+ if _, err := parseSize(s); err == nil {+ t.Errorf("%q: no error", s)+ }+ }+}++// HEAD read from the repo's files follows each commit, so a cached+// history never outlives it; a repo made in a directory that had none is+// found.+func TestCachedHeadFollowsCommits(t *testing.T) {+ needGit(t)+ s, dir := repoTree(t)+ tr := s.list()[0]+ ctx := context.Background()+ _, first := tr.gitHead("index.md")+ before := tr.lastCommit(ctx, "index.md")+ os.WriteFile(filepath.Join(dir, "index.md"), []byte("# Changed\n"), 0o644)+ commitAll(t, dir, "Change the index")+ _, second := tr.gitHead("index.md")+ after := tr.lastCommit(ctx, "index.md")+ if first == second || before == nil || after == nil || after.subject != "Change the index" {+ t.Errorf("HEAD %q then %q; last commit %v then %v", first, second, before, after)+ }++ plain := t.TempDir()+ if r := repoAt(ctx, plain); r.complaint == "" {+ t.Fatalf("%s is in a repo: %+v", plain, r)+ }+ git(ctx, plain, "init", "-q")+ if r := repoAt(ctx, plain); r.complaint != "" {+ t.Errorf("new repo not found: %s", r.complaint)+ }+}
@@ -96,12 +96,29 @@ func (t *tree) gitLog(ctx context.Context, name string, n int) ([]commit, error) if t.shared && !openRepo(ctx, dir) { return nil, errNoHistory }+ // A history is fixed by the commit HEAD names, so it is kept under+ // that commit and a new one misses the cache.+ var key string+ if r := repoAt(ctx, dir); r.complaint == "" {+ key = fmt.Sprintf("log\x00%s\x00%s\x00%s\x00%s\x00%d\x00%t", r.gitDir, r.head(ctx), dir, spec, n, t.shared)+ if v, ok := gitCache.get(key); ok {+ return v.([]commit), nil+ }+ } out, err := git(ctx, dir, "log", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--no-color", "-z", "--format="+commitFormat, "-n", fmt.Sprint(n), "--", spec) if err != nil { return nil, t.gitErr(err) }- return t.commits(out), nil+ log := t.commits(out)+ if key != "" {+ size := int64(0)+ for _, c := range log {+ size += int64(len(c.hash)+len(c.short)+len(c.subject)+len(c.body)) + 64+ }+ gitCache.put(key, log, size)+ }+ return log, nil } // commitFormat is how git prints a commit for commits to read.@@ -204,12 +221,11 @@ func (t *tree) gitHead(name string) (gitDir, state string) { if t.shared && !openRepo(ctx, dir) { return "", "" }- out, err := git(ctx, dir, "rev-parse", "--absolute-git-dir", "HEAD")- if err != nil {- return "", string(out) + err.Error()+ r := repoAt(ctx, dir)+ if r.complaint != "" {+ return "", r.complaint }- gitDir, _, _ = strings.Cut(string(out), "\n")- return gitDir, string(out)+ return r.gitDir, r.gitDir + "\n" + r.head(ctx) } // commitLine is a commit's hash and date, as a history and a page's top
@@ -0,0 +1,147 @@+package main++import (+ "bufio"+ "context"+ "os"+ "path/filepath"+ "strings"+)++// repo is the git repo a directory lies in, as git found it, or git's+// complaint when it lies in none.+type repo struct {+ gitDir, common, top string+ complaint string+}++// repoAt asks git once which repo dir lies in, so git's own checks, such as+// safe.directory, decide. The answer is kept until a .git appears or goes+// where it would change: between dir and the repo's top, or above dir when+// dir lies in no repo.+func repoAt(ctx context.Context, dir string) repo {+ key := "repo\x00" + dir+ if v, ok := gitCache.get(key); ok {+ if r := v.(repo); r.holds(dir) {+ return r+ }+ gitCache.drop(key)+ }+ var r repo+ out, err := git(ctx, dir, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-toplevel")+ if err != nil {+ // Inside a git directory there is no work tree to name.+ out, err = git(ctx, dir, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir")+ }+ if err != nil {+ r.complaint = string(out) + err.Error()+ } else {+ f := strings.Split(strings.TrimSpace(string(out)), "\n")+ r.gitDir, r.common = f[0], f[1]+ if len(f) > 2 {+ r.top = f[2]+ }+ }+ gitCache.put(key, r, int64(len(r.gitDir)+len(r.common)+len(r.top)+len(r.complaint)))+ return r+}++// holds reports whether r still answers for dir: its git directory is+// there, and no .git has appeared or gone in the levels that decide.+func (r repo) holds(dir string) bool {+ if r.complaint != "" {+ for d := dir; ; d = filepath.Dir(d) {+ if exists(filepath.Join(d, ".git")) {+ return false+ }+ if d == filepath.Dir(d) {+ return true+ }+ }+ }+ if !exists(filepath.Join(r.gitDir, "HEAD")) {+ return false+ }+ if r.top == "" {+ return true+ }+ for d := dir; d != r.top; d = filepath.Dir(d) {+ if exists(filepath.Join(d, ".git")) || d == filepath.Dir(d) {+ return false+ }+ }+ return exists(filepath.Join(r.top, ".git"))+}++func exists(p string) bool {+ _, err := os.Lstat(p)+ return err == nil+}++// head names the commit HEAD points at. It reads the repo's files, which+// takes microseconds where git takes a millisecond, and asks git only for+// what it cannot read: a reftable store or a ref it does not find.+func (r repo) head(ctx context.Context) string {+ if h, ok := r.readHead(); ok {+ return h+ }+ out, err := git(ctx, r.gitDir, "rev-parse", "HEAD")+ if err != nil {+ return "no HEAD: " + err.Error()+ }+ return strings.TrimSpace(string(out))+}++func (r repo) readHead() (string, bool) {+ if exists(filepath.Join(r.common, "reftable")) {+ return "", false+ }+ ref := "HEAD"+ for range 5 {+ val, ok := r.readRef(ref)+ if !ok {+ return "", false+ }+ target, symbolic := strings.CutPrefix(val, "ref: ")+ if !symbolic {+ return val, isHash(val)+ }+ ref = target+ }+ return "", false+}++// readRef reads a ref from the worktree's git directory, then the common+// one, then packed-refs.+func (r repo) readRef(ref string) (string, bool) {+ for _, d := range []string{r.gitDir, r.common} {+ if b, err := os.ReadFile(filepath.Join(d, filepath.FromSlash(ref))); err == nil {+ return strings.TrimSpace(string(b)), true+ }+ }+ f, err := os.Open(filepath.Join(r.common, "packed-refs"))+ if err != nil {+ return "", false+ }+ defer f.Close()+ sc := bufio.NewScanner(f)+ for sc.Scan() {+ hash, name, ok := strings.Cut(sc.Text(), " ")+ if ok && name == ref {+ return hash, true+ }+ }+ return "", false+}++func isHash(s string) bool {+ if len(s) != 40 && len(s) != 64 {+ return false+ }+ for _, c := range s {+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {+ return false+ }+ }+ return true+}
@@ -67,7 +67,8 @@ type config struct { ep netip.AddrPort fg bool noopen bool- stamp bool // show the build time above each page's header+ stamp bool // show the build time above each page's header+ cache string // the memory for cached git answers, eg 32M; "" takes defaultCache help bool restart bool list bool@@ -97,6 +98,8 @@ const usageText = `usage: mdserve [-fnh] [-a ip[:port]] [dir] --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+ --cache size memory a new server may spend on cached git answers,+ eg 64M; 0 caches nothing (default 32M) --restart replace every server with one running this binary, serving the same trees; with -a or --share, only the server on that address@@ -177,6 +180,17 @@ func parseArgs(args []string) (config, error) { } continue }+ if name == "cache" {+ v, err := value(&i, "--cache", val)+ if err != nil {+ return c, err+ }+ if _, err := parseSize(v); err != nil {+ return c, err+ }+ c.cache = v+ continue+ } p, ok := sw[name] switch { case !ok:
main_test.go
@@ -16,6 +16,8 @@ func TestParseArgs(t *testing.T) { {nil, config{dir: "."}}, {[]string{"-fn"}, config{dir: ".", fg: true, noopen: true}}, {[]string{"-f", "--stamp"}, config{dir: ".", fg: true, stamp: true}},+ {[]string{"--cache=64M"}, config{dir: ".", cache: "64M"}},+ {[]string{"--cache", "0"}, config{dir: ".", cache: "0"}}, {[]string{"-f", "-n", "docs"}, config{dir: "docs", fg: true, noopen: true}}, {[]string{"docs", "--foreground", "--no-open"}, config{dir: "docs", fg: true, noopen: true}}, {[]string{"-a", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},@@ -70,6 +72,9 @@ func TestParseArgs(t *testing.T) { {"--kill", "-l"}, {"--kill", "--restart"}, {"--kill", "--share", "--secret"},+ {"--cache=lots"},+ {"--cache=-1M"},+ {"--cache"}, } { if _, err := parseArgs(args); err == nil { t.Errorf("%q: no error", args)
@@ -204,6 +204,11 @@ func serve(c config, rep *reporter, ln net.Listener, reg *registry, dir, home st logInfo("server starts on %s: %s", c.ep, build()) go warmLexers() showBuilt = c.stamp+ gitCache = newCache(defaultCache)+ if c.cache != "" {+ n, _ := parseSize(c.cache)+ gitCache = newCache(n)+ } h := &hub{ep: c.ep, home: home, reg: reg, ln: ln, srv: newServer(), w: newWatcher(lockMask), done: make(chan struct{}), fresh: map[net.Conn]bool{}} defer h.w.close()