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+"/") {