vitrine

restructure server around an atomically swappable mount set

commit 6c942a8c457590efed9c6c69005bebb93935546f · parent 1a5958b537 · browse this tree
Ximianth, 2026-07-28 15:32:32 UTC

Preparation for config hot-reload: the exhibit map moves behind
an atomic.Pointer to an immutable mountSet value.
Requests take one snapshot for their whole duration,
so a future reload can publish a new set without racing in-flight requests.
newServer delegates to setExhibits, which builds a whole set before publishing
(nothing is published if any exhibit fails to open).
Old roots are left to os.Root's finalizer rather than closed eagerly;
the reasoning is in the setExhibits doc comment.

No behavior change yet; existing tests pass unchanged.
diff --git a/serve.go b/serve.go
index 8b8e472..72d12b4 100644
--- a/serve.go
+++ b/serve.go
@@ -10,13 +10,22 @@ import (
 	"path"
 	"sort"
 	"strings"
+	"sync/atomic"
 )
 
-// server holds one opened tree per configured exhibit.
+// server holds the currently mounted exhibits 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 once and works with that immutable snapshot for its whole duration.
+// (A RWMutex would also work, but there is nothing to lock *around*:
+// the atomic pointer makes the publish-immutable-snapshots discipline explicit.)
 type server struct {
-	trees map[string]tree
+	mounts atomic.Pointer[mountSet]
 }
 
+// mountSet maps mount names (URL prefixes) to opened trees.
+// A mountSet is never mutated after being published to server.mounts.
+type mountSet map[string]tree
+
 // tree is one exhibit as served: an opened os.Root plus its options.
 // os.Root guarantees all path resolution stays inside the tree
 // (it rejects ".." escapes and symlinks pointing outside).
@@ -26,18 +35,40 @@ type tree struct {
 }
 
 func newServer(exhibits map[string]exhibit) (*server, error) {
-	s := &server{trees: make(map[string]tree)}
+	s := &server{}
+	if err := s.setExhibits(exhibits); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+// setExhibits builds a fresh mount set from the given exhibits and publishes it atomically.
+// On any error (an exhibit dir that fails to open) nothing is published:
+// at startup that is fail-loud (newServer returns the error),
+// and on reload it is fail-stable (the previous mount set keeps serving untouched).
+//
+// The previous mount set's os.Root handles are deliberately not closed here.
+// An in-flight request may have loaded the old set just before the swap
+// and still be reading through one of its roots; closing would break it mid-response.
+// os.Root carries a runtime finalizer that closes its fd once unreferenced,
+// so old handles are reclaimed exactly when the last such request drops them.
+// That is the simplest scheme that is actually correct:
+// refcounting would buy only promptness,
+// and a few directory fds lingering until the next GC cycle cost nothing.
+func (s *server) setExhibits(exhibits map[string]exhibit) error {
+	mounts := make(mountSet, len(exhibits))
 	for name, ex := range exhibits {
 		root, err := os.OpenRoot(ex.Path)
 		if err != nil {
-			return nil, fmt.Errorf("opening exhibit %q: %w", name, err)
+			return fmt.Errorf("opening exhibit %q: %w", name, err)
 		}
-		s.trees[name] = tree{root: root, exposeGit: ex.ExposeGit}
+		mounts[name] = tree{root: root, exposeGit: ex.ExposeGit}
 		if ex.ExposeGit {
 			updateGitServerInfo(name, ex.Path)
 		}
 	}
-	return s, nil
+	s.mounts.Store(&mounts)
+	return nil
 }
 
 // updateGitServerInfo runs `git update-server-info` in the exhibit directory,
@@ -70,8 +101,12 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	req := path.Clean("/" + r.URL.Path)[1:] // normalized, no leading slash; "" at root
 
+	// One snapshot of the mount set serves the whole request,
+	// so a concurrent config reload cannot pull a tree out from under us.
+	mounts := *s.mounts.Load()
+
 	// A request path inside a mount is served from that tree.
-	for mount, tr := range s.trees {
+	for mount, tr := range mounts {
 		if req == mount || strings.HasPrefix(req, mount+"/") {
 			rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/")
 			if rel == "" {
@@ -84,7 +119,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	// A request path *above* a mount gets a synthetic listing
 	// showing only the exposed next-level names.
-	if kids := s.mountChildren(req); kids != nil {
+	if kids := mountChildren(mounts, req); kids != nil {
 		if req != "" && !strings.HasSuffix(r.URL.Path, "/") {
 			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
 			return
@@ -103,9 +138,9 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 // mountChildren returns the listing for a synthetic (above-mount) directory:
 // the deduplicated next path segments of all mounts under the given prefix.
 // Returns nil if no mount lives under this prefix.
-func (s *server) mountChildren(prefix string) []dirEntry {
+func mountChildren(mounts mountSet, prefix string) []dirEntry {
 	seen := make(map[string]bool)
-	for mount := range s.trees {
+	for mount := range mounts {
 		rest := mount
 		if prefix != "" {
 			if !strings.HasPrefix(mount, prefix+"/") {