v0.5.0 c37833b

registry_test.go RawHistoryFolder

1package broker
2
3import (
4	"bytes"
5	"context"
6	"crypto/sha256"
7	"errors"
8	"fmt"
9	"log/slog"
10	"sort"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15	"unicode/utf8"
16
17	"github.com/bnema/vev/internal/domain"
18	"github.com/bnema/vev/internal/ports"
19	"github.com/bnema/vev/internal/protocol"
20	"github.com/bnema/vev/internal/protocol/catalogue"
21	"github.com/stretchr/testify/require"
22)
23
24// probeGrace bounds a negative scheduling assertion: a probe may not start
25// inside this window, so the test observes "nothing happened" without
26// sleeping on the behavior under test.
27const probeGrace = 25 * time.Millisecond
28
29// manualClock is a deterministic ports.Clock/ports.Timer pair: time only moves
30// when Advance is called, and timers with equal deadlines fire in creation
31// order. Scheduling assertions are therefore exact instead of racing wall time.
32type manualClock struct {
33	mu     sync.Mutex
34	now    time.Time
35	order  uint64
36	timers map[*manualTimer]struct{}
37}
38
39func newManualClock(start time.Time) *manualClock {
40	return &manualClock{now: start, timers: make(map[*manualTimer]struct{})}
41}
42
43func (c *manualClock) Now() time.Time {
44	c.mu.Lock()
45	defer c.mu.Unlock()
46	return c.now
47}
48
49func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
50	timer := &manualTimer{clock: c, ch: make(chan time.Time, 1)}
51	c.mu.Lock()
52	defer c.mu.Unlock()
53	timer.arm(d)
54	return timer
55}
56
57// Advance moves time forward and fires every timer that came due.
58func (c *manualClock) Advance(d time.Duration) {
59	if d < 0 {
60		panic("broker: manualClock.Advance requires a non-negative duration")
61	}
62	c.mu.Lock()
63	defer c.mu.Unlock()
64	c.now = c.now.Add(d)
65	due := make([]*manualTimer, 0, len(c.timers))
66	for timer := range c.timers {
67		if !timer.when.After(c.now) {
68			due = append(due, timer)
69		}
70	}
71	sort.Slice(due, func(i, j int) bool {
72		if due[i].when.Equal(due[j].when) {
73			return due[i].order < due[j].order
74		}
75		return due[i].when.Before(due[j].when)
76	})
77	for _, timer := range due {
78		timer.active = false
79		delete(c.timers, timer)
80		select {
81		case timer.ch <- c.now:
82		default:
83		}
84	}
85}
86
87type manualTimer struct {
88	clock  *manualClock
89	ch     chan time.Time
90	when   time.Time
91	order  uint64
92	active bool
93}
94
95func (t *manualTimer) C() <-chan time.Time { return t.ch }
96
97func (t *manualTimer) Stop() bool {
98	t.clock.mu.Lock()
99	defer t.clock.mu.Unlock()
100	if !t.active {
101		return false
102	}
103	t.active = false
104	delete(t.clock.timers, t)
105	return true
106}
107
108func (t *manualTimer) Reset(d time.Duration) bool {
109	t.clock.mu.Lock()
110	defer t.clock.mu.Unlock()
111	wasActive := t.active
112	if t.active {
113		t.active = false
114		delete(t.clock.timers, t)
115	}
116	select {
117	case <-t.ch:
118	default:
119	}
120	t.arm(d)
121	return wasActive
122}
123
124// arm schedules the timer relative to the clock's current time. The caller
125// must hold clock.mu.
126func (t *manualTimer) arm(d time.Duration) {
127	t.when = t.clock.now.Add(d)
128	t.order = t.clock.order
129	t.clock.order++
130	if d <= 0 {
131		t.active = false
132		select {
133		case t.ch <- t.clock.now:
134		default:
135		}
136		return
137	}
138	t.active = true
139	t.clock.timers[t] = struct{}{}
140}
141
142// identityJitter removes scheduling jitter so a test can assert exact
143// deadlines; the production jitter is covered separately.
144func identityJitter(d time.Duration, _ string, _ uint64) time.Duration { return d }
145
146// syncBuffer captures log output written from the registry's persistence
147// goroutine as well as the test goroutine.
148type syncBuffer struct {
149	mu sync.Mutex
150	b  bytes.Buffer
151}
152
153func (w *syncBuffer) Write(p []byte) (int, error) {
154	w.mu.Lock()
155	defer w.mu.Unlock()
156	return w.b.Write(p)
157}
158
159func (w *syncBuffer) String() string {
160	w.mu.Lock()
161	defer w.mu.Unlock()
162	return w.b.String()
163}
164
165func testLogger() (*slog.Logger, *syncBuffer) {
166	logs := &syncBuffer{}
167	return slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})), logs
168}
169
170// testStore is a race-safe in-memory durable store whose writes signal a
171// capacity-one channel so tests wait on a write instead of on wall time. It
172// implements the whole BrokerHostStore seam: authority defaults to the hosts of
173// the restored snapshot, so a fixture that only names a durable snapshot still
174// keeps exercising the rule that observations intersect authoritative
175// membership.
176type testStore struct {
177	mu       sync.Mutex
178	loaded   ports.BrokerSnapshot
179	loadErr  error
180	hosts    ports.BrokerHosts
181	hostsErr error
182	stored   ports.BrokerSnapshot
183	storeErr error
184	writes   int
185	changes  chan struct{}
186}
187
188func (s *testStore) Load() (ports.BrokerSnapshot, error) {
189	s.mu.Lock()
190	defer s.mu.Unlock()
191	if s.loadErr != nil {
192		return ports.BrokerSnapshot{}, s.loadErr
193	}
194	return s.loaded.Clone(), nil
195}
196
197func (s *testStore) LoadHosts() (ports.BrokerHosts, error) {
198	s.mu.Lock()
199	defer s.mu.Unlock()
200	if s.hostsErr != nil {
201		return ports.BrokerHosts{}, s.hostsErr
202	}
203	if s.hosts.Revision != 0 || len(s.hosts.Hosts) > 0 {
204		return s.hosts, nil
205	}
206	authority := ports.BrokerHosts{Revision: 1}
207	for _, host := range s.loaded.Daemons {
208		authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy(), Route: canonicalRoute(poolPolicy(), host.Registration.Endpoint)})
209	}
210	return authority, nil
211}
212
213func (s *testStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error {
214	s.mu.Lock()
215	defer s.mu.Unlock()
216	if s.hostsErr != nil {
217		return s.hostsErr
218	}
219	if s.hosts.Revision == 0 {
220		s.hosts.Revision = 1
221	}
222	if expected != s.hosts.Revision {
223		return ports.ErrBrokerHostConflict
224	}
225	s.hosts.Hosts = append([]ports.BrokerHostRecord(nil), hosts...)
226	s.hosts.Revision++
227	return nil
228}
229
230func (s *testStore) Close() error { return nil }
231
232func (s *testStore) Store(snapshot ports.BrokerSnapshot) error {
233	s.mu.Lock()
234	s.stored = snapshot.Clone()
235	s.writes++
236	err := s.storeErr
237	s.mu.Unlock()
238	s.signal()
239	return err
240}
241
242func (s *testStore) storedSnapshot() ports.BrokerSnapshot {
243	s.mu.Lock()
244	defer s.mu.Unlock()
245	return s.stored.Clone()
246}
247
248func (s *testStore) writeCount() int {
249	s.mu.Lock()
250	defer s.mu.Unlock()
251	return s.writes
252}
253
254func (s *testStore) setStoreErr(err error) {
255	s.mu.Lock()
256	defer s.mu.Unlock()
257	s.storeErr = err
258}
259
260func (s *testStore) signal() {
261	s.mu.Lock()
262	if s.changes == nil {
263		s.changes = make(chan struct{}, 1)
264	}
265	changes := s.changes
266	s.mu.Unlock()
267	select {
268	case changes <- struct{}{}:
269	default:
270	}
271}
272
273func (s *testStore) changesChan() chan struct{} {
274	s.mu.Lock()
275	defer s.mu.Unlock()
276	if s.changes == nil {
277		s.changes = make(chan struct{}, 1)
278	}
279	return s.changes
280}
281
282// blockingStore holds the first durable write inside Store so a test can prove
283// that publication never waits behind persistence.
284type blockingStore struct {
285	mu        sync.Mutex
286	revisions []ports.BrokerRevision
287	entered   chan ports.BrokerRevision
288	released  chan struct{}
289	first     sync.Once
290}
291
292func newBlockingStore() *blockingStore {
293	return &blockingStore{entered: make(chan ports.BrokerRevision, 1), released: make(chan struct{})}
294}
295
296func (s *blockingStore) Load() (ports.BrokerSnapshot, error) { return ports.BrokerSnapshot{}, nil }
297
298func (s *blockingStore) LoadHosts() (ports.BrokerHosts, error) {
299	return ports.BrokerHosts{Revision: 1}, nil
300}
301
302func (s *blockingStore) ReplaceHosts(uint64, []ports.BrokerHostRecord) error { return nil }
303
304func (s *blockingStore) Close() error { return nil }
305
306func (s *blockingStore) Store(snapshot ports.BrokerSnapshot) error {
307	s.mu.Lock()
308	s.revisions = append(s.revisions, snapshot.Revision)
309	s.mu.Unlock()
310	s.first.Do(func() {
311		select {
312		case s.entered <- snapshot.Revision:
313		default:
314		}
315		<-s.released
316	})
317	return nil
318}
319
320func (s *blockingStore) releaseFirst() { close(s.released) }
321
322func (s *blockingStore) writtenRevisions() []ports.BrokerRevision {
323	s.mu.Lock()
324	defer s.mu.Unlock()
325	return append([]ports.BrokerRevision(nil), s.revisions...)
326}
327
328// blockingCASStore parks the first durable membership CAS until it is released,
329// so a test can prove publication and Snapshot never wait behind membership
330// durability. Load, LoadHosts, and Store keep the race-safe testStore behavior,
331// and every later CAS is admitted without blocking.
332type blockingCASStore struct {
333	testStore
334	entered  chan struct{}
335	released chan struct{}
336	block    sync.Once
337	open     sync.Once
338}
339
340func newBlockingCASStore() *blockingCASStore {
341	return &blockingCASStore{entered: make(chan struct{}, 1), released: make(chan struct{})}
342}
343
344func (s *blockingCASStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error {
345	s.block.Do(func() {
346		s.entered <- struct{}{}
347		<-s.released
348	})
349	return s.testStore.ReplaceHosts(expected, hosts)
350}
351
352// releaseCAS lets the parked CAS commit. It is idempotent so a failing test can
353// unblock the parked mutator from cleanup.
354func (s *blockingCASStore) releaseCAS() { s.open.Do(func() { close(s.released) }) }
355
356// testProbe scripts one observation per received call: the test owns the
357// completion, so a probe never depends on wall time.
358type testProbe struct {
359	calls    chan probeCall
360	canceled chan struct{}
361}
362
363func newTestProbe(capacity int) *testProbe {
364	return &testProbe{calls: make(chan probeCall, capacity), canceled: make(chan struct{}, 1)}
365}
366
367type probeCall struct {
368	registration domain.RemoteRegistration
369	result       chan probeAnswer
370}
371
372type probeAnswer struct {
373	snapshot ports.BrokerDaemonObservation
374	err      error
375}
376
377func (p *testProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
378	call := probeCall{registration: registration, result: make(chan probeAnswer, 1)}
379	select {
380	case p.calls <- call:
381	case <-ctx.Done():
382		p.noteCanceled()
383		return ports.BrokerDaemonObservation{}, ctx.Err()
384	}
385	select {
386	case answer := <-call.result:
387		return answer.snapshot, answer.err
388	case <-ctx.Done():
389		p.noteCanceled()
390		return ports.BrokerDaemonObservation{}, ctx.Err()
391	}
392}
393
394func (p *testProbe) noteCanceled() {
395	select {
396	case p.canceled <- struct{}{}:
397	default:
398	}
399}
400
401// churnProbe is a ctx-honoring probe used by the churn regression test. It
402// tracks, per endpoint, how many probes were simultaneously live with an
403// uncancelled context: an attempt that is retired without cancelling its
404// context stays live and shows up as an overlap with its replacement.
405type churnProbe struct {
406	release    chan struct{}
407	released   sync.Once
408	startedSig chan struct{}
409
410	mu       sync.Mutex
411	contexts map[context.Context]string
412	maxLive  map[string]int
413	started  int
414}
415
416func newChurnProbe() *churnProbe {
417	return &churnProbe{
418		release:    make(chan struct{}),
419		startedSig: make(chan struct{}, 256),
420		contexts:   make(map[context.Context]string),
421		maxLive:    make(map[string]int),
422	}
423}
424
425func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
426	p.mu.Lock()
427	live := 0
428	for other, endpoint := range p.contexts {
429		if endpoint == registration.Endpoint && other.Err() == nil {
430			live++
431		}
432	}
433	// The attempt is registered even when its context is already cancelled (a
434	// probe launched into a settling run), but only an uncancelled context is
435	// counted live: an attempt retired without cancelling its context stays in
436	// the map with Err()==nil and still overlaps its replacement.
437	p.contexts[ctx] = registration.Endpoint
438	if ctx.Err() == nil {
439		live++
440	}
441	if live > p.maxLive[registration.Endpoint] {
442		p.maxLive[registration.Endpoint] = live
443	}
444	p.started++
445	p.mu.Unlock()
446	select {
447	case p.startedSig <- struct{}{}:
448	default:
449	}
450	defer func() {
451		p.mu.Lock()
452		delete(p.contexts, ctx)
453		p.mu.Unlock()
454	}()
455	select {
456	case <-ctx.Done():
457	case <-p.release:
458	}
459	return ports.BrokerDaemonObservation{}, ctx.Err()
460}
461
462// openRelease lets every still-blocked probe return, so a failing test never
463// leaks probe goroutines.
464func (p *churnProbe) openRelease() { p.released.Do(func() { close(p.release) }) }
465
466func (p *churnProbe) startedCount() int {
467	p.mu.Lock()
468	defer p.mu.Unlock()
469	return p.started
470}
471
472func (p *churnProbe) maxLiveFor(endpoint string) int {
473	p.mu.Lock()
474	defer p.mu.Unlock()
475	return p.maxLive[endpoint]
476}
477
478func (p *churnProbe) observedEndpoints() []string {
479	p.mu.Lock()
480	defer p.mu.Unlock()
481	endpoints := make([]string, 0, len(p.maxLive))
482	for endpoint := range p.maxLive {
483		endpoints = append(endpoints, endpoint)
484	}
485	sort.Strings(endpoints)
486	return endpoints
487}
488
489func (p *churnProbe) inflight() int {
490	p.mu.Lock()
491	defer p.mu.Unlock()
492	return len(p.contexts)
493}
494
495func registration(t *testing.T, endpoint string, marker byte) domain.RemoteRegistration {
496	t.Helper()
497	var incarnation [16]byte
498	incarnation[0] = marker
499	result, err := domain.NewRemoteRegistration(endpoint, incarnation)
500	require.NoError(t, err)
501	return result
502}
503
504// canonicalRoute derives the exact route production would write for one test
505// policy and endpoint. Test policies only use the closed remote transport
506// vocabulary, so the mapping cannot fail; a fixture that hand-builds membership
507// therefore matches what AddHost or UpgradeBrokerHostRoutes would persist.
508func canonicalRoute(policy ports.BrokerPolicy, endpoint string) ports.BrokerRouteSpec {
509	route, err := ports.BrokerRouteForTransport(policy.Transport, endpoint)
510	if err != nil {
511		panic(err)
512	}
513	return route
514}
515
516// hostRecord builds one pinned authority record with the shared test policy and
517// the canonical route production derives from it. The projection-only setHosts
518// seam takes records now, so membership carries the same configured authority
519// (registration, policy, and route) as production.
520func hostRecord(reg domain.RemoteRegistration) ports.BrokerHostRecord {
521	return ports.BrokerHostRecord{Registration: reg, Pinned: true, Policy: poolPolicy(), Route: canonicalRoute(poolPolicy(), reg.Endpoint)}
522}
523
524func hostRecords(registrations ...domain.RemoteRegistration) []ports.BrokerHostRecord {
525	records := make([]ports.BrokerHostRecord, 0, len(registrations))
526	for _, reg := range registrations {
527		records = append(records, hostRecord(reg))
528	}
529	return records
530}
531
532// hostSession builds one catalogue-valid session. The registry applies the
533// same durable projection rules as the store before adopting an observation, so
534// a fixture session must carry a non-zero lifecycle identity and a non-nil tab
535// list to count as durable.
536func hostSession(name string) catalogue.RemoteCatalogSession {
537	sum := sha256.Sum256([]byte("lifecycle:" + name))
538	lifecycle := domain.SessionLifecycleID{}
539	copy(lifecycle[:], sum[:])
540	return catalogue.RemoteCatalogSession{
541		LifecycleID: lifecycle,
542		Name:        name,
543		State:       catalogue.RemoteCatalogSessionUp,
544		Tabs:        []catalogue.RemoteCatalogTab{},
545	}
546}
547
548// hostSessions builds count minimal catalogue-valid sessions for the projection
549// session bound tests.
550func hostSessions(count int) []catalogue.RemoteCatalogSession {
551	sessions := make([]catalogue.RemoteCatalogSession, 0, count)
552	for index := 0; index < count; index++ {
553		sessions = append(sessions, hostSession(fmt.Sprintf("session-%d", index)))
554	}
555	return sessions
556}
557
558// newTestStore returns the minimal authoritative durable store: a valid
559// revision series over empty membership. NewRegistry requires a
560// BrokerHostStore, so every registry fixture names this store explicitly
561// instead of relying on an optional seam.
562func newTestStore() *testStore { return &testStore{} }
563
564// newTestRegistry builds a registry with exact scheduling and no logger so a
565// failure never depends on jitter.
566func newTestRegistry(t *testing.T, epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock *manualClock) *Registry {
567	t.Helper()
568	registry, err := NewRegistry(epoch, store, probe, clock, nil)
569	require.NoError(t, err)
570	registry.jitter = identityJitter
571	return registry
572}
573
574func startRegistry(t *testing.T, r *Registry) context.CancelFunc {
575	t.Helper()
576	r.SetDemand(true)
577	ctx, cancel := context.WithCancel(context.Background())
578	done := make(chan struct{})
579	go func() { defer close(done); r.Run(ctx) }()
580	t.Cleanup(func() { cancel(); <-done; r.SetDemand(false) })
581	return cancel
582}
583
584// registryTestWait bounds how long a test waits for an expected probe or
585// snapshot. It only delays a failing test, so it is generous for loaded
586// race-enabled CI runners.
587const registryTestWait = 5 * time.Second
588
589func receiveCall(t *testing.T, probe *testProbe) probeCall {
590	t.Helper()
591	select {
592	case call := <-probe.calls:
593		return call
594	case <-time.After(registryTestWait):
595		t.Fatal("timed out waiting for probe")
596		return probeCall{}
597	}
598}
599
600func requireNoProbeWithin(t *testing.T, probe *testProbe, window time.Duration) {
601	t.Helper()
602	timer := time.NewTimer(window)
603	defer timer.Stop()
604	select {
605	case call := <-probe.calls:
606		t.Fatalf("unexpected probe for %q", call.registration.Endpoint)
607	case <-timer.C:
608	}
609}
610
611func waitSnapshot(t *testing.T, r *Registry, predicate func(ports.BrokerSnapshot) bool) ports.BrokerSnapshot {
612	t.Helper()
613	sub := r.Subscribe()
614	defer sub.Close()
615	deadline := time.After(registryTestWait)
616	for {
617		snapshot := r.Snapshot()
618		if predicate(snapshot) {
619			return snapshot
620		}
621		select {
622		case <-sub.Changed():
623		case <-deadline:
624			t.Fatal("timed out waiting for snapshot")
625		}
626	}
627}
628
629func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation {
630	t.Helper()
631	snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool {
632		host, ok := snapshot.Find(endpoint)
633		return ok && predicate(host)
634	})
635	host, _ := snapshot.Find(endpoint)
636	return host
637}
638
639// requireHostStableWithin asserts a stale completion has no delayed effect:
640// the published host stays exactly as observed for a bounded window, and any
641// intervening publication fails immediately.
642func requireHostStableWithin(t *testing.T, r *Registry, endpoint string, want ports.BrokerDaemonObservation, window time.Duration) {
643	t.Helper()
644	sub := r.Subscribe()
645	defer sub.Close()
646	deadline := time.After(window)
647	for {
648		got, ok := r.Snapshot().Find(endpoint)
649		require.True(t, ok)
650		require.Equal(t, want, got)
651		select {
652		case <-sub.Changed():
653		case <-deadline:
654			return
655		}
656	}
657}
658
659// waitStored waits for a durable write satisfying predicate.
660func waitStored(t *testing.T, store *testStore, predicate func(ports.BrokerSnapshot) bool) ports.BrokerSnapshot {
661	t.Helper()
662	deadline := time.After(2 * time.Second)
663	for {
664		if snapshot := store.storedSnapshot(); predicate(snapshot) {
665			return snapshot
666		}
667		select {
668		case <-store.changesChan():
669		case <-deadline:
670			t.Fatal("timed out waiting for durable snapshot")
671			return ports.BrokerSnapshot{}
672		}
673	}
674}
675
676// waitWriterRevision waits until the serializer reports the revision durable.
677func waitWriterRevision(t *testing.T, r *Registry, revision ports.BrokerRevision) {
678	t.Helper()
679	deadline := time.Now().Add(2 * time.Second)
680	for {
681		r.store.mu.Lock()
682		written := r.store.written
683		r.store.mu.Unlock()
684		if written >= revision {
685			return
686		}
687		if time.Now().After(deadline) {
688			t.Fatalf("timed out waiting for durable revision %d", revision)
689		}
690		time.Sleep(time.Millisecond)
691	}
692}
693
694// waitForLog waits until captured log output contains want, so a persistence
695// fault is proven observable instead of silently dropped.
696func waitForLog(t *testing.T, logs *syncBuffer, want string) {
697	t.Helper()
698	deadline := time.Now().Add(2 * time.Second)
699	for !strings.Contains(logs.String(), want) {
700		if time.Now().After(deadline) {
701			t.Fatalf("timed out waiting for log %q in %q", want, logs.String())
702		}
703		time.Sleep(time.Millisecond)
704	}
705}
706
707// attemptFor returns the attempt token currently in flight for an endpoint,
708// or zero when no attempt is admitted.
709func attemptFor(r *Registry, endpoint string) uint64 {
710	r.mu.Lock()
711	defer r.mu.Unlock()
712	if attempt := r.inflight[endpoint]; attempt != nil {
713		return attempt.token
714	}
715	return 0
716}
717
718func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) {
719	reg := registration(t, "example.test", 1)
720	sessions := []catalogue.RemoteCatalogSession{{
721		LifecycleID: domain.SessionLifecycleID{1},
722		Name:        "work",
723		State:       catalogue.RemoteCatalogSessionUp,
724		Tabs:        []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
725	}}
726	stored := ports.BrokerSnapshot{
727		Epoch:    7,
728		Revision: 9,
729		Daemons: []ports.BrokerDaemonObservation{{
730			Endpoint:       reg.Endpoint,
731			Registration:   reg,
732			Availability:   domain.RemoteAvailabilityReachable,
733			Checking:       true,
734			LastSuccess:    time.Unix(50, 0),
735			NextDue:        time.Unix(80, 0),
736			InventoryKnown: true,
737			Sessions:       sessions,
738		}},
739	}
740	store := &testStore{loaded: stored}
741	registry, err := NewRegistry(11, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil)
742	require.NoError(t, err)
743
744	snapshot := registry.Snapshot()
745	require.Equal(t, ports.BrokerEpoch(11), snapshot.Epoch)
746	// A restored broker process starts a fresh revision series: revisions
747	// never compare across epochs.
748	require.Equal(t, ports.BrokerRevision(1), snapshot.Revision)
749	require.Len(t, snapshot.Daemons, 1)
750	require.Empty(t, snapshot.Removed)
751
752	host := snapshot.Daemons[0]
753	require.True(t, host.Registration.Equal(reg))
754	require.Equal(t, domain.RemoteAvailabilityReachable, host.Availability)
755	require.Equal(t, time.Unix(80, 0), host.NextDue)
756	require.Equal(t, sessions, host.Sessions)
757	// In-flight observation state never survives a restart.
758	require.False(t, host.Checking)
759
760	// Restoring is read-only: the registry republishes under its own epoch
761	// without rewriting the durable store.
762	require.Equal(t, ports.BrokerRevision(0), store.storedSnapshot().Revision)
763	require.Zero(t, store.writeCount())
764}
765
766func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) {
767	clock := newManualClock(time.Unix(100, 0))
768	reg := registration(t, "example.test", 1)
769
770	_, err := NewRegistry(0, newTestStore(), newTestProbe(1), clock, nil)
771	require.Error(t, err)
772	_, err = NewRegistry(1, newTestStore(), nil, clock, nil)
773	require.Error(t, err)
774	_, err = NewRegistry(1, newTestStore(), newTestProbe(1), nil, nil)
775	require.Error(t, err)
776
777	// The store is a required dependency: a literal nil store is rejected, and
778	// a typed nil behind the interface is rejected without panicking.
779	_, err = NewRegistry(1, nil, newTestProbe(1), clock, nil)
780	require.Error(t, err)
781	var typedNilStore *testStore
782	_, err = NewRegistry(1, typedNilStore, newTestProbe(1), clock, nil)
783	require.Error(t, err)
784
785	loadErr := errors.New("load failed")
786	_, err = NewRegistry(1, &testStore{loadErr: loadErr}, newTestProbe(1), clock, nil)
787	require.ErrorIs(t, err, loadErr)
788
789	_, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 7}}, newTestProbe(1), clock, nil)
790	require.Error(t, err)
791
792	// A durable record without an epoch is inconsistent, never silently empty.
793	_, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Daemons: []ports.BrokerDaemonObservation{{Endpoint: reg.Endpoint, Registration: reg}}}}, newTestProbe(1), clock, nil)
794	require.Error(t, err)
795	_, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Removed: []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 3}}}}, newTestProbe(1), clock, nil)
796	require.Error(t, err)
797	_, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Revision: 4}}, newTestProbe(1), clock, nil)
798	require.Error(t, err)
799
800	empty, err := NewRegistry(1, &testStore{}, newTestProbe(1), clock, nil)
801	require.NoError(t, err)
802	require.Equal(t, ports.BrokerRevision(1), empty.Snapshot().Revision)
803	require.Empty(t, empty.Snapshot().Daemons)
804}
805
806func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
807	clock := newManualClock(time.Unix(100, 0))
808	reg := registration(t, "example.test", 1)
809
810	// A fresh broker process samples a fresh epoch and restarts its revision
811	// series at one, so a durable record from its own epoch can never be
812	// adopted: the restored series would not be monotonic and a stale record
813	// would be indistinguishable from current authority.
814	currentEpoch := ports.BrokerSnapshot{
815		Epoch:    7,
816		Revision: 9,
817		Daemons: []ports.BrokerDaemonObservation{{
818			Endpoint:     reg.Endpoint,
819			Registration: reg,
820			Availability: domain.RemoteAvailabilityReachable,
821		}},
822	}
823	_, err := NewRegistry(7, &testStore{loaded: currentEpoch}, newTestProbe(1), clock, nil)
824	require.Error(t, err)
825	require.Contains(t, err.Error(), "matches the new registry epoch")
826
827	// The same rejection holds for a tombstone-only and for an empty-hosts
828	// record from the current epoch.
829	_, err = NewRegistry(7, &testStore{loaded: ports.BrokerSnapshot{Epoch: 7, Revision: 1, Removed: []ports.BrokerHostTombstone{{
830		Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 1,
831	}}}}, newTestProbe(1), clock, nil)
832	require.Error(t, err)
833	_, err = NewRegistry(7, &testStore{loaded: ports.BrokerSnapshot{Epoch: 7, Revision: 1}}, newTestProbe(1), clock, nil)
834	require.Error(t, err)
835
836	// A different epoch is still adopted normally.
837	registry, err := NewRegistry(8, &testStore{loaded: currentEpoch}, newTestProbe(1), clock, nil)
838	require.NoError(t, err)
839	require.Len(t, registry.Snapshot().Daemons, 1)
840}
841
842func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) {
843	registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
844
845	reg := registration(t, "example.test", 1)
846	require.Error(t, registry.setHosts(hostRecords(reg, reg)))
847	require.Error(t, registry.setHosts(hostRecords(domain.RemoteRegistration{Endpoint: reg.Endpoint})))
848	require.Empty(t, registry.Snapshot().Daemons)
849}
850
851func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) {
852	registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
853	reg := registration(t, "example.test", 1)
854	require.NoError(t, registry.setHosts(hostRecords(reg)))
855
856	oversized := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts+1)
857	for index := 0; index <= ports.BrokerMaxHosts; index++ {
858		oversized = append(oversized, hostRecord(registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1))))
859	}
860	before := registry.Snapshot()
861	require.Error(t, registry.setHosts(oversized))
862	// The rejected call mutates nothing: same membership, same revision.
863	require.Equal(t, before, registry.Snapshot())
864
865	// The boundary itself is accepted.
866	require.NoError(t, registry.setHosts(oversized[:ports.BrokerMaxHosts]))
867	require.Len(t, registry.Snapshot().Daemons, ports.BrokerMaxHosts)
868}
869
870func TestRegistryUnchangedSetHostsIsNoOpAndReorderRepublishes(t *testing.T) {
871	clock := newManualClock(time.Unix(100, 0))
872	probe := newTestProbe(2)
873	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
874	first := registration(t, "example.test", 1)
875	second := registration(t, "other.test", 2)
876	require.NoError(t, registry.setHosts(hostRecords(first, second)))
877	startRegistry(t, registry)
878
879	// Every configured host is observed exactly once.
880	calls := make(map[string]probeCall, 2)
881	for range 2 {
882		call := receiveCall(t, probe)
883		calls[call.registration.Endpoint] = call
884	}
885	require.Len(t, calls, 2)
886	// Dispatch starts each probe while it holds the registry lock and publishes
887	// the in-flight projection at the end of the same critical section, so a
888	// received call is not yet a visible Checking state. Wait for the
889	// publication instead of assuming it has landed.
890	for endpoint := range calls {
891		waitHost(t, registry, endpoint, func(host ports.BrokerDaemonObservation) bool { return host.Checking })
892	}
893	before := registry.Snapshot()
894	for _, host := range before.Daemons {
895		require.Truef(t, host.Checking, "host %q is not checking: %+v", host.Endpoint, before.Daemons)
896	}
897
898	// Identical membership in the same order never republishes and never
899	// restarts an in-flight observation.
900	require.NoError(t, registry.setHosts(hostRecords(first, second)))
901	require.Equal(t, before, registry.Snapshot())
902	requireNoProbeWithin(t, probe, probeGrace)
903
904	// A reordering of identical records is a membership change: publication
905	// order and the presentation rank stamped from it follow durable membership
906	// authority, so the registry republishes both without cancelling the
907	// in-flight observation.
908	require.NoError(t, registry.setHosts(hostRecords(second, first)))
909	reordered := registry.Snapshot()
910	require.Greater(t, reordered.Revision, before.Revision)
911	require.Equal(t, []string{"other.test", "example.test"}, []string{reordered.Daemons[0].Endpoint, reordered.Daemons[1].Endpoint})
912	require.Equal(t, 0, reordered.Daemons[0].Rank)
913	require.Equal(t, 1, reordered.Daemons[1].Rank)
914	for _, host := range reordered.Daemons {
915		require.True(t, host.Checking)
916	}
917	requireNoProbeWithin(t, probe, probeGrace)
918
919	// Restoring the configured order restores the published order and ranks.
920	require.NoError(t, registry.setHosts(hostRecords(first, second)))
921	require.Equal(t, before.Daemons, registry.Snapshot().Daemons)
922	requireNoProbeWithin(t, probe, probeGrace)
923
924	// The in-flight observations still complete normally.
925	for endpoint, call := range calls {
926		call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
927		waitHost(t, registry, endpoint, func(host ports.BrokerDaemonObservation) bool {
928			return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
929		})
930	}
931}
932
933// TestRegistrySanitizesDerivedDisplayOrigin pins the boundary between routing
934// authority and presentation: a target the routing validator accepts may carry
935// bidi controls or line separators, and a hint the registry derives from it must
936// never render them. The sanitized hint is published, satisfies the contract
937// gate, and leaves the host observable.
938func TestRegistrySanitizesDerivedDisplayOrigin(t *testing.T) {
939	tests := []struct {
940		name     string
941		endpoint string
942		want     string
943	}{
944		{name: "login prefix strips the bidi rune from the origin", endpoint: "user@ho\u202est.test", want: "host.test"},
945		{name: "bare target drops the bidi rune", endpoint: "ho\u202est.test", want: "host.test"},
946		{name: "right-to-left mark dropped", endpoint: "user@ho\u200fst.test", want: "host.test"},
947		{name: "ordinary target unchanged", endpoint: "user@host.test", want: "host.test"},
948	}
949	for _, tc := range tests {
950		t.Run(tc.name, func(t *testing.T) {
951			clock := newManualClock(time.Unix(100, 0))
952			probe := newTestProbe(1)
953			registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
954			require.NoError(t, registry.setHosts(hostRecords(registration(t, tc.endpoint, 1))))
955
956			host := registry.Snapshot().Daemons[0]
957			require.Equal(t, tc.endpoint, host.Endpoint, "the routing target stays authoritative")
958			require.Equal(t, tc.want, host.DisplayOrigin)
959			require.NoError(t, host.Validate())
960			require.NoError(t, registry.Snapshot().Validate())
961
962			// Sanitizing the hint never makes the host unobservable.
963			startRegistry(t, registry)
964			call := receiveCall(t, probe)
965			call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
966			waitHost(t, registry, tc.endpoint, func(host ports.BrokerDaemonObservation) bool {
967				return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
968			})
969		})
970	}
971}
972
973// TestRegistryRefusesUnpublishableMembership pins the fail-closed membership
974// boundary: a target whose derived hint holds only refused runes has no
975// displayable origin, and membership that cannot be published is refused where
976// authority is owned instead of freezing every later publication.
977func TestRegistryRefusesUnpublishableMembership(t *testing.T) {
978	registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
979	err := registry.setHosts(hostRecords(registration(t, "user@\u202e", 1)))
980	require.ErrorContains(t, err, "not publishable")
981	require.Empty(t, registry.Snapshot().Daemons)
982}
983
984// TestRegistryRefusesInvalidPublication pins the last-resort publication guard:
985// a projection that reached the live set unpublished is never published, because
986// a snapshot the wire refuses aborts every client connection on subscribe, and a
987// refused publication never advances the revision series.
988func TestRegistryRefusesInvalidPublication(t *testing.T) {
989	registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
990	require.NoError(t, registry.setHosts(hostRecords(registration(t, "example.test", 1))))
991	before := registry.Snapshot()
992
993	// Inject behind the membership boundary, which now refuses such a record.
994	registry.mu.Lock()
995	broken := before.Daemons[0].Clone()
996	broken.DisplayOrigin = ""
997	registry.hosts[broken.Endpoint] = broken
998	registry.publishLocked(false)
999	registry.mu.Unlock()
1000
1001	require.Equal(t, before, registry.Snapshot())
1002}
1003
1004func TestRegistrySnapshotIsImmutable(t *testing.T) {
1005	clock := newManualClock(time.Unix(100, 0))
1006	probe := newTestProbe(1)
1007	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1008	reg := registration(t, "example.test", 1)
1009	require.NoError(t, registry.setHosts(hostRecords(reg)))
1010	startRegistry(t, registry)
1011
1012	call := receiveCall(t, probe)
1013	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1014		Availability:   domain.RemoteAvailabilityReachable,
1015		InventoryKnown: true,
1016		Sessions: []catalogue.RemoteCatalogSession{{
1017			LifecycleID: domain.SessionLifecycleID{1},
1018			Name:        "work",
1019			State:       catalogue.RemoteCatalogSessionUp,
1020			Tabs:        []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
1021		}},
1022	}}
1023	observed := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1024		return !host.Checking && len(host.Sessions) == 1
1025	})
1026	require.Equal(t, "work", observed.Sessions[0].Name)
1027
1028	// Mutating a returned projection never reaches the registry.
1029	observed.Endpoint = "mutated"
1030	observed.Sessions[0].Name = "mutated"
1031	observed.Sessions[0].Tabs[0].Name = "mutated"
1032	current := registry.Snapshot()
1033	require.Equal(t, reg.Endpoint, current.Daemons[0].Endpoint)
1034	require.Equal(t, "work", current.Daemons[0].Sessions[0].Name)
1035	require.Equal(t, "shell", current.Daemons[0].Sessions[0].Tabs[0].Name)
1036
1037	// A later publication never mutates a snapshot already handed out.
1038	earlier := registry.Snapshot()
1039	registry.RequestProbe(reg.Endpoint)
1040	next := receiveCall(t, probe)
1041	next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1042		Availability:   domain.RemoteAvailabilityReachable,
1043		InventoryKnown: true,
1044		Sessions:       []catalogue.RemoteCatalogSession{hostSession("other")},
1045	}}
1046	waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1047		return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other"
1048	})
1049	require.Equal(t, "work", earlier.Daemons[0].Sessions[0].Name)
1050}
1051
1052func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) {
1053	start := time.Unix(100, 0)
1054	clock := newManualClock(start)
1055	probe := newTestProbe(1)
1056	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1057	reg := registration(t, "example.test", 1)
1058	require.NoError(t, registry.setHosts(hostRecords(reg)))
1059	startRegistry(t, registry)
1060
1061	call := receiveCall(t, probe)
1062	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1063
1064	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1065		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1066	})
1067	require.Equal(t, start, host.LastAttempt)
1068	require.Equal(t, start, host.LastSuccess)
1069	require.Equal(t, start.Add(defaultDemandFreshForRemote), host.NextDue)
1070	require.Zero(t, host.ConsecutiveFailures)
1071	require.Zero(t, host.FailureEpisode)
1072
1073	// A fresh host is not observed again before its scheduled deadline.
1074	clock.Advance(defaultDemandFreshForRemote - time.Nanosecond)
1075	requireNoProbeWithin(t, probe, probeGrace)
1076	clock.Advance(time.Nanosecond)
1077	next := receiveCall(t, probe)
1078	require.True(t, next.registration.Equal(reg))
1079}
1080
1081// TestRegistryAppliesSubscriptionDemandFreshness checks that apply/applyLocal
1082// schedule NextDue on RegistryConfig's demand cadence while Registry.SetDemand
1083// reports a live client subscription. It exercises both the remote and the local
1084// observation paths on the same registry, since service.go's Subscribe/Close
1085// wiring feeds one shared demand counter for both.
1086func TestRegistryAppliesSubscriptionDemandFreshness(t *testing.T) {
1087	const (
1088		demandLocal  = 250 * time.Millisecond
1089		demandRemote = 500 * time.Millisecond
1090	)
1091	tests := []struct {
1092		name      string
1093		local     bool
1094		demand    bool
1095		wantAfter time.Duration
1096	}{
1097		{name: "remote demand uses faster cadence", local: false, demand: true, wantAfter: demandRemote},
1098		{name: "local demand uses faster cadence", local: true, demand: true, wantAfter: demandLocal},
1099	}
1100
1101	for _, tt := range tests {
1102		t.Run(tt.name, func(t *testing.T) {
1103			start := time.Unix(100, 0)
1104			clock := newManualClock(start)
1105			remoteProbe := newTestProbe(1)
1106			localProbe := newScriptedLocalProbe(1)
1107			registry, err := NewRegistryWithConfig(1, newTestStore(), remoteProbe, clock, nil, RegistryConfig{
1108				Local:                &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: localProbe},
1109				DemandFreshForLocal:  demandLocal,
1110				DemandFreshForRemote: demandRemote,
1111			})
1112			require.NoError(t, err)
1113			registry.jitter = identityJitter
1114			reg := registration(t, "example.test", 1)
1115			require.NoError(t, registry.setHosts(hostRecords(reg)))
1116			startRegistry(t, registry)
1117
1118			if tt.demand {
1119				registry.SetDemand(true)
1120				t.Cleanup(func() { registry.SetDemand(false) })
1121			}
1122
1123			if tt.local {
1124				call := receiveLocalCall(t, localProbe)
1125				call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
1126					Identity:        "local-daemon",
1127					Incarnation:     ports.BrokerDaemonIncarnation{9},
1128					ProtocolVersion: protocol.Version,
1129					Availability:    domain.RemoteAvailabilityReachable,
1130				}}
1131				host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
1132					return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
1133				})
1134				require.Equal(t, start.Add(tt.wantAfter), host.NextDue)
1135				return
1136			}
1137			call := receiveCall(t, remoteProbe)
1138			call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1139			host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1140				return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1141			})
1142			require.Equal(t, start.Add(tt.wantAfter), host.NextDue)
1143		})
1144	}
1145}
1146
1147// TestRegistrySetDemandIsCountedNotBoolean checks that subscriptions balance:
1148// observation stops after the last closes and resumes immediately on reopen.
1149func TestRegistrySetDemandIsCountedNotBoolean(t *testing.T) {
1150	start := time.Unix(100, 0)
1151	clock := newManualClock(start)
1152	probe := newTestProbe(2)
1153	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1154	registry.demandFreshForRemote = 500 * time.Millisecond
1155	reg := registration(t, "example.test", 1)
1156	require.NoError(t, registry.setHosts(hostRecords(reg)))
1157	startRegistry(t, registry)
1158
1159	// Two subscriptions open; only one closes, so demand must stay active.
1160	registry.SetDemand(true)
1161	registry.SetDemand(true)
1162	registry.SetDemand(false)
1163
1164	call := receiveCall(t, probe)
1165	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1166	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1167		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1168	})
1169	require.Equal(t, start.Add(500*time.Millisecond), host.NextDue, "one remaining demand unit must still use the faster cadence")
1170
1171	// The last subscription closes: no scheduled observation runs for nobody.
1172	registry.SetDemand(false)
1173	registry.SetDemand(false) // balance the fixture's first subscription
1174	clock.Advance(time.Hour)
1175	requireNoProbeWithin(t, probe, probeGrace)
1176	registry.SetDemand(true)
1177	next := receiveCall(t, probe)
1178	next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1179	host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1180		return !host.Checking && host.LastSuccess.Equal(clock.Now())
1181	})
1182	require.Equal(t, clock.Now().Add(500*time.Millisecond), host.NextDue)
1183}
1184
1185func TestRegistryDemandOverridesFreshSchedule(t *testing.T) {
1186	start := time.Unix(100, 0)
1187	clock := newManualClock(start)
1188	probe := newTestProbe(1)
1189	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1190	reg := registration(t, "example.test", 1)
1191	require.NoError(t, registry.setHosts(hostRecords(reg)))
1192	startRegistry(t, registry)
1193
1194	call := receiveCall(t, probe)
1195	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1196	waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1197		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1198	})
1199
1200	// Explicit demand is admitted immediately, before the fresh deadline.
1201	registry.RequestProbe(reg.Endpoint)
1202	forced := receiveCall(t, probe)
1203	require.True(t, forced.registration.Equal(reg))
1204	require.Equal(t, start, clock.Now())
1205}
1206
1207func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
1208	start := time.Unix(100, 0)
1209	clock := newManualClock(start)
1210	probe := newTestProbe(2)
1211	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1212	reg := registration(t, "example.test", 1)
1213	require.NoError(t, registry.setHosts(hostRecords(reg)))
1214	startRegistry(t, registry)
1215
1216	call := receiveCall(t, probe)
1217	for range 10 {
1218		registry.RequestProbe(reg.Endpoint)
1219	}
1220	select {
1221	case duplicate := <-probe.calls:
1222		t.Fatalf("concurrent demand started a duplicate probe for %q", duplicate.registration.Endpoint)
1223	default:
1224	}
1225	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1226	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1227		return host.Availability == domain.RemoteAvailabilityReachable && host.LastSuccess.Equal(start)
1228	})
1229	require.Equal(t, start.Add(defaultDemandFreshForRemote), host.NextDue)
1230
1231	// The single coalesced follow-up is admitted once the first completes.
1232	followUp := receiveCall(t, probe)
1233	require.True(t, followUp.registration.Equal(reg))
1234	followUp.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1235	waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1236		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1237	})
1238}
1239
1240func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) {
1241	clock := newManualClock(time.Unix(100, 0))
1242	probe := newTestProbe(1)
1243	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1244	reg := registration(t, "example.test", 1)
1245	require.NoError(t, registry.setHosts(hostRecords(reg)))
1246	registry.RequestProbe("unknown.test")
1247	startRegistry(t, registry)
1248
1249	call := receiveCall(t, probe)
1250	require.True(t, call.registration.Equal(reg))
1251	// No probe is ever scheduled for an endpoint outside configured membership.
1252	requireNoProbeWithin(t, probe, probeGrace)
1253}
1254
1255func TestRegistryPublishesReachabilityAndPersists(t *testing.T) {
1256	start := time.Unix(100, 0)
1257	clock := newManualClock(start)
1258	probe := newTestProbe(1)
1259	store := &testStore{}
1260	registry := newTestRegistry(t, 3, store, probe, clock)
1261	reg := registration(t, "example.test", 1)
1262	require.NoError(t, registry.setHosts(hostRecords(reg)))
1263	startRegistry(t, registry)
1264
1265	call := receiveCall(t, probe)
1266	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1267		Availability:   domain.RemoteAvailabilityReachable,
1268		InventoryKnown: true,
1269		Sessions:       []catalogue.RemoteCatalogSession{hostSession("work")},
1270	}}
1271	snapshot := waitSnapshot(t, registry, func(snapshot ports.BrokerSnapshot) bool {
1272		host, ok := snapshot.Find(reg.Endpoint)
1273		return ok && !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && len(host.Sessions) == 1
1274	})
1275
1276	stored := waitStored(t, store, func(stored ports.BrokerSnapshot) bool {
1277		return stored.Revision >= snapshot.Revision && len(stored.Daemons) == 1
1278	})
1279	require.Equal(t, ports.BrokerEpoch(3), stored.Epoch)
1280	require.Len(t, stored.Daemons, 1)
1281	require.Equal(t, "work", stored.Daemons[0].Sessions[0].Name)
1282	require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint)
1283	require.False(t, stored.Daemons[0].Checking)
1284}
1285
1286func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
1287	dialErr := errors.New("dial failed")
1288	tests := []struct {
1289		name             string
1290		answer           probeAnswer
1291		wantAvailability domain.RemoteAvailability
1292		wantKind         domain.RemoteFailureKind
1293		wantErr          error
1294	}{
1295		{
1296			name:             "unreachable without error",
1297			answer:           probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}},
1298			wantAvailability: domain.RemoteAvailabilityUnreachable,
1299			wantKind:         domain.RemoteFailureTransport,
1300		},
1301		{
1302			name:             "unknown outcome is not reachability",
1303			answer:           probeAnswer{snapshot: ports.BrokerDaemonObservation{}},
1304			wantAvailability: domain.RemoteAvailabilityUnreachable,
1305			wantKind:         domain.RemoteFailureTransport,
1306		},
1307		{
1308			name:             "incompatible",
1309			answer:           probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityIncompatible}},
1310			wantAvailability: domain.RemoteAvailabilityIncompatible,
1311			wantKind:         domain.RemoteFailureIncompatible,
1312		},
1313		{
1314			name:             "authentication",
1315			answer:           probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityAuthFailed}},
1316			wantAvailability: domain.RemoteAvailabilityAuthFailed,
1317			wantKind:         domain.RemoteFailureAuthentication,
1318		},
1319		{
1320			name:             "invalid response",
1321			answer:           probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityInvalidResponse}},
1322			wantAvailability: domain.RemoteAvailabilityInvalidResponse,
1323			wantKind:         domain.RemoteFailureInvalidResponse,
1324		},
1325		{
1326			name:             "no daemon after a prior success",
1327			answer:           probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}},
1328			wantAvailability: domain.RemoteAvailabilityNoDaemon,
1329			wantKind:         domain.RemoteFailureNone,
1330		},
1331		{
1332			name:             "transport error",
1333			answer:           probeAnswer{err: dialErr},
1334			wantAvailability: domain.RemoteAvailabilityUnreachable,
1335			wantKind:         domain.RemoteFailureTransport,
1336			wantErr:          dialErr,
1337		},
1338		{
1339			name:             "deadline exceeded",
1340			answer:           probeAnswer{err: context.DeadlineExceeded},
1341			wantAvailability: domain.RemoteAvailabilityUnreachable,
1342			wantKind:         domain.RemoteFailureTimeout,
1343			wantErr:          context.DeadlineExceeded,
1344		},
1345	}
1346	for _, tt := range tests {
1347		t.Run(tt.name, func(t *testing.T) {
1348			start := time.Unix(100, 0)
1349			clock := newManualClock(start)
1350			probe := newTestProbe(1)
1351			registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1352			reg := registration(t, "example.test", 1)
1353			require.NoError(t, registry.setHosts(hostRecords(reg)))
1354			startRegistry(t, registry)
1355			if tt.wantAvailability == domain.RemoteAvailabilityNoDaemon {
1356				reachable := probeAnswer{snapshot: ports.BrokerDaemonObservation{
1357					Availability: domain.RemoteAvailabilityReachable, Identity: ports.BrokerDaemonIdentity("remote-daemon"),
1358					Incarnation: ports.BrokerDaemonIncarnation{1}, ProtocolVersion: protocol.Version,
1359					InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{hostSession("old")},
1360				}}
1361				call := receiveCall(t, probe)
1362				call.result <- reachable
1363				waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1364					return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && host.Identity == ports.BrokerDaemonIdentity("remote-daemon")
1365				})
1366				clock.Advance(5 * time.Second)
1367				registry.RequestProbe(reg.Endpoint)
1368				call = receiveCall(t, probe)
1369				call.result <- reachable
1370				waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1371					return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1372				})
1373				clock.Advance(20 * time.Second)
1374				registry.RequestProbe(reg.Endpoint)
1375			}
1376
1377			call := receiveCall(t, probe)
1378			call.result <- tt.answer
1379			host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1380				return !host.Checking && (host.ConsecutiveFailures == 1 || tt.wantAvailability == domain.RemoteAvailabilityNoDaemon && host.Availability == domain.RemoteAvailabilityNoDaemon)
1381			})
1382			require.Equal(t, tt.wantAvailability, host.Availability)
1383			require.Equal(t, tt.wantKind, host.LastFailure.Kind)
1384			if tt.wantAvailability == domain.RemoteAvailabilityNoDaemon {
1385				require.Zero(t, host.ConsecutiveFailures)
1386				require.Zero(t, host.FailureEpisode)
1387				require.Equal(t, start.Add(25*time.Second), host.LastSuccess)
1388				require.Equal(t, start.Add(40*time.Second), host.NextDue)
1389				require.Empty(t, host.Identity)
1390				require.True(t, host.Incarnation.IsZero())
1391				require.Zero(t, host.ProtocolVersion)
1392				require.Zero(t, host.Capabilities)
1393				require.False(t, host.InventoryKnown)
1394				require.Empty(t, host.Sessions)
1395				require.Equal(t, domain.RemoteFailureNone, host.LastFailure.Kind)
1396			} else {
1397				require.Equal(t, uint(1), host.ConsecutiveFailures)
1398				require.Equal(t, uint64(1), host.FailureEpisode)
1399				// No observation succeeded yet, so there is no last success.
1400				require.True(t, host.LastSuccess.IsZero())
1401				// A watched host retries on the demand cadence.
1402				require.Equal(t, start.Add(defaultDemandFreshForRemote), host.NextDue)
1403			}
1404			if tt.wantErr != nil {
1405				require.ErrorIs(t, host.LastFailure, tt.wantErr)
1406			} else {
1407				require.Nil(t, host.LastFailure.Err)
1408			}
1409		})
1410	}
1411}
1412
1413func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing.T) {
1414	start := time.Unix(100, 0)
1415	clock := newManualClock(start)
1416	probe := newTestProbe(1)
1417	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1418	reg := registration(t, "example.test", 1)
1419	require.NoError(t, registry.setHosts(hostRecords(reg)))
1420	startRegistry(t, registry)
1421	sub := registry.Subscribe()
1422	defer sub.Close()
1423
1424	// A reachable observation establishes the last-known inventory.
1425	call := receiveCall(t, probe)
1426	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1427		Availability:   domain.RemoteAvailabilityReachable,
1428		InventoryKnown: true,
1429		Sessions:       []catalogue.RemoteCatalogSession{hostSession("work")},
1430	}}
1431	waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1432		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1433	})
1434
1435	// A projection carrying one session more than the durable session bound is
1436	// a malformed response: it is classified as an invalid response and never
1437	// reaches publication, because the store enforces the same catalogue rule.
1438	// The loop checks every observed publication, so an invalid snapshot can
1439	// never pass unnoticed.
1440	clock.Advance(defaultFreshFor)
1441	call = receiveCall(t, probe)
1442	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1443		Availability:   domain.RemoteAvailabilityReachable,
1444		InventoryKnown: true,
1445		Sessions:       hostSessions(ports.BrokerMaxSessionsPerHost + 1),
1446	}}
1447	deadline := time.After(time.Second)
1448	var host ports.BrokerDaemonObservation
1449	for {
1450		snapshot := registry.Snapshot()
1451		require.NoError(t, snapshot.Validate())
1452		current, ok := snapshot.Find(reg.Endpoint)
1453		if ok && !current.Checking && current.ConsecutiveFailures == 1 {
1454			host = current
1455			break
1456		}
1457		select {
1458		case <-sub.Changed():
1459		case <-deadline:
1460			t.Fatal("timed out waiting for the invalid-response failure")
1461		}
1462	}
1463	require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)
1464	require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind)
1465	// The last-known inventory is retained: a malformed projection never
1466	// replaces it.
1467	require.Len(t, host.Sessions, 1)
1468	require.Equal(t, "work", host.Sessions[0].Name)
1469	require.Equal(t, clock.Now().Add(defaultDemandFreshForRemote), host.NextDue)
1470}
1471
1472func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) {
1473	start := time.Unix(100, 0)
1474	clock := newManualClock(start)
1475	probe := newTestProbe(1)
1476	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1477	reg := registration(t, "example.test", 1)
1478	require.NoError(t, registry.setHosts(hostRecords(reg)))
1479	startRegistry(t, registry)
1480
1481	// The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are
1482	// accepted and published, and the resulting snapshot validates.
1483	call := receiveCall(t, probe)
1484	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1485		Availability:   domain.RemoteAvailabilityReachable,
1486		InventoryKnown: true,
1487		Sessions:       hostSessions(ports.BrokerMaxSessionsPerHost),
1488	}}
1489	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1490		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1491	})
1492	require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)
1493	require.Zero(t, host.ConsecutiveFailures)
1494	snapshot := registry.Snapshot()
1495	require.NoError(t, snapshot.Validate())
1496	require.Len(t, snapshot.Daemons[0].Sessions, ports.BrokerMaxSessionsPerHost)
1497}
1498
1499// TestRegistryRejectsNonDurableObservation proves the registry and the durable
1500// store agree on what may be persisted: a reachable projection the store would
1501// reject is classified as an invalid response, so the last-known inventory
1502// stays authoritative instead of a snapshot that can never be written.
1503func TestRegistryRejectsNonDurableObservation(t *testing.T) {
1504	start := time.Unix(100, 0)
1505	clock := newManualClock(start)
1506	probe := newTestProbe(1)
1507	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1508	reg := registration(t, "example.test", 1)
1509	require.NoError(t, registry.setHosts(hostRecords(reg)))
1510	startRegistry(t, registry)
1511
1512	call := receiveCall(t, probe)
1513	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1514		Availability:   domain.RemoteAvailabilityReachable,
1515		InventoryKnown: true,
1516		Sessions:       []catalogue.RemoteCatalogSession{hostSession("work")},
1517	}}
1518	waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1519		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1520	})
1521
1522	// A probe answer that violates the catalogue rules the store enforces (here
1523	// a zero lifecycle identity and an absent tab list) is a malformed response.
1524	clock.Advance(defaultFreshFor)
1525	call = receiveCall(t, probe)
1526	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1527		Availability:   domain.RemoteAvailabilityReachable,
1528		InventoryKnown: true,
1529		Sessions:       []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}},
1530	}}
1531	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1532		return !host.Checking && host.ConsecutiveFailures == 1
1533	})
1534	require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)
1535	require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind)
1536	require.Len(t, host.Sessions, 1)
1537	require.Equal(t, "work", host.Sessions[0].Name)
1538	require.Equal(t, start, host.LastSuccess)
1539	snapshot := registry.Snapshot()
1540	require.NoError(t, snapshot.Validate())
1541	require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Daemons[0]))
1542}
1543
1544// TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not
1545// a valid revision and revisions never regress, so an exhausted counter fails
1546// closed instead of wrapping. The last valid snapshot stays published, the
1547// registry is poisoned, and every mutation path reports a typed error rather
1548// than emitting revision zero or a revision below the newest one, either of
1549// which would silently freeze durable persistence.
1550func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
1551	logger, logs := testLogger()
1552	registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger)
1553	require.NoError(t, err)
1554	first := registration(t, "example.test", 1)
1555	require.NoError(t, registry.setHosts(hostRecords(first)))
1556	before := registry.Snapshot()
1557	require.NoError(t, before.Validate())
1558	require.Equal(t, ports.BrokerRevision(2), before.Revision)
1559	authority := registry.authority
1560
1561	// The next publication would overflow the series to zero.
1562	registry.revision = ^ports.BrokerRevision(0)
1563
1564	// Both mutation paths refuse the change before touching the store or the
1565	// projection, so neither durable authority nor the publication series moves.
1566	require.ErrorIs(t, registry.setHosts(hostRecords(registration(t, "other.test", 2))), ports.ErrBrokerRevisionExhausted)
1567	require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy(), Route: canonicalRoute(poolPolicy(), first.Endpoint)}}), ports.ErrBrokerRevisionExhausted)
1568	require.Equal(t, authority, registry.authority)
1569
1570	// The publication path itself fails closed too: a direct attempt neither
1571	// wraps the counter nor replaces the last valid snapshot.
1572	registry.mu.Lock()
1573	registry.publishLocked(false)
1574	registry.mu.Unlock()
1575
1576	after := registry.Snapshot()
1577	require.Equal(t, before, after)
1578	require.NoError(t, after.Validate())
1579	require.Greater(t, after.Revision, ports.BrokerRevision(0))
1580	require.Equal(t, ^ports.BrokerRevision(0), registry.revision)
1581	require.Equal(t, first, after.Daemons[0].Registration)
1582	// The refusal is observable: an exhausted series logs instead of failing
1583	// silently behind a frozen snapshot.
1584	require.Contains(t, logs.String(), "revision series exhausted")
1585}
1586
1587func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
1588	start := time.Unix(100, 0)
1589	clock := newManualClock(start)
1590	probe := newTestProbe(1)
1591	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1592	reg := registration(t, "example.test", 1)
1593	require.NoError(t, registry.setHosts(hostRecords(reg)))
1594	startRegistry(t, registry)
1595
1596	// A successful observation owns the success time and opens no episode.
1597	call := receiveCall(t, probe)
1598	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1599		Availability:   domain.RemoteAvailabilityReachable,
1600		InventoryKnown: true,
1601		Sessions:       []catalogue.RemoteCatalogSession{hostSession("work")},
1602	}}
1603	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1604		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1605	})
1606	require.Equal(t, start, host.LastSuccess)
1607	require.Zero(t, host.FailureEpisode)
1608
1609	// A failure preserves the last success and opens exactly one episode.
1610	clock.Advance(defaultFreshFor)
1611	call = receiveCall(t, probe)
1612	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1613		Availability:   domain.RemoteAvailabilityIncompatible,
1614		InventoryKnown: true,
1615		Sessions:       []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}},
1616	}}
1617	host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1618		return !host.Checking && host.ConsecutiveFailures == 1
1619	})
1620	require.Equal(t, start, host.LastSuccess)
1621	require.Equal(t, uint64(1), host.FailureEpisode)
1622	// The last-known inventory is retained: a failed observation never
1623	// replaces it.
1624	require.Equal(t, "work", host.Sessions[0].Name)
1625
1626	// Further failures in the same streak preserve the episode identity so
1627	// notice policy does not re-notify.
1628	clock.Advance(defaultRetryBase)
1629	call = receiveCall(t, probe)
1630	call.result <- probeAnswer{err: errors.New("offline")}
1631	host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1632		return !host.Checking && host.ConsecutiveFailures == 2
1633	})
1634	require.Equal(t, start, host.LastSuccess)
1635	require.Equal(t, uint64(1), host.FailureEpisode)
1636
1637	// Recovery clears the streak, keeps the episode, and advances the success.
1638	clock.Advance(2 * defaultRetryBase)
1639	recovered := clock.Now()
1640	call = receiveCall(t, probe)
1641	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1642	host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1643		return !host.Checking && host.ConsecutiveFailures == 0
1644	})
1645	require.Equal(t, recovered, host.LastSuccess)
1646	require.Equal(t, uint64(1), host.FailureEpisode)
1647	require.Equal(t, domain.RemoteFailureNone, host.LastFailure.Kind)
1648	require.Nil(t, host.LastFailure.Err)
1649}
1650
1651func TestRegistryDemandCapsFailureBackoff(t *testing.T) {
1652	start := time.Unix(100, 0)
1653	clock := newManualClock(start)
1654	probe := newTestProbe(1)
1655	reg := registration(t, "demand.test", 1)
1656	store := newTestStore()
1657	require.NoError(t, store.ReplaceHosts(1, hostRecords(reg)))
1658	registry := newTestRegistry(t, 1, store, probe, clock)
1659	startRegistry(t, registry)
1660	registry.SetDemand(true)
1661	t.Cleanup(func() { registry.SetDemand(false) })
1662
1663	for failure := uint(1); failure <= 9; failure++ {
1664		call := receiveCall(t, probe)
1665		call.result <- probeAnswer{err: errors.New("daemon not available")}
1666		host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1667			return !host.Checking && host.ConsecutiveFailures == failure
1668		})
1669		require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), demandRetryMax, "demanded retry gap must never exceed the demand retry cap")
1670		clock.Advance(host.NextDue.Sub(clock.Now()))
1671		registry.RequestProbe(reg.Endpoint)
1672	}
1673	call := receiveCall(t, probe)
1674	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1675	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1676		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1677	})
1678	require.Zero(t, host.ConsecutiveFailures)
1679}
1680
1681func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
1682	start := time.Unix(100, 0)
1683	clock := newManualClock(start)
1684	probe := newTestProbe(1)
1685	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1686	reg := registration(t, "example.test", 1)
1687	require.NoError(t, registry.setHosts(hostRecords(reg)))
1688	startRegistry(t, registry)
1689
1690	probeErr := errors.New("offline")
1691	// A watched host backs off from its two-second demand cadence, doubling
1692	// per failure up to the demand retry cap.
1693	want := []time.Duration{
1694		2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second,
1695		demandRetryMax, demandRetryMax, demandRetryMax,
1696	}
1697	for index, delay := range want {
1698		call := receiveCall(t, probe)
1699		at := clock.Now()
1700		call.result <- probeAnswer{err: probeErr}
1701		host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1702			return !host.Checking && host.ConsecutiveFailures == uint(index+1)
1703		})
1704		require.Equal(t, domain.RemoteAvailabilityUnreachable, host.Availability)
1705		require.Equal(t, domain.RemoteFailureTransport, host.LastFailure.Kind)
1706		require.ErrorIs(t, host.LastFailure, probeErr)
1707		require.Equal(t, at.Add(delay), host.NextDue)
1708
1709		if index == len(want)-1 {
1710			break
1711		}
1712		// The retry is not admitted early: only the backoff deadline wakes it.
1713		clock.Advance(delay - time.Nanosecond)
1714		requireNoProbeWithin(t, probe, probeGrace)
1715		clock.Advance(time.Nanosecond)
1716	}
1717}
1718
1719func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) {
1720	start := time.Unix(100, 0)
1721	clock := newManualClock(start)
1722	probe := newTestProbe(1)
1723	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1724	registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 }
1725	reg := registration(t, "example.test", 1)
1726	require.NoError(t, registry.setHosts(hostRecords(reg)))
1727	startRegistry(t, registry)
1728
1729	call := receiveCall(t, probe)
1730	call.result <- probeAnswer{err: errors.New("offline")}
1731	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1732		return !host.Checking && host.Availability == domain.RemoteAvailabilityUnreachable
1733	})
1734	require.Equal(t, start.Add(defaultDemandFreshForRemote/2), host.NextDue)
1735
1736	clock.Advance(defaultDemandFreshForRemote / 2)
1737	next := receiveCall(t, probe)
1738	successAt := clock.Now()
1739	next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1740	host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1741		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1742	})
1743	// The healthy refresh is jittered too: a fleet of hosts never converges
1744	// on one instant.
1745	require.Equal(t, successAt.Add(defaultDemandFreshForRemote/2), host.NextDue)
1746}
1747
1748func TestRegistryJitterIsDeterministicAndBounded(t *testing.T) {
1749	bases := []time.Duration{defaultRetryBase, defaultFreshFor, defaultRetryLimit}
1750	endpoints := []string{"example.test", "other.test", "user@host:2222"}
1751	for _, base := range bases {
1752		minimum, maximum := base*9/10, base*11/10
1753		for _, endpoint := range endpoints {
1754			spread := make(map[time.Duration]struct{})
1755			for attempt := uint64(1); attempt <= 64; attempt++ {
1756				got := jittered(base, endpoint, attempt)
1757				require.GreaterOrEqual(t, got, minimum)
1758				require.LessOrEqual(t, got, maximum)
1759				// Identical endpoint and attempt always yield the identical
1760				// duration: no wall clock and no shared PRNG participate.
1761				require.Equal(t, got, jittered(base, endpoint, attempt))
1762				spread[got] = struct{}{}
1763			}
1764			require.Greater(t, len(spread), 1, "jitter must vary across attempts")
1765		}
1766	}
1767	// A non-positive base has no spread to apply.
1768	require.Zero(t, jittered(0, "example.test", 1))
1769}
1770
1771func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
1772	replacementAnswer := func(name string) probeAnswer {
1773		return probeAnswer{snapshot: ports.BrokerDaemonObservation{
1774			Availability:   domain.RemoteAvailabilityReachable,
1775			InventoryKnown: true,
1776			Sessions:       []catalogue.RemoteCatalogSession{hostSession(name)},
1777		}}
1778	}
1779	assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) {
1780		t.Helper()
1781		clock := newManualClock(time.Unix(100, 0))
1782		probe := newTestProbe(2)
1783		registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1784		require.NoError(t, registry.setHosts(hostRecords(current)))
1785		startRegistry(t, registry)
1786		currentCall := receiveCall(t, probe)
1787		require.True(t, currentCall.registration.Equal(current))
1788
1789		require.NoError(t, registry.setHosts(hostRecords(replacement)))
1790		replacementCall := receiveCall(t, probe)
1791		require.True(t, replacementCall.registration.Equal(replacement))
1792
1793		// The replacement completes and becomes authoritative...
1794		replacementCall.result <- replacementAnswer("replacement")
1795		applied := waitHost(t, registry, replacement.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1796			return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && len(host.Sessions) == 1
1797		})
1798		require.True(t, applied.Registration.Equal(replacement))
1799		require.Equal(t, "replacement", applied.Sessions[0].Name)
1800
1801		// ...and the completion bound to the retired registration, arriving
1802		// afterwards, never touches it.
1803		currentCall.result <- replacementAnswer("retired")
1804		requireHostStableWithin(t, registry, replacement.Endpoint, applied, probeGrace)
1805
1806		// The retired completion also never holds the replacement's slot: a
1807		// further observation is still admitted and still applies.
1808		registry.RequestProbe(replacement.Endpoint)
1809		next := receiveCall(t, probe)
1810		require.True(t, next.registration.Equal(replacement))
1811		next.result <- replacementAnswer("next")
1812		waitHost(t, registry, replacement.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1813			return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "next"
1814		})
1815	}
1816
1817	t.Run("incarnation", func(t *testing.T) {
1818		assertFenced(t, registration(t, "example.test", 1), registration(t, "example.test", 2))
1819	})
1820	t.Run("generation", func(t *testing.T) {
1821		current := registration(t, "example.test", 1)
1822		replacement := current
1823		replacement.Generation++
1824		assertFenced(t, current, replacement)
1825	})
1826}
1827
1828func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.T) {
1829	clock := newManualClock(time.Unix(100, 0))
1830	probe := newTestProbe(2)
1831	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1832	reg := registration(t, "example.test", 1)
1833	require.NoError(t, registry.setHosts(hostRecords(reg)))
1834
1835	ctx, cancel := context.WithCancel(context.Background())
1836	defer cancel()
1837	results := make(chan probeResult, 1)
1838	dispatch := func() { registry.dispatch(ctx, clock.Now(), results) }
1839
1840	registry.RequestProbe(reg.Endpoint)
1841	dispatch()
1842	retiredCall := receiveCall(t, probe)
1843	require.True(t, retiredCall.registration.Equal(reg))
1844	retired := attemptFor(registry, reg.Endpoint)
1845	require.NotZero(t, retired)
1846
1847	// Removal retires a bounded tombstone and drops the in-flight token.
1848	require.NoError(t, registry.setHosts(nil))
1849	removed := registry.Snapshot()
1850	require.Empty(t, removed.Daemons)
1851	require.Len(t, removed.Removed, 1)
1852	require.Equal(t, removed.Revision, removed.Removed[0].RetiredRevision)
1853	require.True(t, removed.Removed[0].Fences(reg))
1854	require.NoError(t, removed.Validate())
1855
1856	// The retired attempt can never revive a removed endpoint.
1857	registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1858	require.Empty(t, registry.Snapshot().Daemons)
1859	require.Len(t, registry.Snapshot().Removed, 1)
1860
1861	// Re-adding the identical registration supersedes the tombstone, so only
1862	// the attempt token can fence the retired completion.
1863	require.NoError(t, registry.setHosts(hostRecords(reg)))
1864	require.Empty(t, registry.Snapshot().Removed)
1865	registry.RequestProbe(reg.Endpoint)
1866	dispatch()
1867	freshCall := receiveCall(t, probe)
1868	require.True(t, freshCall.registration.Equal(reg))
1869	fresh := attemptFor(registry, reg.Endpoint)
1870	require.Greater(t, fresh, retired)
1871
1872	registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1873	stale, ok := registry.Snapshot().Find(reg.Endpoint)
1874	require.True(t, ok)
1875	require.True(t, stale.Checking)
1876	require.Equal(t, domain.RemoteAvailabilityUnknown, stale.Availability)
1877
1878	registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: fresh, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1879	applied, ok := registry.Snapshot().Find(reg.Endpoint)
1880	require.True(t, ok)
1881	require.False(t, applied.Checking)
1882	require.Equal(t, domain.RemoteAvailabilityReachable, applied.Availability)
1883}
1884
1885func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) {
1886	current := registration(t, "example.test", 1)
1887	replacement := registration(t, "example.test", 2)
1888	tests := []struct {
1889		name string
1890		next []ports.BrokerHostRecord
1891	}{
1892		{name: "replacement", next: []ports.BrokerHostRecord{hostRecord(replacement)}},
1893		{name: "removal", next: nil},
1894	}
1895	for _, tt := range tests {
1896		t.Run(tt.name, func(t *testing.T) {
1897			clock := newManualClock(time.Unix(100, 0))
1898			probe := newTestProbe(2)
1899			registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1900			require.NoError(t, registry.setHosts(hostRecords(current)))
1901			startRegistry(t, registry)
1902
1903			call := receiveCall(t, probe)
1904			require.True(t, call.registration.Equal(current))
1905
1906			// Replacing or removing the registration retires the in-flight
1907			// attempt: the probe's derived context is cancelled before the
1908			// replacement can start, so a ctx-honoring probe stops instead of
1909			// overlapping its successor.
1910			require.NoError(t, registry.setHosts(tt.next))
1911			select {
1912			case <-probe.canceled:
1913			case <-time.After(time.Second):
1914				t.Fatal("retired probe did not observe cancellation")
1915			}
1916		})
1917	}
1918}
1919
1920func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
1921	clock := newManualClock(time.Unix(100, 0))
1922	probe := newChurnProbe()
1923	defer probe.openRelease()
1924	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1925
1926	first := registration(t, "example.test", 1)
1927	second := registration(t, "example.test", 2)
1928	require.NoError(t, registry.setHosts(hostRecords(first)))
1929
1930	ctx, cancel := context.WithCancel(context.Background())
1931	defer cancel()
1932	done := make(chan struct{})
1933	go func() { defer close(done); registry.Run(ctx) }()
1934	registry.RequestProbe(first.Endpoint)
1935
1936	// Drop the initial attempt's start signal so each lockstep round below
1937	// consumes the start of its own replacement attempt.
1938	select {
1939	case <-probe.startedSig:
1940	case <-time.After(time.Second):
1941		t.Fatal("timed out waiting for the initial probe")
1942	}
1943	require.Equal(t, 1, probe.startedCount())
1944
1945	// Lockstep churn: every round replaces the registration, which must cancel
1946	// the retired attempt before the replacement starts. An uncancelled
1947	// retirement would stay live and overlap its successor, and the exact start
1948	// count catches an attempt that never restarts.
1949	const rounds = 8
1950	for round := 0; round < rounds; round++ {
1951		next := first
1952		if round%2 == 0 {
1953			next = second
1954		}
1955		require.NoError(t, registry.setHosts(hostRecords(next)))
1956		registry.RequestProbe(next.Endpoint)
1957		select {
1958		case <-probe.startedSig:
1959		case <-time.After(time.Second):
1960			t.Fatal("timed out waiting for the replacement attempt to start")
1961		}
1962		require.Equal(t, round+2, probe.startedCount())
1963	}
1964
1965	// Concurrent churn and demand: the same invariant must hold under the race
1966	// detector while membership and demand churn from several goroutines.
1967	failures := make([]error, 4)
1968	var wg sync.WaitGroup
1969	for worker := 0; worker < len(failures); worker++ {
1970		wg.Add(1)
1971		go func(worker int) {
1972			defer wg.Done()
1973			for round := 0; round < 32; round++ {
1974				next := first
1975				if (worker+round)%2 == 0 {
1976					next = second
1977				}
1978				if err := registry.setHosts(hostRecords(next)); err != nil {
1979					failures[worker] = err
1980					return
1981				}
1982				registry.RequestProbe(first.Endpoint)
1983				registry.Snapshot()
1984			}
1985		}(worker)
1986	}
1987	wg.Wait()
1988	for _, err := range failures {
1989		require.NoError(t, err)
1990	}
1991
1992	endpoints := probe.observedEndpoints()
1993	require.NotEmpty(t, endpoints)
1994	for _, endpoint := range endpoints {
1995		require.Equal(t, 1, probe.maxLiveFor(endpoint), "endpoint %q had overlapping live probes", endpoint)
1996	}
1997	require.GreaterOrEqual(t, probe.startedCount(), rounds+1)
1998
1999	// Settling releases every attempt: no probe stays live once Run returns.
2000	cancel()
2001	select {
2002	case <-done:
2003	case <-time.After(time.Second):
2004		t.Fatal("registry did not stop after cancellation")
2005	}
2006	deadline := time.Now().Add(time.Second)
2007	for probe.inflight() != 0 {
2008		if time.Now().After(deadline) {
2009			t.Fatalf("%d probes still live after settle", probe.inflight())
2010		}
2011		time.Sleep(time.Millisecond)
2012	}
2013}
2014
2015func TestRegistryPublishesBoundedTombstones(t *testing.T) {
2016	registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
2017
2018	// Two full membership rounds with distinct endpoints: without pruning the
2019	// retired set would double.
2020	for round := 0; round < 2; round++ {
2021		registrations := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts)
2022		for index := 0; index < ports.BrokerMaxHosts; index++ {
2023			registrations = append(registrations, hostRecord(registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1))))
2024		}
2025		require.NoError(t, registry.setHosts(registrations))
2026		require.NoError(t, registry.setHosts(nil))
2027	}
2028
2029	snapshot := registry.Snapshot()
2030	require.NoError(t, snapshot.Validate())
2031	require.Len(t, snapshot.Removed, ports.BrokerMaxTombstones)
2032	for index, tombstone := range snapshot.Removed {
2033		require.NoError(t, tombstone.Validate())
2034		// Only the newest retirements survive the bound.
2035		require.Equal(t, snapshot.Revision, tombstone.RetiredRevision)
2036		if index > 0 {
2037			require.GreaterOrEqual(t, snapshot.Removed[index-1].RetiredRevision, tombstone.RetiredRevision)
2038		}
2039	}
2040}
2041
2042func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) {
2043	clock := newManualClock(time.Unix(100, 0))
2044	probe := newTestProbe(1)
2045	store := newBlockingStore()
2046	registry, err := NewRegistry(1, store, probe, clock, nil)
2047	require.NoError(t, err)
2048	defer registry.store.close()
2049
2050	reg := registration(t, "example.test", 1)
2051	require.NoError(t, registry.setHosts(hostRecords(reg)))
2052	// The writer is now blocked inside the first durable write.
2053	first := <-store.entered
2054	require.Equal(t, ports.BrokerRevision(2), first)
2055
2056	// Publication is never blocked behind persistence and never holds the
2057	// registry lock across a store call.
2058	var setErr error
2059	published := make(chan struct{})
2060	go func() {
2061		defer close(published)
2062		for generation := domain.RemoteGeneration(2); generation <= 6; generation++ {
2063			reg.Generation = generation
2064			if err := registry.setHosts(hostRecords(reg)); err != nil {
2065				setErr = err
2066				return
2067			}
2068		}
2069	}()
2070	select {
2071	case <-published:
2072	case <-time.After(time.Second):
2073		t.Fatal("publication blocked behind durable persistence")
2074	}
2075	require.NoError(t, setErr)
2076
2077	last := registry.Snapshot().Revision
2078	store.releaseFirst()
2079	waitWriterRevision(t, registry, last)
2080
2081	// Writes stay ordered and coalesced: only the first and the newest
2082	// revision ever reach the store.
2083	require.Equal(t, []ports.BrokerRevision{2, last}, store.writtenRevisions())
2084}
2085
2086// TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree proves membership
2087// durability and publication stay independent: a ReplaceHosts parked inside the
2088// durable CAS may hold the registry lock, but Snapshot is served from the atomic
2089// publication, no observation may be applied behind it, and the run loop
2090// resumes scheduling as soon as the CAS commits.
2091func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
2092	clock := newManualClock(time.Unix(100, 0))
2093	probe := newTestProbe(2)
2094	store := newBlockingCASStore()
2095	registry, err := NewRegistry(1, store, probe, clock, nil)
2096	require.NoError(t, err)
2097
2098	reg := registration(t, "example.test", 1)
2099	require.NoError(t, registry.setHosts(hostRecords(reg)))
2100	startRegistry(t, registry)
2101	call := receiveCall(t, probe)
2102	require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2103		return host.Checking
2104	}).Checking)
2105
2106	// The replacement takes the registry lock and parks inside the durable CAS.
2107	replaced := make(chan error, 1)
2108	go func() {
2109		replaced <- registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy(), Route: canonicalRoute(poolPolicy(), reg.Endpoint)}})
2110	}()
2111	<-store.entered
2112	t.Cleanup(store.releaseCAS)
2113
2114	// Snapshot stays lock-free: the atomic publication is readable while the
2115	// mutator is parked inside the store, and it still carries the pre-CAS
2116	// projection.
2117	observed := make(chan ports.BrokerSnapshot, 1)
2118	go func() { observed <- registry.Snapshot() }()
2119	select {
2120	case snapshot := <-observed:
2121		require.NoError(t, snapshot.Validate())
2122		require.Len(t, snapshot.Daemons, 1)
2123		require.Equal(t, reg, snapshot.Daemons[0].Registration)
2124	case <-time.After(time.Second):
2125		t.Fatal("Snapshot blocked behind the durable membership CAS")
2126	}
2127
2128	// Wake the run loop and complete the observation while the CAS is still
2129	// parked: applying the result needs the registry lock, so the in-flight
2130	// projection must stay exactly as published until membership commits.
2131	registry.hint()
2132	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
2133	pending, ok := registry.Snapshot().Find(reg.Endpoint)
2134	require.True(t, ok)
2135	require.True(t, pending.Checking)
2136	stable := time.NewTimer(probeGrace)
2137	defer stable.Stop()
2138
2139window:
2140	for {
2141		current, found := registry.Snapshot().Find(reg.Endpoint)
2142		require.True(t, found)
2143		require.Equal(t, pending, current)
2144		select {
2145		case <-stable.C:
2146			break window
2147		case <-time.After(time.Millisecond):
2148		}
2149	}
2150
2151	// Committing membership releases the mutator and unparks the run loop, which
2152	// applies the observation and resumes scheduling the next one.
2153	store.releaseCAS()
2154	require.NoError(t, <-replaced)
2155	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2156		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
2157	})
2158	require.Equal(t, clock.Now(), host.LastSuccess)
2159	clock.Advance(defaultFreshFor)
2160	receiveCall(t, probe)
2161}
2162
2163func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) {
2164	clock := newManualClock(time.Unix(100, 0))
2165	probe := newTestProbe(1)
2166	logger, logs := testLogger()
2167	storeErr := errors.New("disk full")
2168	store := &testStore{storeErr: storeErr}
2169	registry, err := NewRegistry(1, store, probe, clock, logger)
2170	require.NoError(t, err)
2171	defer registry.store.close()
2172
2173	reg := registration(t, "example.test", 1)
2174	require.NoError(t, registry.setHosts(hostRecords(reg)))
2175	waitForLog(t, logs, "durable snapshot write failed")
2176	require.Contains(t, logs.String(), storeErr.Error())
2177
2178	// A failed write is not fatal and never blocks the next publication.
2179	store.setStoreErr(nil)
2180	reg.Generation++
2181	require.NoError(t, registry.setHosts(hostRecords(reg)))
2182	stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2183		return snapshot.Revision == registry.Snapshot().Revision
2184	})
2185	require.Len(t, stored.Daemons, 1)
2186	require.True(t, stored.Daemons[0].Registration.Equal(reg))
2187}
2188
2189func TestRegistryPersistedCopyClearsChecking(t *testing.T) {
2190	clock := newManualClock(time.Unix(100, 0))
2191	probe := newTestProbe(1)
2192	store := &testStore{}
2193	registry := newTestRegistry(t, 3, store, probe, clock)
2194	reg := registration(t, "example.test", 1)
2195	require.NoError(t, registry.setHosts(hostRecords(reg)))
2196	startRegistry(t, registry)
2197
2198	// The observation is in flight: the published projection carries Checking,
2199	// the durable copy never does.
2200	require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2201		return host.Checking
2202	}).Checking)
2203	stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2204		return len(snapshot.Daemons) == 1
2205	})
2206	require.False(t, stored.Daemons[0].Checking)
2207
2208	// Completing the observation persists the settled projection.
2209	call := receiveCall(t, probe)
2210	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
2211	settled := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2212		return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable
2213	})
2214	require.False(t, settled.Daemons[0].Checking)
2215}
2216
2217// TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones
2218// are process-local fencing state: the durable copy carries no Removed, and a
2219// fresh registry reopened over that snapshot neither resurrects the retired
2220// host nor sees a stale tombstone.
2221func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) {
2222	clock := newManualClock(time.Unix(100, 0))
2223	store := &testStore{}
2224	registry := newTestRegistry(t, 1, store, newTestProbe(1), clock)
2225	defer registry.store.close()
2226
2227	reg := registration(t, "retired.test", 1)
2228	require.NoError(t, registry.setHosts(hostRecords(reg)))
2229	require.NoError(t, registry.setHosts(nil))
2230
2231	// Removal publishes a bounded tombstone and drops the live host in memory.
2232	require.Empty(t, registry.Snapshot().Daemons)
2233	require.Len(t, registry.Snapshot().Removed, 1)
2234
2235	// The durable copy drops the tombstone before the store ever sees it.
2236	stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2237		return len(snapshot.Daemons) == 0 && snapshot.Revision == registry.Snapshot().Revision
2238	})
2239	require.Empty(t, stored.Removed)
2240
2241	// Reopening over the durable snapshot adopts empty membership without the
2242	// stale tombstone.
2243	reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil)
2244	require.NoError(t, err)
2245	require.Empty(t, reopened.Snapshot().Daemons)
2246	require.Empty(t, reopened.Snapshot().Removed)
2247}
2248
2249func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
2250	clock := newManualClock(time.Unix(100, 0))
2251	probe := newTestProbe(1)
2252	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2253
2254	sub := registry.Subscribe()
2255	reg := registration(t, "example.test", 1)
2256	for generation := domain.RemoteGeneration(1); generation <= 10; generation++ {
2257		reg.Generation = generation
2258		require.NoError(t, registry.setHosts(hostRecords(reg)))
2259	}
2260	// A subscriber that never drains is bounded to one pending wake and
2261	// never blocks publication.
2262	require.Len(t, sub.Changed(), 1)
2263
2264	// Drain the pending wake, then close: a closed subscriber never receives
2265	// another notification, and closing is idempotent.
2266	<-sub.Changed()
2267	sub.Close()
2268	sub.Close()
2269	reg.Generation++
2270	require.NoError(t, registry.setHosts(hostRecords(reg)))
2271	select {
2272	case <-sub.Changed():
2273		t.Fatal("closed subscription received a notification")
2274	default:
2275	}
2276
2277	ctx, cancel := context.WithCancel(context.Background())
2278	done := make(chan struct{})
2279	go func() { defer close(done); registry.Run(ctx) }()
2280	registry.RequestProbe(reg.Endpoint)
2281	receiveCall(t, probe)
2282	cancel()
2283	select {
2284	case <-done:
2285	case <-time.After(time.Second):
2286		t.Fatal("registry did not stop after cancellation")
2287	}
2288	select {
2289	case <-probe.canceled:
2290	case <-time.After(time.Second):
2291		t.Fatal("in-flight probe did not observe cancellation")
2292	}
2293	// Cleanup settles the projection: no host stays in flight.
2294	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2295		return !host.Checking
2296	})
2297	require.Equal(t, domain.RemoteAvailabilityUnknown, host.Availability)
2298}
2299
2300func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
2301	clock := newManualClock(time.Unix(100, 0))
2302	probe := newTestProbe(1)
2303	logger, logs := testLogger()
2304	store := &testStore{}
2305	registry, err := NewRegistry(1, store, probe, clock, logger)
2306	require.NoError(t, err)
2307	reg := registration(t, "example.test", 1)
2308	require.NoError(t, registry.setHosts(hostRecords(reg)))
2309
2310	ctx, cancel := context.WithCancel(context.Background())
2311	done := make(chan struct{})
2312	go func() { defer close(done); registry.Run(ctx) }()
2313	registry.RequestProbe(reg.Endpoint)
2314
2315	call := receiveCall(t, probe)
2316	require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2317		return host.Checking
2318	}).Checking)
2319
2320	// A concurrent Run is refused instead of duplicating scheduling.
2321	refused := make(chan struct{})
2322	go func() { defer close(refused); registry.Run(context.Background()) }()
2323	select {
2324	case <-refused:
2325	case <-time.After(time.Second):
2326		t.Fatal("second Run did not return")
2327	}
2328	require.Contains(t, logs.String(), "Run refused")
2329
2330	cancel()
2331	select {
2332	case <-done:
2333	case <-time.After(time.Second):
2334		t.Fatal("registry did not stop after cancellation")
2335	}
2336	// Cleanup publishes the settled projection: nothing stays in flight.
2337	require.False(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2338		return !host.Checking
2339	}).Checking)
2340
2341	// The retired completion can never be applied once the run is over.
2342	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
2343	settled := registry.Snapshot()
2344	require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Daemons[0].Availability)
2345	require.Zero(t, settled.Daemons[0].ConsecutiveFailures)
2346
2347	// Persistence is flushed before shutdown and no write outlives it.
2348	stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2349		return snapshot.Revision >= settled.Revision
2350	})
2351	require.Equal(t, settled.Revision, stored.Revision)
2352	require.False(t, stored.Daemons[0].Checking)
2353	writes := store.writeCount()
2354	require.NoError(t, registry.setHosts(nil))
2355	require.Equal(t, writes, store.writeCount())
2356	require.Contains(t, logs.String(), "refused after shutdown")
2357
2358	// A restart is refused rather than racing a stale scheduler.
2359	restarted := make(chan struct{})
2360	go func() { defer close(restarted); registry.Run(context.Background()) }()
2361	select {
2362	case <-restarted:
2363	case <-time.After(time.Second):
2364		t.Fatal("restarted Run did not return")
2365	}
2366	requireNoProbeWithin(t, probe, probeGrace)
2367}
2368
2369func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) {
2370	start := time.Unix(100, 0)
2371	clock := newManualClock(start)
2372	probe := newTestProbe(2)
2373	registry := newTestRegistry(t, 42, newTestStore(), probe, clock)
2374
2375	var last ports.BrokerRevision
2376	observe := func() {
2377		t.Helper()
2378		snapshot := registry.Snapshot()
2379		require.Equal(t, ports.BrokerEpoch(42), snapshot.Epoch)
2380		require.Greater(t, snapshot.Revision, last)
2381		last = snapshot.Revision
2382	}
2383	observe()
2384
2385	first := registration(t, "a.test", 1)
2386	second := registration(t, "b.test", 2)
2387	require.NoError(t, registry.setHosts(hostRecords(first)))
2388	observe()
2389	require.NoError(t, registry.setHosts(hostRecords(first, second)))
2390	observe()
2391
2392	startRegistry(t, registry)
2393	for range 2 {
2394		call := receiveCall(t, probe)
2395		call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
2396		waitHost(t, registry, call.registration.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2397			return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
2398		})
2399		observe()
2400	}
2401}
2402
2403// TestRegistryProbeCannotOverrideConfiguredAuthority proves the ownership rule:
2404// a probe supplies observed state only. Configured authority (local flag,
2405// endpoint, registration, policy, display origin, rank) is stamped by the
2406// registry from its own host records and a hostile probe result can never
2407// override it.
2408func TestRegistryProbeCannotOverrideConfiguredAuthority(t *testing.T) {
2409	clock := newManualClock(time.Unix(100, 0))
2410	probe := newTestProbe(1)
2411	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2412	reg := registration(t, "user@host:22", 1)
2413	require.NoError(t, registry.setHosts(hostRecords(reg)))
2414	startRegistry(t, registry)
2415
2416	call := receiveCall(t, probe)
2417	require.True(t, call.registration.Equal(reg))
2418
2419	// Every authority field is invented by the probe, including a local flag,
2420	// a foreign endpoint and registration, a different policy, and display/rank
2421	// hints. None of it may reach the published projection.
2422	hostilePolicy := poolPolicy()
2423	hostilePolicy.Trust = "attacker"
2424	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
2425		Local:         true,
2426		Endpoint:      "attacker@evil",
2427		DisplayOrigin: "evil",
2428		Rank:          42,
2429		Registration:  registration(t, "attacker@evil", 9),
2430		Policy:        hostilePolicy,
2431		Availability:  domain.RemoteAvailabilityReachable,
2432	}}
2433	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2434		return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
2435	})
2436	require.False(t, host.Local)
2437	require.Equal(t, reg.Endpoint, host.Endpoint)
2438	require.Equal(t, domain.RemoteDisplayOrigin(reg.Endpoint), host.DisplayOrigin)
2439	require.Zero(t, host.Rank)
2440	require.True(t, host.Registration.Equal(reg))
2441	require.Equal(t, poolPolicy(), host.Policy)
2442	require.NotEqual(t, hostilePolicy, host.Policy)
2443	require.Len(t, registry.Snapshot().Daemons, 1)
2444}
2445
2446// TestRegistryPublishesObservedDaemonIdentity proves observed identity,
2447// incarnation, protocol version, and capabilities flow from a probe result into
2448// the published snapshot; configured authority is stamped alongside them.
2449func TestRegistryPublishesObservedDaemonIdentity(t *testing.T) {
2450	clock := newManualClock(time.Unix(100, 0))
2451	probe := newTestProbe(1)
2452	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2453	reg := registration(t, "user@host:22", 1)
2454	require.NoError(t, registry.setHosts(hostRecords(reg)))
2455	startRegistry(t, registry)
2456
2457	call := receiveCall(t, probe)
2458	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
2459		Identity:        "authed-daemon",
2460		Incarnation:     ports.BrokerDaemonIncarnation{7, 11},
2461		ProtocolVersion: protocol.Version,
2462		Capabilities:    protocol.CapabilityResume | protocol.CapabilityUDP,
2463		Availability:    domain.RemoteAvailabilityReachable,
2464	}}
2465	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2466		return !host.Checking && host.Identity != ""
2467	})
2468	require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), host.Identity)
2469	require.Equal(t, ports.BrokerDaemonIncarnation{7, 11}, host.Incarnation)
2470	require.Equal(t, protocol.Version, host.ProtocolVersion)
2471	require.Equal(t, protocol.CapabilityResume|protocol.CapabilityUDP, host.Capabilities)
2472	require.Equal(t, poolPolicy(), host.Policy)
2473	require.NoError(t, registry.Snapshot().Validate())
2474}
2475
2476// TestRegistryRejectsObservedIdentityWithoutProtocolVersion pins the partial
2477// identity rule: an observation that reports identity and incarnation without a
2478// protocol version fails ports validation and is classified as an invalid
2479// response, so it never publishes a half-observed daemon.
2480func TestRegistryRejectsObservedIdentityWithoutProtocolVersion(t *testing.T) {
2481	// The contract itself rejects partial identity.
2482	require.Error(t, (ports.BrokerDaemonObservation{
2483		DisplayOrigin: "host",
2484		Policy:        poolPolicy(),
2485		Identity:      "authed-daemon",
2486		Incarnation:   ports.BrokerDaemonIncarnation{7},
2487		Availability:  domain.RemoteAvailabilityReachable,
2488	}).Validate())
2489
2490	clock := newManualClock(time.Unix(100, 0))
2491	probe := newTestProbe(1)
2492	registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2493	reg := registration(t, "user@host:22", 1)
2494	require.NoError(t, registry.setHosts(hostRecords(reg)))
2495	startRegistry(t, registry)
2496
2497	call := receiveCall(t, probe)
2498	call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
2499		Identity:     "authed-daemon",
2500		Incarnation:  ports.BrokerDaemonIncarnation{7},
2501		Availability: domain.RemoteAvailabilityReachable,
2502	}}
2503	host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2504		return !host.Checking && host.ConsecutiveFailures == 1
2505	})
2506	require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)
2507	require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind)
2508	require.Empty(t, host.Identity, "partial identity must never be published")
2509	require.True(t, host.Incarnation.IsZero())
2510}
2511
2512// TestRegistryPublishesRemoteDaemonsInRegistrationOrder pins the publication
2513// invariant: remote daemons appear in durable membership registration order (no
2514// local producer exists yet, so the snapshot carries remotes only).
2515func TestRegistryPublishesRemoteDaemonsInRegistrationOrder(t *testing.T) {
2516	registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
2517	first := registration(t, "a.test", 1)
2518	second := registration(t, "b.test", 2)
2519	third := registration(t, "c.test", 3)
2520	require.NoError(t, registry.setHosts(hostRecords(first, second)))
2521	require.Equal(t, []string{"a.test", "b.test"}, daemonEndpoints(registry.Snapshot()))
2522
2523	// Membership changes publish in the new registration order, not sorted and
2524	// not map order.
2525	require.NoError(t, registry.setHosts(hostRecords(third, second, first)))
2526	require.Equal(t, []string{"c.test", "b.test", "a.test"}, daemonEndpoints(registry.Snapshot()))
2527}
2528
2529func daemonEndpoints(snapshot ports.BrokerSnapshot) []string {
2530	endpoints := make([]string, 0, len(snapshot.Daemons))
2531	for _, daemon := range snapshot.Daemons {
2532		endpoints = append(endpoints, daemon.Endpoint)
2533	}
2534	return endpoints
2535}
2536
2537// TestDurableProjectionDropsLocalObservationAndPolicy pins the durable format
2538// decision: the persistable copy carries remote observations only (a local
2539// daemon observation is process-local) and omits policy, because membership is
2540// the single policy authority and the loader re-stamps it.
2541func TestDurableProjectionDropsLocalObservationAndPolicy(t *testing.T) {
2542	reg := registration(t, "user@host:22", 1)
2543	snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
2544		{Local: true, DisplayOrigin: "local", Policy: poolPolicy(), Availability: domain.RemoteAvailabilityReachable},
2545		{Endpoint: reg.Endpoint, DisplayOrigin: domain.RemoteDisplayOrigin(reg.Endpoint), Registration: reg, Policy: poolPolicy(), Availability: domain.RemoteAvailabilityReachable},
2546	}}
2547	out := durable(snapshot)
2548	require.Len(t, out.Daemons, 1)
2549	require.False(t, out.Daemons[0].Local)
2550	require.Equal(t, reg.Endpoint, out.Daemons[0].Endpoint)
2551	require.Equal(t, ports.BrokerPolicy{}, out.Daemons[0].Policy)
2552	require.Nil(t, out.Removed)
2553	// The published snapshot the caller handed in is untouched.
2554	require.Len(t, snapshot.Daemons, 2)
2555	require.Equal(t, poolPolicy(), snapshot.Daemons[0].Policy)
2556}
2557
2558// TestHostDisplayOriginIsBounded pins the presentation-hint bound: a derived
2559// origin is clamped rune-safely at the ports limit so an over-long configured
2560// endpoint still yields a valid publication instead of an unobservable host.
2561func TestHostDisplayOriginIsBounded(t *testing.T) {
2562	require.Equal(t, "host:22", hostDisplayOrigin("user@host:22"))
2563	require.Equal(t, "plain.test", hostDisplayOrigin("plain.test"))
2564
2565	origin := hostDisplayOrigin("user@" + strings.Repeat("€", 100))
2566	require.LessOrEqual(t, len(origin), ports.BrokerMaxDisplayOriginBytes)
2567	require.True(t, utf8.ValidString(origin))
2568	require.NotEmpty(t, origin)
2569}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close