state: GetSnapshot copies the state's maps (closes #261)
check / check (push) Canceled after 0s
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:
+12
-2
@@ -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.
|
||||
|
||||
@@ -995,7 +995,8 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy.
|
||||
// TestGetSnapshot_ReturnsCopy verifies that the maps in the snapshot
|
||||
// GetSnapshot returns are not the state's own.
|
||||
func TestGetSnapshot_ReturnsCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1005,15 +1006,67 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
|
||||
|
||||
snap := s.GetSnapshot()
|
||||
|
||||
// Mutate the returned snapshot's map.
|
||||
snap.Domains["mutated.example.com"] = &state.DomainState{
|
||||
Nameservers: []string{"ns1.mutated.com."},
|
||||
const added = "added.example.com"
|
||||
|
||||
snap.Domains[added] = &state.DomainState{}
|
||||
snap.Hostnames[added] = &state.HostnameState{}
|
||||
snap.Ports[added] = &state.PortState{}
|
||||
snap.Certificates[added] = &state.CertificateState{}
|
||||
|
||||
if _, ok := s.GetDomainState(added); ok {
|
||||
t.Error("the snapshot's Domains map is the state's own")
|
||||
}
|
||||
|
||||
// The original domain data should still be accessible.
|
||||
_, ok := s.GetDomainState("example.com")
|
||||
if !ok {
|
||||
t.Error("original domain should still exist")
|
||||
if _, ok := s.GetHostnameState(added); ok {
|
||||
t.Error("the snapshot's Hostnames map is the state's own")
|
||||
}
|
||||
|
||||
if _, ok := s.GetPortState(added); ok {
|
||||
t.Error("the snapshot's Ports map is the state's own")
|
||||
}
|
||||
|
||||
if _, ok := s.GetCertificateState(added); ok {
|
||||
t.Error("the snapshot's Certificates map is the state's own")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSnapshot_WhileWritten ranges over snapshots while another
|
||||
// goroutine writes the state, as the dashboard does while a check runs.
|
||||
// Under -race it fails when a snapshot's maps are the state's own.
|
||||
func TestGetSnapshot_WhileWritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := state.NewForTestWithDataDir(t.TempDir())
|
||||
|
||||
const writes = 1000
|
||||
|
||||
written := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(written)
|
||||
|
||||
for range writes {
|
||||
s.SetDomainState("example.com", &state.DomainState{})
|
||||
s.SetHostnameState(testHostname, &state.HostnameState{})
|
||||
s.SetPortState(testIPv4+":443", &state.PortState{})
|
||||
s.SetCertificateState(
|
||||
testIPv4+":443:"+testHostname, &state.CertificateState{},
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-written:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// json.Marshal ranges over each of the snapshot's maps.
|
||||
_, err := json.Marshal(s.GetSnapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user