state: GetSnapshot copies the state's maps (closes #261)
check / check (push) Canceled after 0s

The dashboard and /api/v1/status range over the maps in the snapshot
GetSnapshot returns after the read lock is released, while the watcher
writes the same maps under the lock. Go aborts the process when a write
lands during such a range. GetSnapshot now returns copies of the four
maps.

The entries in the maps are still shared with the state. That is safe
because the watcher replaces an entry with a Set call and never changes
one in place; the comment on GetSnapshot says so.

TestGetSnapshot_WhileWritten ranges over snapshots while another
goroutine writes the state and fails under -race without the copies.
TestGetSnapshot_ReturnsCopy now checks that each map is not the state's
own.

Model: opus-5-5
This commit was merged in pull request #272.
This commit is contained in:
2026-10-13 13:44:31 +02:00
parent e8b40062da
commit 02cb1ec580
3 changed files with 75 additions and 10 deletions
+12 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"maps"
"os"
"path/filepath"
"slices"
@@ -270,12 +271,21 @@ func (s *State) Save() error {
return nil
}
// GetSnapshot returns a copy of the current snapshot.
// GetSnapshot returns a copy of the current snapshot, with copies of
// its maps, so that a caller can range over them while the state is
// written. The entries in the maps are not copied: an entry saved in the
// state must be replaced, never changed in place.
func (s *State) GetSnapshot() Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return *s.snapshot
snap := *s.snapshot
snap.Domains = maps.Clone(snap.Domains)
snap.Hostnames = maps.Clone(snap.Hostnames)
snap.Ports = maps.Clone(snap.Ports)
snap.Certificates = maps.Clone(snap.Certificates)
return snap
}
// SetDomainState updates the state for a domain.