Serialize edge route reloads through installation
3 files changed,
+339
−4
internal/edge/edge.go+21 −4internal/edge/edge_test.go+280 −0internal/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 |