vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 6 slice 1: the tree is the site
d3296cda0c18f78097e37373822292707113d245 · Keenan Nemetz · 18d ago · parent 64ac5071
Phase 6 slice 1: the tree is the site A repository's `pages` branch is served at <repo>.<pages_domain>, straight out of git. No export step, no build to drift: the push moves the branch and the next request reads the new tree. - internal/pages: a pool of `git cat-file --batch` processes per active repository (closed after two idle minutes), an LRU keyed by (commit, path), and tree parsing in git's binary format with the object-id width detected rather than assumed - ETag is the blob sha, Last-Modified the commit time, Content-Type from the extension or a sniff; If-None-Match and If-Modified-Since answer 304 - directories serve index.html and redirect to add the trailing slash; path resolution walks trees, so a traversal-shaped request is a missing page - repository and ACL are resolved once per one-second memo window together with the branch tip, which is what makes the read path fast: doing it per request spawned three git processes and cost 3.8 ms, against 6.7 µs warm - sites follow clone visibility, so a private repository answers 404; the repo page links to the site when pages_domain is configured Measured: a site pushed over SSH is live in 1.4 s (the phase criterion is 5 s); warm requests 6.7 µs, cold 144 µs. Custom domains and their TLS certificates are the remaining Phase 6 work.
internal/pages/catfile.go +151
| @@ -0,0 +1,151 @@ | |||
| 1 | package pages | ||
| 2 | |||
| 3 | // A pool of `git cat-file --batch` processes: the read path for site blobs. | ||
| 4 | // | ||
| 5 | // The Phase 0 spike measured that one process is not the ceiling — 1 to 4 | ||
| 6 | // processes doubled throughput (12.1k to 24.8k req/s) — so the pool exists to | ||
| 7 | // let reads run in parallel. Each process is a pipe pair guarded by one mutex; | ||
| 8 | // a process that dies is restarted on the next read rather than taking the site | ||
| 9 | // down with it. | ||
| 10 | |||
| 11 | import ( | ||
| 12 | "bufio" | ||
| 13 | "errors" | ||
| 14 | "fmt" | ||
| 15 | "io" | ||
| 16 | "os" | ||
| 17 | "os/exec" | ||
| 18 | "strconv" | ||
| 19 | "strings" | ||
| 20 | "sync" | ||
| 21 | ) | ||
| 22 | |||
| 23 | var errMissing = errors.New("pages: object not found") | ||
| 24 | |||
| 25 | type objectPool struct { | ||
| 26 | dir string | ||
| 27 | procs []*catFileProc | ||
| 28 | next int | ||
| 29 | mu sync.Mutex | ||
| 30 | } | ||
| 31 | |||
| 32 | type catFileProc struct { | ||
| 33 | dir string | ||
| 34 | mu sync.Mutex | ||
| 35 | cmd *exec.Cmd | ||
| 36 | in io.WriteCloser | ||
| 37 | out *bufio.Reader | ||
| 38 | dead bool | ||
| 39 | } | ||
| 40 | |||
| 41 | func newObjectPool(dir string, n int) *objectPool { | ||
| 42 | if n <= 0 { | ||
| 43 | n = 1 | ||
| 44 | } | ||
| 45 | p := &objectPool{dir: dir, procs: make([]*catFileProc, n)} | ||
| 46 | for i := range p.procs { | ||
| 47 | p.procs[i] = &catFileProc{dir: dir} | ||
| 48 | } | ||
| 49 | return p | ||
| 50 | } | ||
| 51 | |||
| 52 | // object is one thing read out of git: the id git resolved, the type, and bytes. | ||
| 53 | type object struct { | ||
| 54 | SHA string | ||
| 55 | Type string | ||
| 56 | Data []byte | ||
| 57 | } | ||
| 58 | |||
| 59 | // read resolves rev and returns its content. rev may be a ref name, which is how | ||
| 60 | // the pool answers "what commit is this branch on" without a second subprocess. | ||
| 61 | func (p *objectPool) read(rev string) (object, error) { | ||
| 62 | if strings.ContainsAny(rev, "\n\r") { | ||
| 63 | return object{}, fmt.Errorf("pages: malformed revision %q", rev) | ||
| 64 | } | ||
| 65 | p.mu.Lock() | ||
| 66 | proc := p.procs[p.next%len(p.procs)] | ||
| 67 | p.next++ | ||
| 68 | p.mu.Unlock() | ||
| 69 | return proc.read(rev) | ||
| 70 | } | ||
| 71 | |||
| 72 | func (p *objectPool) Close() { | ||
| 73 | for _, proc := range p.procs { | ||
| 74 | proc.close() | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | func (proc *catFileProc) read(rev string) (object, error) { | ||
| 79 | proc.mu.Lock() | ||
| 80 | defer proc.mu.Unlock() | ||
| 81 | if proc.dead || proc.cmd == nil { | ||
| 82 | if err := proc.start(); err != nil { | ||
| 83 | return object{}, err | ||
| 84 | } | ||
| 85 | } | ||
| 86 | if _, err := io.WriteString(proc.in, rev+"\n"); err != nil { | ||
| 87 | proc.dead = true | ||
| 88 | return object{}, err | ||
| 89 | } | ||
| 90 | line, err := proc.out.ReadString('\n') | ||
| 91 | if err != nil { | ||
| 92 | proc.dead = true | ||
| 93 | return object{}, err | ||
| 94 | } | ||
| 95 | fields := strings.Fields(strings.TrimRight(line, "\n")) | ||
| 96 | if len(fields) == 2 && fields[1] == "missing" { | ||
| 97 | return object{}, errMissing | ||
| 98 | } | ||
| 99 | if len(fields) != 3 { | ||
| 100 | proc.dead = true | ||
| 101 | return object{}, fmt.Errorf("pages: unexpected cat-file header %q", line) | ||
| 102 | } | ||
| 103 | size, err := strconv.ParseInt(fields[2], 10, 64) | ||
| 104 | if err != nil || size < 0 { | ||
| 105 | proc.dead = true | ||
| 106 | return object{}, fmt.Errorf("pages: bad object size in %q", line) | ||
| 107 | } | ||
| 108 | data := make([]byte, size) | ||
| 109 | if _, err := io.ReadFull(proc.out, data); err != nil { | ||
| 110 | proc.dead = true | ||
| 111 | return object{}, err | ||
| 112 | } | ||
| 113 | // cat-file terminates each object with one newline. | ||
| 114 | if _, err := proc.out.Discard(1); err != nil { | ||
| 115 | proc.dead = true | ||
| 116 | return object{}, err | ||
| 117 | } | ||
| 118 | return object{SHA: fields[0], Type: fields[1], Data: data}, nil | ||
| 119 | } | ||
| 120 | |||
| 121 | func (proc *catFileProc) start() error { | ||
| 122 | cmd := exec.Command("git", "cat-file", "--batch") | ||
| 123 | cmd.Dir = proc.dir | ||
| 124 | cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") | ||
| 125 | in, err := cmd.StdinPipe() | ||
| 126 | if err != nil { | ||
| 127 | return err | ||
| 128 | } | ||
| 129 | out, err := cmd.StdoutPipe() | ||
| 130 | if err != nil { | ||
| 131 | return err | ||
| 132 | } | ||
| 133 | cmd.Stderr = io.Discard | ||
| 134 | if err := cmd.Start(); err != nil { | ||
| 135 | return err | ||
| 136 | } | ||
| 137 | proc.cmd, proc.in, proc.out, proc.dead = cmd, in, bufio.NewReaderSize(out, 64<<10), false | ||
| 138 | return nil | ||
| 139 | } | ||
| 140 | |||
| 141 | func (proc *catFileProc) close() { | ||
| 142 | proc.mu.Lock() | ||
| 143 | defer proc.mu.Unlock() | ||
| 144 | if proc.cmd == nil { | ||
| 145 | return | ||
| 146 | } | ||
| 147 | proc.in.Close() | ||
| 148 | proc.cmd.Process.Kill() | ||
| 149 | proc.cmd.Wait() | ||
| 150 | proc.cmd, proc.in, proc.out, proc.dead = nil, nil, nil, true | ||
| 151 | } | ||