the repo set becomes an atomically swapped immutable snapshot
commit d3c2758b678c08253b53934275ff0d5b5794948b · parent 94a8b54dd5
· browse this tree
Ximianth, 2026-10-27 00:45:40 UTC
Each request loads the pointer once, at the top of ServeHTTP, and works with that snapshot for its whole duration; previously the isRPC method check and the dispatch consulted the server field separately, which a future concurrent swap could have straddled. Groundwork for live config reload; behavior today is unchanged (the set is still only ever set at startup). The language-stats cache will survive swaps: its entries are keyed by commit sha, which content-addresses the tree the stats derive from, so a reload can orphan an entry but never falsify one.
diff --git a/lang_test.go b/lang_test.go index 7d2b947..07ef146 100644 --- a/lang_test.go +++ b/lang_test.go @@ -120,7 +120,7 @@ func TestLangCacheBounds(t *testing.T) { func TestLangStatsCached(t *testing.T) { t.Parallel() s := fixtureServer(t) - rp := s.repos["fixturama"] + rp := (*s.repos.Load())["fixturama"] planted := []langStat{{"Ada", 42}} s.langs.put(langCacheKey(rp.name, fixtureShaMain), planted) got, err := s.langStats(rp, fixtureShaMain) diff --git a/pages.go b/pages.go index 8df2df4..10889eb 100644 --- a/pages.go +++ b/pages.go @@ -110,10 +110,10 @@ type indexRow struct { LangColor string // its linguist color; the placard dot only shows when one exists } -func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) { +func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, repos map[string]*repo) { var rows []indexRow - for _, name := range sortedNames(s.repos) { - rp := s.repos[name] + for _, name := range sortedNames(repos) { + rp := repos[name] row := indexRow{Name: name, Description: rp.description, Branch: rp.defaultBranch()} // Enrichment is best-effort, matching the caption's existing stance: // the entrance still stands when one repo cannot answer. diff --git a/serve.go b/serve.go index ec07733..dcf0d3f 100644 --- a/serve.go +++ b/serve.go @@ -8,14 +8,18 @@ import ( "net/http" "net/url" "strings" + "sync/atomic" "vitrine/display" ) // server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch. -// The repo set is fixed at startup (no live reload yet); requests only ever read it. +// The repo set lives behind an atomic pointer so a config reload can swap the whole set +// at once while requests read it lock-free: each request loads the pointer exactly once +// (at the top of ServeHTTP) and works with that immutable snapshot for its whole duration. +// A published map is never mutated. type server struct { - repos map[string]*repo + repos atomic.Pointer[map[string]*repo] theme display.Theme // fixed for the process lifetime; see display/theme.go sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go style []byte // the assembled /-/style.css body; see buildStyle @@ -23,23 +27,35 @@ type server struct { } func newServer(repos map[string]*repo, th display.Theme) *server { - return &server{ - repos: repos, + s := &server{ theme: th, sketch: sketchAssets(th.Name), style: buildStyle(th), langs: newLangCache(langCacheCap), } + s.setRepos(repos) + return s +} + +// setRepos publishes a new repo set atomically; in-flight requests keep the one they loaded. +// The old set needs no teardown: a repo holds only strings (git runs as a subprocess per +// operation), so the garbage collector reclaims it once the last such request drops it. +// The language-stats cache deliberately survives the swap: it is keyed (repo-name, commit-sha) +// and a sha content-addresses the commit's tree, so an entry can be orphaned by a reload +// but never wrong, and the cache's cap already bounds orphans. +func (s *server) setRepos(repos map[string]*repo) { + s.repos.Store(&repos) } // ServeHTTP dispatches: the two server-wide paths first, then longest-prefix repo matching // (matchRepo), then the first path segment after the repo name selects the verb. func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") + repos := *s.repos.Load() // this request's snapshot; a concurrent reload cannot alter it // Fetch RPC is the only POST in the whole URL space; everything else is GET (or HEAD). isRPC := false - if _, rest := matchRepo(s.repos, p); rest == "git-upload-pack" { + if _, rest := matchRepo(repos, p); rest == "git-upload-pack" { isRPC = true } switch { @@ -53,7 +69,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case p == "": - s.serveIndex(w, r) + s.serveIndex(w, r, repos) return case p == "-/style.css": w.Header().Set("Content-Type", "text/css; charset=utf-8") @@ -64,7 +80,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - rp, rest := matchRepo(s.repos, p) + rp, rest := matchRepo(repos, p) if rp == nil { http.NotFound(w, r) return