Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }