socle

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