serve: publish {repos, baseurl} as one atomic snapshot
commit 701ac20d8a20ee09f2bd624b6797ed0db562d85a · parent 4eed84cb53
· browse this tree
Ximianth, 2026-10-27 17:06:46 UTC
Pure plumbing plus the clone-URL half of baseurl consumption: when configured, the repo home shows the canonical clone address instead of the request-derived guess (which stays as the fallback, and is right whenever no rewriting proxy intervenes). The two values swap together so a reload can never pair old repos with a new address.
diff --git a/lang_test.go b/lang_test.go index 07ef146..d5803e8 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.Load())["fixturama"] + rp := s.snap.Load().repos["fixturama"] planted := []langStat{{"Ada", 42}} s.langs.put(langCacheKey(rp.name, fixtureShaMain), planted) got, err := s.langStats(rp, fixtureShaMain) diff --git a/main.go b/main.go index bf0cd42..94d6528 100644 --- a/main.go +++ b/main.go @@ -37,7 +37,7 @@ func main() { for _, w := range warnings { log.Print("socle: ", w) } - srv := newServer(repos, display.NewTheme(seed)) + srv := newServer(repos, cfg.BaseURL, display.NewTheme(seed)) // Config changes apply live: reloads are fail-stable // (any problem logs loudly and the old repo set keeps serving), @@ -69,6 +69,6 @@ func reloadConfig(srv *server, path string) { log.Printf("socle: config reload REJECTED, keeping previous repos: no configured repo could be opened") return } - srv.setRepos(repos) + srv.publish(repos, cfg.BaseURL) log.Printf("socle: config reloaded: %d repos", len(repos)) } diff --git a/pages.go b/pages.go index 10889eb..722ba75 100644 --- a/pages.go +++ b/pages.go @@ -209,15 +209,21 @@ func langSegments(stats []langStat) []langSeg { return segs } -func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) { - scheme := "http" - if r.TLS != nil { - scheme = "https" +func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, baseURL string) { + // The clone URL: the configured canonical address when there is one, + // else derived from the request (right whenever no rewriting proxy intervenes). + cloneURL := baseURL + "/" + rp.name + if baseURL == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + cloneURL = scheme + "://" + r.Host + "/" + rp.name } db := rp.defaultBranch() d := homeData{ Description: rp.description, - CloneURL: scheme + "://" + r.Host + "/" + rp.name, + CloneURL: cloneURL, Ref: db, } branches, tags, err := rp.refs() diff --git a/serve.go b/serve.go index dcf0d3f..23b1226 100644 --- a/serve.go +++ b/serve.go @@ -13,45 +13,55 @@ import ( "vitrine/display" ) +// snapshot is the server's immutable view of one loaded config: +// the opened repo set, plus the canonical base URL (empty when unconfigured; +// see config.BaseURL for what each state means). +// The two travel together so a reload can never pair old repos with a new address. +type snapshot struct { + repos map[string]*repo + baseURL string +} + // server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch. -// The repo set lives behind an atomic pointer so a config reload can swap the whole set +// The config snapshot lives behind an atomic pointer so a reload can swap the whole thing // 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. +// A published snapshot is never mutated. type server struct { - repos atomic.Pointer[map[string]*repo] + snap atomic.Pointer[snapshot] 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 langs *langCache // language composition per (repo, sha); see lang.go } -func newServer(repos map[string]*repo, th display.Theme) *server { +func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server { s := &server{ theme: th, sketch: sketchAssets(th.Name), style: buildStyle(th), langs: newLangCache(langCacheCap), } - s.setRepos(repos) + s.publish(repos, baseURL) return s } -// setRepos publishes a new repo set atomically; in-flight requests keep the one they loaded. +// publish swaps in a new config snapshot 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) +func (s *server) publish(repos map[string]*repo, baseURL string) { + s.snap.Store(&snapshot{repos: repos, baseURL: baseURL}) } // 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 + snap := s.snap.Load() // this request's snapshot; a concurrent reload cannot alter it + repos := snap.repos // Fetch RPC is the only POST in the whole URL space; everything else is GET (or HEAD). isRPC := false @@ -92,7 +102,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, r.URL.EscapedPath()+"/", http.StatusMovedPermanently) return } - s.serveHome(w, r, rp) + s.serveHome(w, r, rp, snap.baseURL) return } diff --git a/serve_test.go b/serve_test.go index 852927d..70aef6e 100644 --- a/serve_test.go +++ b/serve_test.go @@ -17,7 +17,7 @@ import ( // with a pinned theme seed so output stays deterministic. func fixtureServer(t *testing.T) *server { t.Helper() - return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, display.NewTheme("test")) + return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", display.NewTheme("test")) } // do runs one request through the handler and returns the recorded response. @@ -208,7 +208,7 @@ func TestFileViewModes(t *testing.T) { if err != nil { t.Fatal(err) } - s := newServer(map[string]*repo{"media": rp}, display.NewTheme("test")) + s := newServer(map[string]*repo{"media": rp}, "", display.NewTheme("test")) for _, c := range []struct { target string want string @@ -252,7 +252,7 @@ func TestCommitPageShapes(t *testing.T) { if err != nil { t.Fatal(err) } - s := newServer(map[string]*repo{"merger": rp}, display.NewTheme("test")) + s := newServer(map[string]*repo{"merger": rp}, "", display.NewTheme("test")) rec := do(s, "/merger/commit/main", nil, nil) if rec.Code != 200 { t.Fatalf("status = %d", rec.Code) diff --git a/watch_test.go b/watch_test.go index f5d1887..58b9557 100644 --- a/watch_test.go +++ b/watch_test.go @@ -51,7 +51,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { if len(warnings) > 0 { t.Fatalf("fixture repos should all open: %v", warnings) } - fx.srv = newServer(repos, display.NewTheme("test")) + fx.srv = newServer(repos, cfg.BaseURL, display.NewTheme("test")) watchConfig(fx.cfgPath, pollInterval, func() { reloadConfig(fx.srv, fx.cfgPath) fx.checked.Add(1)