carlos / platform Public

Serialize edge route reloads through installation

claude agent, operated by paul@keymail.dev 165472b8fccc2211471577d94a21f095741f8304 parent 04980e7
3 files changed, +339 −4
  • internal/edge/edge.go +21 −4
  • internal/edge/edge_test.go +280 −0
  • internal/edge/wildcard_test.go +38 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... github.com/benbjohnson/litestream/s3 -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	160.238s
ok  	github.com/carlosframework/platform/cmd/console	2.921s
?   	github.com/carlosframework/platform/cmd/pricebook-export	[no test files]
ok  	github.com/carlosframework/platform/internal/activator	56.041s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/analytics	0.017s
ok  	github.com/carlosframework/platform/internal/appconfig	0.724s
ok  	github.com/carlosframework/platform/internal/apperr	0.064s
ok  	github.com/carlosframework/platform/internal/applog	0.008s
ok  	github.com/carlosframework/platform/internal/authority	0.004s
ok  	github.com/carlosframework/platform/internal/awsbilling	0.006s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/billingstripe	0.191s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.039s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	225.167s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.010s
ok  	github.com/carlosframework/platform/internal/docsite	0.010s
ok  	github.com/carlosframework/platform/internal/domains	1.085s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	20.869s
ok  	github.com/carlosframework/platform/internal/edgesfile	0.005s
ok  	github.com/carlosframework/platform/internal/entitlement	0.010s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.096s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/host	7.134s
ok  	github.com/carlosframework/platform/internal/infraview	0.005s
ok  	github.com/carlosframework/platform/internal/instancectl	0.417s
ok  	github.com/carlosframework/platform/internal/instanceexec	0.010s
ok  	github.com/carlosframework/platform/internal/instancefs	0.003s
ok  	github.com/carlosframework/platform/internal/lease	5.545s
ok  	github.com/carlosframework/platform/internal/ledger	0.012s
ok  	github.com/carlosframework/platform/internal/mail	0.041s
ok  	github.com/carlosframework/platform/internal/maintenance	0.082s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.493s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.043s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/objstore	8.627s
ok  	github.com/carlosframework/platform/internal/ops	15.434s
ok  	github.com/carlosframework/platform/internal/ops/admissionmodel	0.088s
ok  	github.com/carlosframework/platform/internal/plans	0.051s
ok  	github.com/carlosframework/platform/internal/privatefile	0.008s
ok  	github.com/carlosframework/platform/internal/provision	0.846s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
ok  	github.com/carlosframework/platform/internal/registry	1.472s
ok  	github.com/carlosframework/platform/internal/relay	0.184s
ok  	github.com/carlosframework/platform/internal/release	12.079s
ok  	github.com/carlosframework/platform/internal/resourcebudget	0.319s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/schedule	1.039s
ok  	github.com/carlosframework/platform/internal/sigfence	0.031s
ok  	github.com/carlosframework/platform/internal/status	0.029s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.007s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.007s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/usage	3.478s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.563s
ok  	github.com/benbjohnson/litestream/s3	1.463s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.025s
minio contract: PASS
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index dff59be47..a7bad0633 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -164,6 +164,9 @@ type Edge struct {
// Reload hooks remain outside it: this lock protects only the order in
// which complete credential/account snapshots become visible.
fleetRuntimeMu sync.Mutex
+ // routeReloadMu serializes each complete route read through its prober,
+ // upstream-map and activator install. Reload hooks remain outside it.
+ routeReloadMu sync.Mutex
// cache, when non-nil, answers cacheable GET/HEAD requests without ever
// invoking the hibernation activator or the backend — the mitigation
// this whole feature exists for. nil disables caching entirely.
@@ -395,6 +398,7 @@ func (e *Edge) armStoreDoor() {
}
type fleetRuntimeReader func() ([]registry.FleetRuntimeEntry, error)
+type routeReader func() ([]registry.Route, error)
// reloadFleetRuntime keeps concurrent reloads in read/install order. The
// reader seam is intentionally private and narrow so tests can stop one read
@@ -447,7 +451,23 @@ func (e *Edge) statusHandlerFor(rt registry.Route) http.Handler {
// Reload rebuilds the host→backend map from the registry and swaps it in
// atomically. Safe to call while serving.
func (e *Edge) Reload() error {
- routes, err := e.reg.List()
+ if err := e.reloadRoutes(e.reg.List); err != nil {
+ return err
+ }
+ if fn := e.reloadHook.Load(); fn != nil {
+ (*fn)()
+ }
+ return nil
+}
+
+// reloadRoutes serializes each call from before its route read through its
+// install. It does not combine Registry's separate reads or change their
+// connection or freshness semantics. The reader seam is private and narrow
+// so tests can stop one read without adding hooks to Registry or production.
+func (e *Edge) reloadRoutes(read routeReader) error {
+ e.routeReloadMu.Lock()
+ defer e.routeReloadMu.Unlock()
+ routes, err := read()
if err != nil {
return err
}
@@ -795,9 +815,6 @@ func (e *Edge) Reload() error {
if e.act != nil {
e.act.RetireExcept(managed)
}
- if fn := e.reloadHook.Load(); fn != nil {
- (*fn)()
- }
return nil
}
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 2f92f98dc..205bd5edd 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
@@ -15,6 +16,7 @@ import (
"path/filepath"
"slices"
"strings"
+ "sync"
"sync/atomic"
"syscall"
"testing"
@@ -264,6 +266,85 @@ func (f *fakeActivator) RetireExcept(keep map[string]bool) {
f.retired = append(f.retired, keep)
}
+type routeReloadRetireGate struct {
+ host string
+ edge *Edge
+ entered chan struct{}
+ release chan struct{}
+ failures chan string
+ once sync.Once
+}
+
+// routeReloadActivator is deliberately separate from fakeActivator: reload
+// serialization tests call RetireExcept concurrently and need synchronized
+// records plus a gate at the final install effect.
+type routeReloadActivator struct {
+ mu sync.Mutex
+ retired []map[string]bool
+ gate *routeReloadRetireGate
+}
+
+func (a *routeReloadActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ proxy.ServeHTTP(w, r)
+ return true
+}
+
+func (a *routeReloadActivator) RetireExcept(keep map[string]bool) {
+ copyKeep := make(map[string]bool, len(keep))
+ for host, kept := range keep {
+ copyKeep[host] = kept
+ }
+ a.mu.Lock()
+ a.retired = append(a.retired, copyKeep)
+ gate := a.gate
+ a.mu.Unlock()
+
+ if gate == nil || !keep[gate.host] {
+ return
+ }
+ if gate.edge.routeReloadMu.TryLock() {
+ gate.edge.routeReloadMu.Unlock()
+ select {
+ case gate.failures <- "route reload mutex was not held inside RetireExcept":
+ default:
+ }
+ }
+ gate.once.Do(func() { close(gate.entered) })
+ select {
+ case <-gate.release:
+ case <-time.After(5 * time.Second):
+ select {
+ case gate.failures <- "timed out waiting to release RetireExcept gate":
+ default:
+ }
+ }
+}
+
+func (a *routeReloadActivator) arm(gate *routeReloadRetireGate) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.gate = gate
+}
+
+func (a *routeReloadActivator) count() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return len(a.retired)
+}
+
+func (a *routeReloadActivator) last() map[string]bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if len(a.retired) == 0 {
+ return nil
+ }
+ out := make(map[string]bool, len(a.retired[len(a.retired)-1]))
+ for host, kept := range a.retired[len(a.retired)-1] {
+ out[host] = kept
+ }
+ return out
+}
+
// testEdgeWithStore builds an Edge with one route already loaded and static
// serving (the plumbing kind=status shares — see EnableStatic's doc comment)
// wired to a fresh, empty DirStore. It returns the store (so a test can Put
@@ -1681,6 +1762,205 @@ func frozenHealth() provision.Health {
return provision.Health{Path: testProbePath, Status: 200, IntervalMS: 60000, FailAfter: 1000, RiseAfter: 1000}
}
+func TestRouteReloadsSerializeReadThroughRetire(t *testing.T) {
+ reg := openReg(t)
+ act := &routeReloadActivator{}
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+
+ addrA := deadAddr(t)
+ addrB := deadAddr(t)
+ routeA := registry.Route{
+ Host: "a.example.com", Kind: "instance", Status: "ready", Hibernate: true,
+ Addrs: addrA, Health: healthJSON(t, frozenHealth()),
+ }
+ routeB := registry.Route{
+ Host: "b.example.com", Kind: "instance", Status: "ready", Hibernate: true,
+ Addrs: addrB, Health: healthJSON(t, frozenHealth()),
+ }
+
+ firstEntered := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ secondStarted := make(chan struct{})
+ secondEntered := make(chan struct{})
+ retireEntered := make(chan struct{})
+ releaseRetire := make(chan struct{})
+ failures := make(chan string, 4)
+ firstDone := make(chan error, 1)
+ secondDone := make(chan error, 1)
+ var releaseFirstOnce, releaseRetireOnce sync.Once
+ t.Cleanup(func() { releaseFirstOnce.Do(func() { close(releaseFirst) }) })
+ t.Cleanup(func() { releaseRetireOnce.Do(func() { close(releaseRetire) }) })
+
+ act.arm(&routeReloadRetireGate{
+ host: routeA.Host, edge: e, entered: retireEntered, release: releaseRetire, failures: failures,
+ })
+ go func() {
+ firstDone <- e.reloadRoutes(func() ([]registry.Route, error) {
+ if e.routeReloadMu.TryLock() {
+ e.routeReloadMu.Unlock()
+ failures <- "route reload mutex was not held inside the first reader"
+ }
+ close(firstEntered)
+ select {
+ case <-releaseFirst:
+ case <-time.After(5 * time.Second):
+ failures <- "timed out waiting to release the first reader"
+ }
+ return []registry.Route{routeA}, nil
+ })
+ }()
+ select {
+ case <-firstEntered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("first route reader did not enter")
+ }
+ select {
+ case failure := <-failures:
+ t.Fatal(failure)
+ default:
+ }
+ go func() {
+ close(secondStarted)
+ secondDone <- e.reloadRoutes(func() ([]registry.Route, error) {
+ close(secondEntered)
+ return []registry.Route{routeB}, nil
+ })
+ }()
+ select {
+ case <-secondStarted:
+ case <-time.After(5 * time.Second):
+ t.Fatal("second reload did not start")
+ }
+ select {
+ case <-secondEntered:
+ t.Fatal("second route reader entered while the first reader was gated")
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ releaseFirstOnce.Do(func() { close(releaseFirst) })
+ select {
+ case <-retireEntered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("first reload did not reach RetireExcept")
+ }
+ select {
+ case failure := <-failures:
+ t.Fatal(failure)
+ default:
+ }
+ select {
+ case <-secondEntered:
+ t.Fatal("second route reader entered while the first RetireExcept was gated")
+ case <-time.After(100 * time.Millisecond):
+ }
+ releaseRetireOnce.Do(func() { close(releaseRetire) })
+
+ select {
+ case err := <-firstDone:
+ if err != nil {
+ t.Fatalf("first reload: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("first reload did not finish")
+ }
+ select {
+ case err := <-secondDone:
+ if err != nil {
+ t.Fatalf("second reload: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("second reload did not finish")
+ }
+ select {
+ case failure := <-failures:
+ t.Fatal(failure)
+ default:
+ }
+
+ current := e.current()
+ if _, ok := current[routeB.Host]; !ok {
+ t.Fatalf("final route map lacks newer host %q", routeB.Host)
+ }
+ if _, ok := current[routeA.Host]; ok {
+ t.Fatalf("final route map retained older host %q", routeA.Host)
+ }
+ lastKeep := act.last()
+ if !lastKeep[routeB.Host] || lastKeep[routeA.Host] {
+ t.Fatalf("last activator keep set = %v, want only %q", lastKeep, routeB.Host)
+ }
+ probeState := e.probers.State()
+ if _, ok := probeState[routeB.Host]; !ok {
+ t.Fatalf("final prober set lacks newer host %q", routeB.Host)
+ }
+ if _, ok := probeState[routeA.Host]; ok {
+ t.Fatalf("final prober set retained older host %q", routeA.Host)
+ }
+}
+
+func TestRouteReloadReadFailureWithholdsInstallEffects(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := registry.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ route := registry.Route{
+ Host: "kept.example.com", Kind: "instance", Status: "ready", Hibernate: true,
+ Addrs: deadAddr(t), Health: healthJSON(t, frozenHealth()),
+ }
+ if err := reg.Upsert(route); err != nil {
+ t.Fatal(err)
+ }
+ act := &routeReloadActivator{}
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ beforeMap := e.upstreams.Load()
+ beforeRetires := act.count()
+ beforeProbers := e.probers.State()
+ var hookCalls atomic.Int32
+ e.OnReload(func() { hookCalls.Add(1) })
+
+ breaker, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { breaker.Close() })
+ if _, err := breaker.Exec(`DROP TABLE route_paths`); err != nil {
+ t.Fatal(err)
+ }
+ if err := e.Reload(); err == nil {
+ t.Fatal("Reload after dropping route_paths succeeded, want a real path read failure")
+ }
+ if got := e.upstreams.Load(); got != beforeMap {
+ t.Fatal("failed reload replaced the upstream map")
+ }
+ if got := act.count(); got != beforeRetires {
+ t.Fatalf("failed reload called RetireExcept %d additional time(s)", got-beforeRetires)
+ }
+ afterProbers := e.probers.State()
+ if len(afterProbers) != len(beforeProbers) {
+ t.Fatalf("failed reload changed prober host count from %d to %d", len(beforeProbers), len(afterProbers))
+ }
+ for host := range beforeProbers {
+ if _, ok := afterProbers[host]; !ok {
+ t.Fatalf("failed reload removed prober host %q", host)
+ }
+ }
+ if got := hookCalls.Load(); got != 0 {
+ t.Fatalf("failed reload called hook %d time(s)", got)
+ }
+ if !e.routeReloadMu.TryLock() {
+ t.Fatal("failed reload did not release route reload mutex")
+ }
+ e.routeReloadMu.Unlock()
+}
+
func TestMultiUpstreamBalancesAcrossLiveBackends(t *testing.T) {
addrA, hitsA := countingBackend(t, "A")
addrB, hitsB := countingBackend(t, "B")
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 8b3f5e182..0e28be276 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -14,6 +14,7 @@ import (
"slices"
"sort"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -408,6 +409,43 @@ func TestOnReloadHookFires(t *testing.T) {
}
}
+func TestOnReloadHookCanReload(t *testing.T) {
+ reg := openReg(t)
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ var calls atomic.Int32
+ nestedDone := make(chan error, 1)
+ e.OnReload(func() {
+ if calls.Add(1) == 1 {
+ nestedDone <- e.Reload()
+ }
+ })
+ outerDone := make(chan error, 1)
+ go func() { outerDone <- e.Reload() }()
+ select {
+ case err := <-nestedDone:
+ if err != nil {
+ t.Fatalf("nested Reload: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("nested Reload blocked inside reload hook")
+ }
+ select {
+ case err := <-outerDone:
+ if err != nil {
+ t.Fatalf("outer Reload: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("outer Reload did not finish")
+ }
+ if got := calls.Load(); got != 2 {
+ t.Fatalf("hook fired %d times, want 2 for two successful reloads", got)
+ }
+}
+
// TestCurrentHostsPlatformOnly guards the wildcard-safety filter: only
// platform-minted routes contribute to currentHosts — console-minted
// (Platform==true) or records-provisioned (Provisioned==true) — since